From b6da6eef9f78bde54e6e4c56518a67d803383b84 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 10:27:13 -0500 Subject: [PATCH 1/4] client: Add passwords to graphic conversions - Accept open passwords for PNG, BMP, GIF, JPEG, and TIFF helpers - Serialize passwords through the shared sync and async payload contract - Cover request serialization and empty-password rejection Assisted-by: Codex --- src/pdfrest/client.py | 40 ++++++++++ src/pdfrest/models/_internal.py | 4 + tests/test_convert_to_bmp.py | 6 ++ tests/test_convert_to_gif.py | 6 ++ tests/test_convert_to_jpeg.py | 6 ++ tests/test_convert_to_png.py | 6 ++ tests/test_convert_to_tiff.py | 6 ++ tests/test_graphic_payload_validation.py | 94 +++++++++++++++++++++++- 8 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 08b7e757..9c8a8b1f 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 e53827f1..e1038545 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/test_convert_to_bmp.py b/tests/test_convert_to_bmp.py index a07eb749..f5a87455 100644 --- a/tests/test_convert_to_bmp.py +++ b/tests/test_convert_to_bmp.py @@ -20,6 +20,8 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + @pytest.mark.parametrize("color_model", ["rgb", "gray"]) def test_convert_to_bmp_success( @@ -36,6 +38,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 +71,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 +235,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 +271,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 00569c67..88c9d485 100644 --- a/tests/test_convert_to_gif.py +++ b/tests/test_convert_to_gif.py @@ -20,6 +20,8 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + @pytest.mark.parametrize("color_model", ["rgb", "gray"]) def test_convert_to_gif_success( @@ -36,6 +38,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 +71,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 +232,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 +268,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 4ebf5a4e..950171d6 100644 --- a/tests/test_convert_to_jpeg.py +++ b/tests/test_convert_to_jpeg.py @@ -20,6 +20,8 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + @pytest.mark.parametrize("color_model", ["rgb", "cmyk", "gray"]) def test_convert_to_jpeg_success( @@ -36,6 +38,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 +74,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 +432,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 +471,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 01569644..a0551eb4 100644 --- a/tests/test_convert_to_png.py +++ b/tests/test_convert_to_png.py @@ -19,6 +19,8 @@ make_pdf_file, ) +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + @pytest.mark.parametrize("color_model", ["rgb", "rgba", "gray"]) def test_convert_to_png_success( @@ -35,6 +37,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 +75,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 +256,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 +295,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 13fe836d..1d77af8c 100644 --- a/tests/test_convert_to_tiff.py +++ b/tests/test_convert_to_tiff.py @@ -20,6 +20,8 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + @pytest.mark.parametrize( "color_model", @@ -39,6 +41,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 +74,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 +240,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 +276,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 cea6c5a1..ba4c762d 100644 --- a/tests/test_graphic_payload_validation.py +++ b/tests/test_graphic_payload_validation.py @@ -3,9 +3,11 @@ import re from typing import Any +import httpx import pytest from pydantic import ValidationError +from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models._internal import ( BasePdfRestGraphicPayload, BmpPdfRestPayload, @@ -15,7 +17,7 @@ TiffPdfRestPayload, ) -from .graphics_test_helpers import make_pdf_file +from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file PAYLOAD_MODELS: tuple[type[BasePdfRestGraphicPayload[Any]], ...] = ( PngPdfRestPayload, @@ -24,6 +26,96 @@ JpegPdfRestPayload, TiffPdfRestPayload, ) +GRAPHIC_METHOD_NAMES = ( + "convert_to_png", + "convert_to_bmp", + "convert_to_gif", + "convert_to_jpeg", + "convert_to_tiff", +) +_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("method_name", GRAPHIC_METHOD_NAMES) +def test_graphic_client_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, + method_name: str, +) -> 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", + ), + ): + getattr(client, method_name)( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method_name", GRAPHIC_METHOD_NAMES) +async def test_async_graphic_client_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, + method_name: str, +) -> 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 getattr(client, method_name)( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) @pytest.mark.parametrize("payload_model", PAYLOAD_MODELS) From 09ec98b3f7aae00f4683e43f73f239644665b1d9 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 10:27:59 -0500 Subject: [PATCH 2/4] tests: Cover graphic conversion passwords live - Verify encrypted inputs across PNG, BMP, GIF, JPEG, and TIFF - Exercise correct passwords through synchronous and asynchronous clients - Confirm incorrect passwords return an API error Assisted-by: Codex --- tests/live/test_live_graphic_conversions.py | 120 ++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index 9c13ae31..6c3d9d18 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,124 @@ 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: + 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=f"live-password-{endpoint_label}", + ) + + expected_mime, expected_suffix = _expected_file_format(endpoint_label) + _assert_output_files( + response.output_files, + expected_mime=expected_mime, + expected_suffix=expected_suffix, + ) + 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: + 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=f"live-async-password-{endpoint_label}", + ) + + expected_mime, expected_suffix = _expected_file_format(endpoint_label) + _assert_output_files( + response.output_files, + expected_mime=expected_mime, + expected_suffix=expected_suffix, + ) + assert str(response.input_id) == str(encrypted_pdf_for_graphics.id) + + +def test_live_graphic_invalid_password( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + encrypted_pdf_for_graphics: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)password"), + ): + client.convert_to_png( + encrypted_pdf_for_graphics, + password=_WRONG_GRAPHIC_PASSWORD, + ) + + +@pytest.mark.asyncio +async def test_live_async_graphic_invalid_password( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + encrypted_pdf_for_graphics: PdfRestFile, +) -> 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 client.convert_to_png( + encrypted_pdf_for_graphics, + password=_WRONG_GRAPHIC_PASSWORD, + ) + + def test_live_convert_to_png_success( pdfrest_api_key: str, pdfrest_live_base_url: str, From f5837e0d5026085ac4e8d80a21c51c79b6f9463a Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 10:35:57 -0500 Subject: [PATCH 3/4] examples: Demonstrate encrypted PDF image conversion - Add a runnable protected-PDF-to-PNG workflow using an open password - Link the example from the inventory and API guide for discoverability Assisted-by: Codex --- docs/api-guide.md | 5 ++ examples/README.md | 3 + ...t_password_protected_pdf_to_png_example.py | 55 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 examples/convert_to_image/convert_password_protected_pdf_to_png_example.py diff --git a/docs/api-guide.md b/docs/api-guide.md index 60abe3e5..b954b904 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 332917de..63227ae3 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 00000000..934fd4fe --- /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() From 55adabf151f4e473ab7ae3e829a85797969f62c9 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Mon, 19 Oct 2026 13:36:09 -0500 Subject: [PATCH 4/4] tests: Strengthen graphic password coverage - Verify wrong passwords for every image format through both clients - Assert live output count, prefix, URL, and warning state - Keep empty-password client checks in each endpoint test module Assisted-by: Codex --- tests/live/test_live_graphic_conversions.py | 48 +++++++++++----- tests/test_convert_to_bmp.py | 47 ++++++++++++++++ tests/test_convert_to_gif.py | 47 ++++++++++++++++ tests/test_convert_to_jpeg.py | 47 ++++++++++++++++ tests/test_convert_to_png.py | 47 ++++++++++++++++ tests/test_convert_to_tiff.py | 47 ++++++++++++++++ tests/test_graphic_payload_validation.py | 62 +-------------------- 7 files changed, 270 insertions(+), 75 deletions(-) diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index 6c3d9d18..501d22d4 100644 --- a/tests/live/test_live_graphic_conversions.py +++ b/tests/live/test_live_graphic_conversions.py @@ -177,6 +177,7 @@ def test_live_graphic_password( 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, @@ -185,15 +186,18 @@ def test_live_graphic_password( response = client_method( encrypted_pdf_for_graphics, password=_GRAPHIC_PASSWORD, - output_prefix=f"live-password-{endpoint_label}", + output_prefix=output_prefix, ) expected_mime, expected_suffix = _expected_file_format(endpoint_label) - _assert_output_files( - response.output_files, - expected_mime=expected_mime, - expected_suffix=expected_suffix, - ) + 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) @@ -210,6 +214,7 @@ async def test_live_async_graphic_password( 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, @@ -218,22 +223,31 @@ async def test_live_async_graphic_password( response = await client_method( encrypted_pdf_for_graphics, password=_GRAPHIC_PASSWORD, - output_prefix=f"live-async-password-{endpoint_label}", + output_prefix=output_prefix, ) expected_mime, expected_suffix = _expected_file_format(endpoint_label) - _assert_output_files( - response.output_files, - expected_mime=expected_mime, - expected_suffix=expected_suffix, - ) + 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( @@ -242,24 +256,30 @@ def test_live_graphic_invalid_password( ) as client, pytest.raises(PdfRestApiError, match=r"(?i)password"), ): - client.convert_to_png( + 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 client.convert_to_png( + await getattr(client, spec.method_name)( encrypted_pdf_for_graphics, password=_WRONG_GRAPHIC_PASSWORD, ) diff --git a/tests/test_convert_to_bmp.py b/tests/test_convert_to_bmp.py index f5a87455..efe048a4 100644 --- a/tests/test_convert_to_bmp.py +++ b/tests/test_convert_to_bmp.py @@ -23,6 +23,53 @@ _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( monkeypatch: pytest.MonkeyPatch, color_model: str diff --git a/tests/test_convert_to_gif.py b/tests/test_convert_to_gif.py index 88c9d485..97323bc7 100644 --- a/tests/test_convert_to_gif.py +++ b/tests/test_convert_to_gif.py @@ -23,6 +23,53 @@ _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( monkeypatch: pytest.MonkeyPatch, color_model: str diff --git a/tests/test_convert_to_jpeg.py b/tests/test_convert_to_jpeg.py index 950171d6..b87959da 100644 --- a/tests/test_convert_to_jpeg.py +++ b/tests/test_convert_to_jpeg.py @@ -23,6 +23,53 @@ _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( monkeypatch: pytest.MonkeyPatch, color_model: str diff --git a/tests/test_convert_to_png.py b/tests/test_convert_to_png.py index a0551eb4..a7aeb527 100644 --- a/tests/test_convert_to_png.py +++ b/tests/test_convert_to_png.py @@ -22,6 +22,53 @@ _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( monkeypatch: pytest.MonkeyPatch, color_model: str diff --git a/tests/test_convert_to_tiff.py b/tests/test_convert_to_tiff.py index 1d77af8c..cbca3278 100644 --- a/tests/test_convert_to_tiff.py +++ b/tests/test_convert_to_tiff.py @@ -23,6 +23,53 @@ _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", ["rgb", "rgba", "cmyk", "lab", "gray"], diff --git a/tests/test_graphic_payload_validation.py b/tests/test_graphic_payload_validation.py index ba4c762d..d3a42b4c 100644 --- a/tests/test_graphic_payload_validation.py +++ b/tests/test_graphic_payload_validation.py @@ -3,11 +3,9 @@ import re from typing import Any -import httpx import pytest from pydantic import ValidationError -from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models._internal import ( BasePdfRestGraphicPayload, BmpPdfRestPayload, @@ -17,7 +15,7 @@ TiffPdfRestPayload, ) -from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file +from .graphics_test_helpers import make_pdf_file PAYLOAD_MODELS: tuple[type[BasePdfRestGraphicPayload[Any]], ...] = ( PngPdfRestPayload, @@ -26,13 +24,6 @@ JpegPdfRestPayload, TiffPdfRestPayload, ) -GRAPHIC_METHOD_NAMES = ( - "convert_to_png", - "convert_to_bmp", - "convert_to_gif", - "convert_to_jpeg", - "convert_to_tiff", -) _TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. @@ -67,57 +58,6 @@ def test_graphic_payload_rejects_empty_password( ) -@pytest.mark.parametrize("method_name", GRAPHIC_METHOD_NAMES) -def test_graphic_client_rejects_empty_password( - monkeypatch: pytest.MonkeyPatch, - method_name: str, -) -> 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", - ), - ): - getattr(client, method_name)( - make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), - password="", - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("method_name", GRAPHIC_METHOD_NAMES) -async def test_async_graphic_client_rejects_empty_password( - monkeypatch: pytest.MonkeyPatch, - method_name: str, -) -> 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 getattr(client, method_name)( - make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), - password="", - ) - - @pytest.mark.parametrize("payload_model", PAYLOAD_MODELS) def test_graphic_payload_accepts_page_range_variants( payload_model: type[BasePdfRestGraphicPayload[Any]],