diff --git a/docs/api-guide.md b/docs/api-guide.md index 60abe3e..b954b90 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -158,6 +158,11 @@ formats. [convert_to_jpeg][pdfrest.PdfRestClient.convert_to_jpeg], [convert_to_tiff][pdfrest.PdfRestClient.convert_to_tiff] +All PDF-to-image helpers accept `password` for opening an encrypted input PDF. +See the +[password-protected PDF example](https://github.com/pdfrest/pdfrest-python/blob/main/examples/convert_to_image/convert_password_protected_pdf_to_png_example.py) +for the complete upload, protection, and conversion workflow. + ## Forms data workflows Use this group when users need to transfer form field values in/out of PDFs. diff --git a/examples/README.md b/examples/README.md index 332917d..63227ae 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,6 +28,9 @@ supported interpreter matrix. format-specific options. - `examples/convert_postscript_to_pdf/convert_postscript_with_job_options_example.py` – convert PostScript to PDF with an uploaded custom `.joboptions` profile. +- `examples/convert_to_image/convert_password_protected_pdf_to_png_example.py` – + convert a password-protected PDF into PNG images by supplying its open + password. - `examples/delete/delete_example.py` – demonstrate file deletion (sync + async variants). - `examples/extract_text/extract_pdf_text_example.py` – run `extract_pdf_text` diff --git a/examples/convert_to_image/convert_password_protected_pdf_to_png_example.py b/examples/convert_to_image/convert_password_protected_pdf_to_png_example.py new file mode 100644 index 0000000..934fd4f --- /dev/null +++ b/examples/convert_to_image/convert_password_protected_pdf_to_png_example.py @@ -0,0 +1,55 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Convert a password-protected PDF into PNG images. + +This sample demonstrates how to: + +1. Upload the bundled ``examples/resources/report.pdf`` resource. +2. Add an open password to create a protected PDF. +3. Supply that password to ``convert_to_png``. +4. Print metadata for every generated PNG image. + +Set ``PDFREST_API_KEY``, then run from the repository root with +``uv run examples/convert_to_image/convert_password_protected_pdf_to_png_example.py``. +The input PDF is included in the repository, so no additional files are required. +""" + +from __future__ import annotations + +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import PdfRestClient + +RESOURCE = Path(__file__).resolve().parents[1] / "resources" / "report.pdf" +PASSWORD = "example-password" # noqa: S105 - Demonstration-only password. + + +def convert_password_protected_pdf() -> None: + """Protect the sample PDF, then convert its pages with the same password.""" + load_dotenv() + + with PdfRestClient() as client: + uploaded = client.files.create_from_paths([RESOURCE])[0] + protected = client.add_open_password( + uploaded, + new_open_password=PASSWORD, + output="password-protected-report", + ).output_file + response = client.convert_to_png( + protected, + password=PASSWORD, + output_prefix="password-protected-report-page", + ) + + print(f"Created {len(response.output_files)} PNG image(s)") + for output in response.output_files: + print(f"{output.name}: {output.type}, {output.size} bytes") + print(f" Download URL: {output.url}") + + +if __name__ == "__main__": # pragma: no cover - manual example + convert_password_protected_pdf() diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 08b7e75..9c8a8b1 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -5780,6 +5780,7 @@ def convert_to_png( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: PngColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -5796,6 +5797,7 @@ def convert_to_png( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -5820,6 +5822,8 @@ def convert_to_png( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return self._convert_to_graphic( endpoint="/png", @@ -5838,6 +5842,7 @@ def convert_to_bmp( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: BmpColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -5854,6 +5859,7 @@ def convert_to_bmp( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -5878,6 +5884,8 @@ def convert_to_bmp( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return self._convert_to_graphic( endpoint="/bmp", @@ -5896,6 +5904,7 @@ def convert_to_gif( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: GifColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -5912,6 +5921,7 @@ def convert_to_gif( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -5936,6 +5946,8 @@ def convert_to_gif( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return self._convert_to_graphic( endpoint="/gif", @@ -5954,6 +5966,7 @@ def convert_to_jpeg( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: JpegColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", jpeg_quality: int = 75, @@ -5971,6 +5984,7 @@ def convert_to_jpeg( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. jpeg_quality: JPEG quality setting (1-100) controlling compression level and output fidelity. @@ -5997,6 +6011,8 @@ def convert_to_jpeg( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return self._convert_to_graphic( endpoint="/jpg", @@ -6015,6 +6031,7 @@ def convert_to_tiff( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: TiffColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -6031,6 +6048,7 @@ def convert_to_tiff( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -6055,6 +6073,8 @@ def convert_to_tiff( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return self._convert_to_graphic( endpoint="/tif", @@ -9108,6 +9128,7 @@ async def convert_to_png( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: PngColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -9124,6 +9145,7 @@ async def convert_to_png( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -9148,6 +9170,8 @@ async def convert_to_png( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return await self._convert_to_graphic( endpoint="/png", @@ -9166,6 +9190,7 @@ async def convert_to_bmp( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: BmpColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -9182,6 +9207,7 @@ async def convert_to_bmp( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -9206,6 +9232,8 @@ async def convert_to_bmp( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return await self._convert_to_graphic( endpoint="/bmp", @@ -9224,6 +9252,7 @@ async def convert_to_gif( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: GifColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -9240,6 +9269,7 @@ async def convert_to_gif( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -9264,6 +9294,8 @@ async def convert_to_gif( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return await self._convert_to_graphic( endpoint="/gif", @@ -9282,6 +9314,7 @@ async def convert_to_jpeg( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: JpegColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", jpeg_quality: int = 75, @@ -9299,6 +9332,7 @@ async def convert_to_jpeg( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. jpeg_quality: JPEG quality setting (1-100) controlling compression level and output fidelity. @@ -9325,6 +9359,8 @@ async def convert_to_jpeg( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return await self._convert_to_graphic( endpoint="/jpg", @@ -9343,6 +9379,7 @@ async def convert_to_tiff( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: TiffColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -9359,6 +9396,7 @@ async def convert_to_tiff( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -9383,6 +9421,8 @@ async def convert_to_tiff( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return await self._convert_to_graphic( endpoint="/tif", diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index e53827f..e103854 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1648,6 +1648,10 @@ class BasePdfRestGraphicPayload(BaseModel, Generic[ColorModelT]): PlainSerializer(_serialize_page_ranges), ] resolution: Annotated[int, Field(ge=12, le=2400, default=300)] + password: Annotated[ + str | None, + Field(min_length=1, default=None), + ] = None color_model: Annotated[ColorModelT, Field(default=...)] smoothing: Annotated[ list[Literal["none", "all", "text", "line", "image"]], diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index 9c13ae3..501d22d 100644 --- a/tests/live/test_live_graphic_conversions.py +++ b/tests/live/test_live_graphic_conversions.py @@ -43,6 +43,8 @@ class _GraphicEndpointSpec(NamedTuple): "jpeg": ("image/jpeg", ".jpg"), "tiff": ("image/tiff", ".tif"), } +_GRAPHIC_PASSWORD = "graphic-password" # noqa: S105 - Test-only password. +_WRONG_GRAPHIC_PASSWORD = "wrong-password" # noqa: S105 - Test-only password. def _enumerate_color_models( @@ -145,6 +147,144 @@ def uploaded_20_page_pdf( return client.files.create_from_paths([resource])[0] +@pytest.fixture(scope="module") +def encrypted_pdf_for_graphics( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + return client.add_open_password( + uploaded, + new_open_password=_GRAPHIC_PASSWORD, + output="graphic-password-source", + ).output_file + + +@pytest.mark.parametrize( + ("endpoint_label", "spec"), + PAYLOAD_MODELS.items(), + ids=list(PAYLOAD_MODELS), +) +def test_live_graphic_password( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + encrypted_pdf_for_graphics: PdfRestFile, + endpoint_label: str, + spec: _GraphicEndpointSpec, +) -> None: + output_prefix = f"live-password-{endpoint_label}" + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + client_method = getattr(client, spec.method_name) + response = client_method( + encrypted_pdf_for_graphics, + password=_GRAPHIC_PASSWORD, + output_prefix=output_prefix, + ) + + expected_mime, expected_suffix = _expected_file_format(endpoint_label) + assert len(response.output_files) == 1 + output_file = response.output_file + assert output_file.name.startswith(output_prefix) + assert output_file.name.endswith(expected_suffix) + assert output_file.type == expected_mime + assert output_file.size > 0 + assert output_file.url + assert response.warning is None + assert str(response.input_id) == str(encrypted_pdf_for_graphics.id) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("endpoint_label", "spec"), + PAYLOAD_MODELS.items(), + ids=list(PAYLOAD_MODELS), +) +async def test_live_async_graphic_password( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + encrypted_pdf_for_graphics: PdfRestFile, + endpoint_label: str, + spec: _GraphicEndpointSpec, +) -> None: + output_prefix = f"live-async-password-{endpoint_label}" + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + client_method = getattr(client, spec.method_name) + response = await client_method( + encrypted_pdf_for_graphics, + password=_GRAPHIC_PASSWORD, + output_prefix=output_prefix, + ) + + expected_mime, expected_suffix = _expected_file_format(endpoint_label) + assert len(response.output_files) == 1 + output_file = response.output_file + assert output_file.name.startswith(output_prefix) + assert output_file.name.endswith(expected_suffix) + assert output_file.type == expected_mime + assert output_file.size > 0 + assert output_file.url + assert response.warning is None + assert str(response.input_id) == str(encrypted_pdf_for_graphics.id) + + +@pytest.mark.parametrize( + "spec", + PAYLOAD_MODELS.values(), + ids=list(PAYLOAD_MODELS), +) +def test_live_graphic_invalid_password( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + encrypted_pdf_for_graphics: PdfRestFile, + spec: _GraphicEndpointSpec, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)password"), + ): + getattr(client, spec.method_name)( + encrypted_pdf_for_graphics, + password=_WRONG_GRAPHIC_PASSWORD, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "spec", + PAYLOAD_MODELS.values(), + ids=list(PAYLOAD_MODELS), +) +async def test_live_async_graphic_invalid_password( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + encrypted_pdf_for_graphics: PdfRestFile, + spec: _GraphicEndpointSpec, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)password"): + await getattr(client, spec.method_name)( + encrypted_pdf_for_graphics, + password=_WRONG_GRAPHIC_PASSWORD, + ) + + def test_live_convert_to_png_success( pdfrest_api_key: str, pdfrest_live_base_url: str, diff --git a/tests/test_convert_to_bmp.py b/tests/test_convert_to_bmp.py index a07eb74..efe048a 100644 --- a/tests/test_convert_to_bmp.py +++ b/tests/test_convert_to_bmp.py @@ -20,6 +20,55 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + + +def test_convert_to_bmp_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client, + pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ), + ): + client.convert_to_bmp( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + + +@pytest.mark.asyncio +async def test_async_convert_to_bmp_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + with pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ): + await client.convert_to_bmp( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + @pytest.mark.parametrize("color_model", ["rgb", "gray"]) def test_convert_to_bmp_success( @@ -36,6 +85,7 @@ def test_convert_to_bmp_success( "output_prefix": "converted", "page_range": ["1", "2-3"], "resolution": 600, + "password": _TEST_PASSWORD, "color_model": color_model, "smoothing": ["text", "image"], } @@ -68,6 +118,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="converted", page_range=["1", "2-3"], resolution=600, + password=_TEST_PASSWORD, color_model=color_model, # type: ignore[arg-type] smoothing=["text", "image"], ) @@ -231,6 +282,7 @@ async def test_async_convert_to_bmp_success( "output_prefix": "async-output", "page_range": "1-2", "resolution": 450, + "password": _TEST_PASSWORD, "color_model": "gray", "smoothing": ["all"], } @@ -266,6 +318,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="async-output", page_range="1-2", resolution=450, + password=_TEST_PASSWORD, color_model="gray", smoothing=["all"], ) diff --git a/tests/test_convert_to_gif.py b/tests/test_convert_to_gif.py index 00569c6..97323bc 100644 --- a/tests/test_convert_to_gif.py +++ b/tests/test_convert_to_gif.py @@ -20,6 +20,55 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + + +def test_convert_to_gif_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client, + pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ), + ): + client.convert_to_gif( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + + +@pytest.mark.asyncio +async def test_async_convert_to_gif_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + with pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ): + await client.convert_to_gif( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + @pytest.mark.parametrize("color_model", ["rgb", "gray"]) def test_convert_to_gif_success( @@ -36,6 +85,7 @@ def test_convert_to_gif_success( "output_prefix": "converted", "page_range": ["1", "3-4"], "resolution": 500, + "password": _TEST_PASSWORD, "color_model": color_model, "smoothing": ["text", "image"], } @@ -68,6 +118,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="converted", page_range=["1", "3-4"], resolution=500, + password=_TEST_PASSWORD, color_model=color_model, # type: ignore[arg-type] smoothing=["text", "image"], ) @@ -228,6 +279,7 @@ async def test_async_convert_to_gif_success( "output_prefix": "async-output", "page_range": "1-2", "resolution": 400, + "password": _TEST_PASSWORD, "color_model": "gray", "smoothing": ["all"], } @@ -263,6 +315,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="async-output", page_range="1-2", resolution=400, + password=_TEST_PASSWORD, color_model="gray", smoothing=["all"], ) diff --git a/tests/test_convert_to_jpeg.py b/tests/test_convert_to_jpeg.py index 4ebf5a4..b87959d 100644 --- a/tests/test_convert_to_jpeg.py +++ b/tests/test_convert_to_jpeg.py @@ -20,6 +20,55 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + + +def test_convert_to_jpeg_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client, + pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ), + ): + client.convert_to_jpeg( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + + +@pytest.mark.asyncio +async def test_async_convert_to_jpeg_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + with pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ): + await client.convert_to_jpeg( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + @pytest.mark.parametrize("color_model", ["rgb", "cmyk", "gray"]) def test_convert_to_jpeg_success( @@ -36,6 +85,7 @@ def test_convert_to_jpeg_success( "output_prefix": "converted", "page_range": ["1", "2-3"], "resolution": 450, + "password": _TEST_PASSWORD, "color_model": color_model, "jpeg_quality": 90, "smoothing": ["text", "image"], @@ -71,6 +121,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="converted", page_range=["1", "2-3"], resolution=450, + password=_TEST_PASSWORD, color_model=color_model, # type: ignore[arg-type] smoothing=["text", "image"], jpeg_quality=90, @@ -428,6 +479,7 @@ async def test_async_convert_to_jpeg_success( "output_prefix": "async-output", "page_range": "1-2", "resolution": 500, + "password": _TEST_PASSWORD, "color_model": color_model, "jpeg_quality": 85, "smoothing": ["all"], @@ -466,6 +518,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="async-output", page_range="1-2", resolution=500, + password=_TEST_PASSWORD, color_model=color_model, smoothing=["all"], jpeg_quality=85, diff --git a/tests/test_convert_to_png.py b/tests/test_convert_to_png.py index 0156964..a7aeb52 100644 --- a/tests/test_convert_to_png.py +++ b/tests/test_convert_to_png.py @@ -19,6 +19,55 @@ make_pdf_file, ) +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + + +def test_convert_to_png_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client, + pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ), + ): + client.convert_to_png( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + + +@pytest.mark.asyncio +async def test_async_convert_to_png_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + with pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ): + await client.convert_to_png( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + @pytest.mark.parametrize("color_model", ["rgb", "rgba", "gray"]) def test_convert_to_png_success( @@ -35,6 +84,7 @@ def test_convert_to_png_success( "output_prefix": "converted", "page_range": ["1", "2-3"], "resolution": 600, + "password": _TEST_PASSWORD, "color_model": color_model, "smoothing": ["text", "image"], } @@ -72,6 +122,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="converted", page_range=["1", "2-3"], resolution=600, + password=_TEST_PASSWORD, color_model=color_model, # type: ignore[arg-type] smoothing=["text", "image"], ) @@ -252,6 +303,7 @@ async def test_async_convert_to_png_success(monkeypatch: pytest.MonkeyPatch) -> "output_prefix": "async-output", "page_range": "1-2", "resolution": 450, + "password": _TEST_PASSWORD, "color_model": "rgb", "smoothing": ["all"], } @@ -290,6 +342,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="async-output", page_range="1-2", resolution=450, + password=_TEST_PASSWORD, color_model="rgb", smoothing=["all"], ) diff --git a/tests/test_convert_to_tiff.py b/tests/test_convert_to_tiff.py index 13fe836..cbca327 100644 --- a/tests/test_convert_to_tiff.py +++ b/tests/test_convert_to_tiff.py @@ -20,6 +20,55 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + + +def test_convert_to_tiff_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client, + pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ), + ): + client.convert_to_tiff( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + + +@pytest.mark.asyncio +async def test_async_convert_to_tiff_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + with pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ): + await client.convert_to_tiff( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + @pytest.mark.parametrize( "color_model", @@ -39,6 +88,7 @@ def test_convert_to_tiff_success( "output_prefix": "converted", "page_range": ["1", "last"], "resolution": 600, + "password": _TEST_PASSWORD, "color_model": color_model, "smoothing": ["text", "image"], } @@ -71,6 +121,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="converted", page_range=["1", "last"], resolution=600, + password=_TEST_PASSWORD, color_model=color_model, # type: ignore[arg-type] smoothing=["text", "image"], ) @@ -236,6 +287,7 @@ async def test_async_convert_to_tiff_success( "output_prefix": "async-output", "page_range": "1-2", "resolution": 500, + "password": _TEST_PASSWORD, "color_model": "rgba", "smoothing": ["all"], } @@ -271,6 +323,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="async-output", page_range="1-2", resolution=500, + password=_TEST_PASSWORD, color_model="rgba", smoothing=["all"], ) diff --git a/tests/test_graphic_payload_validation.py b/tests/test_graphic_payload_validation.py index cea6c5a..d3a42b4 100644 --- a/tests/test_graphic_payload_validation.py +++ b/tests/test_graphic_payload_validation.py @@ -24,6 +24,38 @@ JpegPdfRestPayload, TiffPdfRestPayload, ) +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + + +@pytest.mark.parametrize("payload_model", PAYLOAD_MODELS) +def test_graphic_payload_serializes_password( + payload_model: type[BasePdfRestGraphicPayload[Any]], +) -> None: + payload = payload_model.model_validate( + { + "files": [make_pdf_file("12345678-1234-4abc-8def-1234567890ab")], + "password": _TEST_PASSWORD, + } + ) + + data = payload.model_dump(mode="json", by_alias=True, exclude_none=True) + assert data["password"] == _TEST_PASSWORD + + +@pytest.mark.parametrize("payload_model", PAYLOAD_MODELS) +def test_graphic_payload_rejects_empty_password( + payload_model: type[BasePdfRestGraphicPayload[Any]], +) -> None: + with pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ): + payload_model.model_validate( + { + "files": [make_pdf_file("12345678-1234-4abc-8def-1234567890ab")], + "password": "", + } + ) @pytest.mark.parametrize("payload_model", PAYLOAD_MODELS)