From ec5c713ac805b9d96b90f53c69406c5e7395b3a0 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Mon, 12 Jan 2026 14:04:16 -0600 Subject: [PATCH 1/8] client: add blank-pdf methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `blank_pdf()` sync and async methods - Ensure compatibility with Blank PDF response: - Allow PdfRestRawFileResponse.input_id to default empty so missing inputId doesn’t fail validation - When normalizing file responses, fall back to raw ids (outputId) when inputId is absent for blank-pdf - Document blank-pdf handling to keep response construction working without server-provided input ids Assisted-by: Codex --- src/pdfrest/client.py | 81 ++++++++ src/pdfrest/models/_internal.py | 59 +++++- src/pdfrest/models/public.py | 1 - src/pdfrest/types/__init__.py | 4 + src/pdfrest/types/public.py | 4 + tests/live/test_live_blank_pdf.py | 103 ++++++++++ tests/test_blank_pdf.py | 311 ++++++++++++++++++++++++++++++ 7 files changed, 561 insertions(+), 2 deletions(-) create mode 100644 tests/live/test_live_blank_pdf.py create mode 100644 tests/test_blank_pdf.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 61c19397..8e24301a 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -85,6 +85,7 @@ PdfAddAttachmentPayload, PdfAddImagePayload, PdfAddTextPayload, + PdfBlankPayload, PdfCompressPayload, PdfDecryptPayload, PdfEncryptPayload, @@ -128,7 +129,9 @@ PdfAType, PdfInfoQuery, PdfMergeInput, + PdfPageOrientation, PdfPageSelection, + PdfPageSize, PdfRedactionInstruction, PdfRestriction, PdfRGBColor, @@ -3071,6 +3074,45 @@ def add_attachment_to_pdf( timeout=timeout, ) + def blank_pdf( + self, + *, + page_size: PdfPageSize, + page_count: int, + page_orientation: PdfPageOrientation | None = None, + custom_height: float | None = None, + custom_width: float | None = None, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Create a blank PDF with the specified size, count, and orientation.""" + + payload: dict[str, Any] = { + "page_size": page_size, + "page_count": page_count, + } + if page_orientation is not None: + payload["page_orientation"] = page_orientation + if custom_height is not None: + payload["custom_height"] = custom_height + if custom_width is not None: + payload["custom_width"] = custom_width + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/blank-pdf", + payload=payload, + payload_model=PdfBlankPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def flatten_transparencies( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -4477,6 +4519,45 @@ async def add_attachment_to_pdf( timeout=timeout, ) + async def blank_pdf( + self, + *, + page_size: PdfPageSize, + page_count: int, + page_orientation: PdfPageOrientation | None = None, + custom_height: float | None = None, + custom_width: float | None = None, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously create a blank PDF with the specified size.""" + + payload: dict[str, Any] = { + "page_size": page_size, + "page_count": page_count, + } + if page_orientation is not None: + payload["page_orientation"] = page_orientation + if custom_height is not None: + payload["custom_height"] = custom_height + if custom_width is not None: + payload["custom_width"] = custom_width + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/blank-pdf", + payload=payload, + payload_model=PdfBlankPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def flatten_transparencies( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 19f67e3f..1c62b081 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -26,6 +26,8 @@ OcrLanguage, PdfAType, PdfInfoQuery, + PdfPageOrientation, + PdfPageSize, PdfRestriction, PdfXType, SummaryFormat, @@ -1365,6 +1367,57 @@ class PdfAddAttachmentPayload(BaseModel): ] = None +class PdfBlankPayload(BaseModel): + """Adapt caller options into a pdfRest-ready blank PDF request payload.""" + + page_size: Annotated[ + PdfPageSize, + Field(serialization_alias="page_size"), + ] + page_count: Annotated[ + int, + Field(serialization_alias="page_count", ge=1, le=1000), + ] + page_orientation: Annotated[ + PdfPageOrientation | None, + Field(serialization_alias="page_orientation", default=None), + ] = None + custom_height: Annotated[ + float | None, + Field(serialization_alias="custom_height", gt=0, default=None), + ] = None + custom_width: Annotated[ + float | None, + Field(serialization_alias="custom_width", gt=0, default=None), + ] = None + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + @model_validator(mode="after") + def _validate_page_configuration(self) -> PdfBlankPayload: + is_custom = self.page_size == "custom" + has_custom_height = self.custom_height is not None + has_custom_width = self.custom_width is not None + if is_custom: + if not (has_custom_height and has_custom_width): + msg = "custom_height and custom_width are required when page_size is 'custom'." + raise ValueError(msg) + if self.page_orientation is not None: + msg = "page_orientation must be omitted when page_size is 'custom'." + raise ValueError(msg) + else: + if self.page_orientation is None: + msg = "page_orientation is required when page_size is not 'custom'." + raise ValueError(msg) + if has_custom_height or has_custom_width: + msg = "custom_height and custom_width can only be provided when page_size is 'custom'." + raise ValueError(msg) + return self + + class PdfRestrictPayload(BaseModel): """Adapt caller options into a pdfRest-ready restrict-PDF request payload.""" @@ -1603,7 +1656,11 @@ class PdfRestRawFileResponse(BaseModel): input_id: Annotated[ list[PdfRestFileID], - Field(alias="inputId", description="The id of the input file"), + Field( + alias="inputId", + description="The id of the input file", + default_factory=list, + ), BeforeValidator(_ensure_list), ] output_urls: Annotated[ diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 99ef5257..811aa112 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -269,7 +269,6 @@ class PdfRestFileBasedResponse(BaseModel): list[PdfRestFileID], Field( description="The ids of the files that were input to the pdfRest operation", - min_length=1, validation_alias=AliasChoices("input_id", "inputId"), ), ] diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index f305cd9e..b61c4b4f 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -18,7 +18,9 @@ PdfInfoQuery, PdfMergeInput, PdfMergeSource, + PdfPageOrientation, PdfPageSelection, + PdfPageSize, PdfRedactionInstruction, PdfRedactionPreset, PdfRedactionType, @@ -51,7 +53,9 @@ "PdfInfoQuery", "PdfMergeInput", "PdfMergeSource", + "PdfPageOrientation", "PdfPageSelection", + "PdfPageSize", "PdfRGBColor", "PdfRedactionInstruction", "PdfRedactionPreset", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 27821ec4..45453446 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -30,7 +30,9 @@ "PdfInfoQuery", "PdfMergeInput", "PdfMergeSource", + "PdfPageOrientation", "PdfPageSelection", + "PdfPageSize", "PdfRGBColor", "PdfRedactionInstruction", "PdfRedactionPreset", @@ -197,3 +199,5 @@ class PdfMergeSource(TypedDict, total=False): ALL_PDF_RESTRICTIONS: tuple[PdfRestriction, ...] = cast( tuple[PdfRestriction, ...], get_args(PdfRestriction) ) +PdfPageSize = Literal["letter", "legal", "ledger", "A3", "A4", "A5", "custom"] +PdfPageOrientation = Literal["portrait", "landscape"] diff --git a/tests/live/test_live_blank_pdf.py b/tests/live/test_live_blank_pdf.py new file mode 100644 index 00000000..07f281e1 --- /dev/null +++ b/tests/live/test_live_blank_pdf.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient + + +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("blank-doc", id="custom-output"), + ], +) +def test_live_blank_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + output_name: str | None, +) -> None: + kwargs: dict[str, str | int] = { + "page_size": "letter", + "page_count": 1, + "page_orientation": "portrait", + } + if output_name is not None: + kwargs["output"] = output_name + + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.blank_pdf(**kwargs) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pdf") + + +@pytest.mark.asyncio +async def test_live_async_blank_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.blank_pdf( + page_size="A4", + page_count=2, + page_orientation="landscape", + output="async-blank", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async-blank") + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None + + +def test_live_blank_pdf_invalid_request( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)(page|size)"), + ): + client.blank_pdf( + page_size="letter", + page_count=1, + page_orientation="portrait", + extra_body={"page_size": "not-a-size"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_blank_pdf_invalid_request( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)(page|size)"): + await client.blank_pdf( + page_size="letter", + page_count=1, + page_orientation="portrait", + extra_body={"page_size": "bad-size"}, + ) diff --git a/tests/test_blank_pdf.py b/tests/test_blank_pdf.py new file mode 100644 index 00000000..b38ee0e0 --- /dev/null +++ b/tests/test_blank_pdf.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfBlankPayload + +from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, build_file_info_payload + + +def test_blank_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfBlankPayload.model_validate( + { + "page_size": "letter", + "page_count": 2, + "page_orientation": "portrait", + "output": "blank", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/blank-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "blank.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.blank_pdf( + page_size="letter", + page_count=2, + page_orientation="portrait", + output="blank", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + output_file = response.output_file + assert output_file.name == "blank.pdf" + assert output_file.type == "application/pdf" + assert response.warning is None + assert str(response.input_id) == output_id + + +def test_blank_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/blank-pdf": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "sync" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["page_size"] == "custom" + assert payload["custom_height"] == 792 + assert payload["custom_width"] == 612 + assert "page_orientation" not in payload + assert payload["debug"] == "yes" + assert payload["output"] == "custom" + return httpx.Response( + 200, + json={ + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "sync" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "custom.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.blank_pdf( + page_size="custom", + page_count=3, + custom_height=792, + custom_width=612, + output="custom", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": "yes"}, + timeout=0.29, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "custom.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.29) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.29) + + +@pytest.mark.asyncio +async def test_async_blank_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfBlankPayload.model_validate( + { + "page_size": "A4", + "page_count": 1, + "page_orientation": "landscape", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/blank-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "async.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.blank_pdf( + page_size="A4", + page_count=1, + page_orientation="landscape", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async.pdf" + assert response.output_file.type == "application/pdf" + assert str(response.input_id) == output_id + + +@pytest.mark.asyncio +async def test_async_blank_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/blank-pdf": + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["page_size"] == "custom" + assert payload["custom_height"] == 100 + assert payload["custom_width"] == 50 + assert "page_orientation" not in payload + assert payload["debug"] == "yes" + return httpx.Response( + 200, + json={ + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "async-custom.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.blank_pdf( + page_size="custom", + page_count=1, + custom_height=100, + custom_width=50, + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": "yes"}, + timeout=0.52, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-custom.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.52) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.52) + + +def test_blank_pdf_validation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValueError, match="page_orientation is required"), + ): + client.blank_pdf(page_size="letter", page_count=1) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValueError, match="custom_height and custom_width are required"), + ): + client.blank_pdf(page_size="custom", page_count=1, custom_height=50) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValueError, match="custom_height and custom_width can only be provided" + ), + ): + client.blank_pdf( + page_size="A3", + page_count=1, + page_orientation="portrait", + custom_width=10, + ) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValueError, match="page_orientation must be omitted"), + ): + client.blank_pdf( + page_size="custom", + page_count=1, + page_orientation="portrait", + custom_width=10, + custom_height=10, + ) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, match="Input should be less than or equal to 1000" + ), + ): + client.blank_pdf( + page_size="A4", + page_count=1001, + page_orientation="portrait", + ) From c4a9a1fb99d765f007295433c93954b55e34ad4e Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 10 Feb 2026 15:48:10 -0600 Subject: [PATCH 2/8] tests: expand blank-pdf literal and boundary coverage - Add full sync+async unit parametrization for blank_pdf page-size/page-orientation literals across all standard sizes. - Add explicit boundary tests for page_count (min/max success and below/above range validation) and custom dimension validation for non-positive values. Assisted-by: Codex --- tests/test_blank_pdf.py | 342 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) diff --git a/tests/test_blank_pdf.py b/tests/test_blank_pdf.py index b38ee0e0..f9495855 100644 --- a/tests/test_blank_pdf.py +++ b/tests/test_blank_pdf.py @@ -13,6 +13,348 @@ from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, build_file_info_payload +@pytest.mark.parametrize( + "page_size", + [ + pytest.param("letter", id="letter"), + pytest.param("legal", id="legal"), + pytest.param("ledger", id="ledger"), + pytest.param("A3", id="a3"), + pytest.param("A4", id="a4"), + pytest.param("A5", id="a5"), + ], +) +@pytest.mark.parametrize( + "page_orientation", + [ + pytest.param("portrait", id="portrait"), + pytest.param("landscape", id="landscape"), + ], +) +def test_blank_pdf_standard_page_literals( + monkeypatch: pytest.MonkeyPatch, + page_size: str, + page_orientation: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfBlankPayload.model_validate( + { + "page_size": page_size, + "page_count": 1, + "page_orientation": page_orientation, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/blank-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response(200, json={"outputId": [output_id]}) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "blank.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.blank_pdf( + page_size=page_size, + page_count=1, + page_orientation=page_orientation, + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "page_size", + [ + pytest.param("letter", id="letter"), + pytest.param("legal", id="legal"), + pytest.param("ledger", id="ledger"), + pytest.param("A3", id="a3"), + pytest.param("A4", id="a4"), + pytest.param("A5", id="a5"), + ], +) +@pytest.mark.parametrize( + "page_orientation", + [ + pytest.param("portrait", id="portrait"), + pytest.param("landscape", id="landscape"), + ], +) +async def test_async_blank_pdf_standard_page_literals( + monkeypatch: pytest.MonkeyPatch, + page_size: str, + page_orientation: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfBlankPayload.model_validate( + { + "page_size": page_size, + "page_count": 1, + "page_orientation": page_orientation, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/blank-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response(200, json={"outputId": [output_id]}) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "blank.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.blank_pdf( + page_size=page_size, + page_count=1, + page_orientation=page_orientation, + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.parametrize( + "page_count", + [ + pytest.param(1, id="min-page-count"), + pytest.param(1000, id="max-page-count"), + ], +) +def test_blank_pdf_page_count_boundaries_success( + monkeypatch: pytest.MonkeyPatch, + page_count: int, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/blank-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload["page_count"] == page_count + return httpx.Response(200, json={"outputId": [output_id]}) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "blank.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.blank_pdf( + page_size="letter", + page_count=page_count, + page_orientation="portrait", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert seen == {"post": 1, "get": 1} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "page_count", + [ + pytest.param(1, id="min-page-count"), + pytest.param(1000, id="max-page-count"), + ], +) +async def test_async_blank_pdf_page_count_boundaries_success( + monkeypatch: pytest.MonkeyPatch, + page_count: int, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/blank-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload["page_count"] == page_count + return httpx.Response(200, json={"outputId": [output_id]}) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "blank.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.blank_pdf( + page_size="letter", + page_count=page_count, + page_orientation="portrait", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert seen == {"post": 1, "get": 1} + + +@pytest.mark.parametrize( + ("page_count", "match"), + [ + pytest.param(0, "greater than or equal to 1", id="below-min"), + pytest.param(1001, "less than or equal to 1000", id="above-max"), + ], +) +def test_blank_pdf_page_count_boundaries_validation( + monkeypatch: pytest.MonkeyPatch, + page_count: int, + match: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match=match), + ): + client.blank_pdf( + page_size="A4", + page_count=page_count, + page_orientation="portrait", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("page_count", "match"), + [ + pytest.param(0, "greater than or equal to 1", id="below-min"), + pytest.param(1001, "less than or equal to 1000", id="above-max"), + ], +) +async def test_async_blank_pdf_page_count_boundaries_validation( + monkeypatch: pytest.MonkeyPatch, + page_count: int, + match: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match=match): + await client.blank_pdf( + page_size="A4", + page_count=page_count, + page_orientation="portrait", + ) + + +@pytest.mark.parametrize( + ("custom_height", "custom_width", "match"), + [ + pytest.param(0.0, 10.0, "greater than 0", id="height-zero"), + pytest.param(-1.0, 10.0, "greater than 0", id="height-negative"), + pytest.param(10.0, 0.0, "greater than 0", id="width-zero"), + pytest.param(10.0, -1.0, "greater than 0", id="width-negative"), + ], +) +def test_blank_pdf_custom_dimensions_validation( + monkeypatch: pytest.MonkeyPatch, + custom_height: float, + custom_width: float, + match: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match=match), + ): + client.blank_pdf( + page_size="custom", + page_count=1, + custom_height=custom_height, + custom_width=custom_width, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("custom_height", "custom_width", "match"), + [ + pytest.param(0.0, 10.0, "greater than 0", id="height-zero"), + pytest.param(-1.0, 10.0, "greater than 0", id="height-negative"), + pytest.param(10.0, 0.0, "greater than 0", id="width-zero"), + pytest.param(10.0, -1.0, "greater than 0", id="width-negative"), + ], +) +async def test_async_blank_pdf_custom_dimensions_validation( + monkeypatch: pytest.MonkeyPatch, + custom_height: float, + custom_width: float, + match: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match=match): + await client.blank_pdf( + page_size="custom", + page_count=1, + custom_height=custom_height, + custom_width=custom_width, + ) + + def test_blank_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) output_id = str(PdfRestFileID.generate()) From e8b60e4a245d14a786578970f7f76849cc0cb3d2 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 10 Feb 2026 16:04:11 -0600 Subject: [PATCH 3/8] tests: enumerate live blank-pdf literals for sync and async - Add a shared literal case matrix to live blank-pdf tests covering all accepted page_size values, both page_orientation literals, and custom dimensions. - Keep explicit invalid extra_body overrides for both transports to verify server errors on unsupported page_size values. Assisted-by: Codex --- tests/live/test_live_blank_pdf.py | 121 ++++++++++++++++++++++++------ 1 file changed, 99 insertions(+), 22 deletions(-) diff --git a/tests/live/test_live_blank_pdf.py b/tests/live/test_live_blank_pdf.py index 07f281e1..a2196587 100644 --- a/tests/live/test_live_blank_pdf.py +++ b/tests/live/test_live_blank_pdf.py @@ -4,26 +4,90 @@ from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +BLANK_PDF_LITERAL_CASES = [ + pytest.param( + "letter", + "portrait", + None, + None, + "blank-letter", + id="letter-portrait", + ), + pytest.param( + "legal", + "landscape", + None, + None, + "blank-legal", + id="legal-landscape", + ), + pytest.param( + "ledger", + "portrait", + None, + None, + "blank-ledger", + id="ledger-portrait", + ), + pytest.param( + "A3", + "landscape", + None, + None, + "blank-a3", + id="a3-landscape", + ), + pytest.param( + "A4", + "portrait", + None, + None, + "blank-a4", + id="a4-portrait", + ), + pytest.param( + "A5", + "landscape", + None, + None, + "blank-a5", + id="a5-landscape", + ), + pytest.param( + "custom", + None, + 792.0, + 612.0, + "blank-custom", + id="custom-dimensions", + ), +] + @pytest.mark.parametrize( - "output_name", - [ - pytest.param(None, id="default-output"), - pytest.param("blank-doc", id="custom-output"), - ], + ("page_size", "page_orientation", "custom_height", "custom_width", "output_name"), + BLANK_PDF_LITERAL_CASES, ) def test_live_blank_pdf_success( pdfrest_api_key: str, pdfrest_live_base_url: str, - output_name: str | None, + page_size: str, + page_orientation: str | None, + custom_height: float | None, + custom_width: float | None, + output_name: str, ) -> None: - kwargs: dict[str, str | int] = { - "page_size": "letter", + kwargs: dict[str, str | int | float] = { + "page_size": page_size, "page_count": 1, - "page_orientation": "portrait", + "output": output_name, } - if output_name is not None: - kwargs["output"] = output_name + if page_orientation is not None: + kwargs["page_orientation"] = page_orientation + if custom_height is not None: + kwargs["custom_height"] = custom_height + if custom_width is not None: + kwargs["custom_width"] = custom_width with PdfRestClient( api_key=pdfrest_api_key, @@ -36,31 +100,44 @@ def test_live_blank_pdf_success( assert output_file.type == "application/pdf" assert output_file.size > 0 assert response.warning is None - if output_name is not None: - assert output_file.name.startswith(output_name) - else: - assert output_file.name.endswith(".pdf") + assert output_file.name.startswith(output_name) @pytest.mark.asyncio +@pytest.mark.parametrize( + ("page_size", "page_orientation", "custom_height", "custom_width", "output_name"), + BLANK_PDF_LITERAL_CASES, +) async def test_live_async_blank_pdf_success( pdfrest_api_key: str, pdfrest_live_base_url: str, + page_size: str, + page_orientation: str | None, + custom_height: float | None, + custom_width: float | None, + output_name: str, ) -> None: + kwargs: dict[str, str | int | float] = { + "page_size": page_size, + "page_count": 2, + "output": output_name, + } + if page_orientation is not None: + kwargs["page_orientation"] = page_orientation + if custom_height is not None: + kwargs["custom_height"] = custom_height + if custom_width is not None: + kwargs["custom_width"] = custom_width + async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - response = await client.blank_pdf( - page_size="A4", - page_count=2, - page_orientation="landscape", - output="async-blank", - ) + response = await client.blank_pdf(**kwargs) assert response.output_files output_file = response.output_file - assert output_file.name.startswith("async-blank") + assert output_file.name.startswith(output_name) assert output_file.type == "application/pdf" assert output_file.size > 0 assert response.warning is None From 4c991e2b98765941612663a5764cbd18ffd736aa Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 12 Feb 2026 14:54:21 -0600 Subject: [PATCH 4/8] tests: Fix blank pdf tests to expect no input id Assisted-by: Codex --- tests/test_blank_pdf.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_blank_pdf.py b/tests/test_blank_pdf.py index f9495855..0443481f 100644 --- a/tests/test_blank_pdf.py +++ b/tests/test_blank_pdf.py @@ -410,7 +410,9 @@ def handler(request: httpx.Request) -> httpx.Response: assert output_file.name == "blank.pdf" assert output_file.type == "application/pdf" assert response.warning is None - assert str(response.input_id) == output_id + assert response.input_ids == [] + with pytest.raises(ValueError, match=r"no input id was specified"): + _ = response.input_id def test_blank_pdf_request_customization( @@ -531,7 +533,9 @@ def handler(request: httpx.Request) -> httpx.Response: assert isinstance(response, PdfRestFileBasedResponse) assert response.output_file.name == "async.pdf" assert response.output_file.type == "application/pdf" - assert str(response.input_id) == output_id + assert response.input_ids == [] + with pytest.raises(ValueError, match=r"no input id was specified"): + _ = response.input_id @pytest.mark.asyncio From f30b5a3d47b7d7afc37d6b2a3d16bc5795b7fdee Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 13 Feb 2026 16:39:51 -0600 Subject: [PATCH 5/8] blank-pdf: Consolidate page size parameters Page size can be either a `str` literal or custom dimensions Assisted-by: Codex --- src/pdfrest/client.py | 12 ------ src/pdfrest/models/_internal.py | 33 ++++++++++++++-- src/pdfrest/types/public.py | 4 +- tests/live/test_live_blank_pdf.py | 40 ++++---------------- tests/test_blank_pdf.py | 63 ++++++++++--------------------- 5 files changed, 59 insertions(+), 93 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 8e24301a..9c9ec390 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -3080,8 +3080,6 @@ def blank_pdf( page_size: PdfPageSize, page_count: int, page_orientation: PdfPageOrientation | None = None, - custom_height: float | None = None, - custom_width: float | None = None, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -3096,10 +3094,6 @@ def blank_pdf( } if page_orientation is not None: payload["page_orientation"] = page_orientation - if custom_height is not None: - payload["custom_height"] = custom_height - if custom_width is not None: - payload["custom_width"] = custom_width if output is not None: payload["output"] = output @@ -4525,8 +4519,6 @@ async def blank_pdf( page_size: PdfPageSize, page_count: int, page_orientation: PdfPageOrientation | None = None, - custom_height: float | None = None, - custom_width: float | None = None, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -4541,10 +4533,6 @@ async def blank_pdf( } if page_orientation is not None: payload["page_orientation"] = page_orientation - if custom_height is not None: - payload["custom_height"] = custom_height - if custom_width is not None: - payload["custom_width"] = custom_width if output is not None: payload["output"] = output diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 1c62b081..08354a8c 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1,9 +1,9 @@ from __future__ import annotations import re -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from pathlib import PurePath -from typing import Annotated, Any, Generic, Literal, TypeVar +from typing import Annotated, Any, Generic, Literal, TypeVar, cast from langcodes import tag_is_valid from pydantic import ( @@ -1371,7 +1371,7 @@ class PdfBlankPayload(BaseModel): """Adapt caller options into a pdfRest-ready blank PDF request payload.""" page_size: Annotated[ - PdfPageSize, + PdfPageSize | Literal["custom"], Field(serialization_alias="page_size"), ] page_count: Annotated[ @@ -1396,6 +1396,33 @@ class PdfBlankPayload(BaseModel): AfterValidator(_validate_output_prefix), ] = None + @model_validator(mode="before") + @classmethod + def _normalize_custom_page_size(cls, data: Any) -> Any: + if not isinstance(data, Mapping): + return data + + request_data = cast(Mapping[str, Any], data) + page_size = request_data.get("page_size") + if not isinstance(page_size, Sequence) or isinstance( + page_size, (str, bytes, bytearray) + ): + return request_data + + custom_dimensions = list(page_size) + if len(custom_dimensions) != 2: + msg = ( + "Custom page sizes must contain exactly two values: " + "custom_height and custom_width." + ) + raise ValueError(msg) + + normalized_data: dict[str, Any] = dict(request_data) + normalized_data["page_size"] = "custom" + normalized_data["custom_height"] = custom_dimensions[0] + normalized_data["custom_width"] = custom_dimensions[1] + return normalized_data + @model_validator(mode="after") def _validate_page_configuration(self) -> PdfBlankPayload: is_custom = self.page_size == "custom" diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 45453446..68c2a859 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -199,5 +199,7 @@ class PdfMergeSource(TypedDict, total=False): ALL_PDF_RESTRICTIONS: tuple[PdfRestriction, ...] = cast( tuple[PdfRestriction, ...], get_args(PdfRestriction) ) -PdfPageSize = Literal["letter", "legal", "ledger", "A3", "A4", "A5", "custom"] +PdfPageSize = ( + Literal["letter", "legal", "ledger", "A3", "A4", "A5"] | tuple[float, float] +) PdfPageOrientation = Literal["portrait", "landscape"] diff --git a/tests/live/test_live_blank_pdf.py b/tests/live/test_live_blank_pdf.py index a2196587..225a355d 100644 --- a/tests/live/test_live_blank_pdf.py +++ b/tests/live/test_live_blank_pdf.py @@ -8,56 +8,42 @@ pytest.param( "letter", "portrait", - None, - None, "blank-letter", id="letter-portrait", ), pytest.param( "legal", "landscape", - None, - None, "blank-legal", id="legal-landscape", ), pytest.param( "ledger", "portrait", - None, - None, "blank-ledger", id="ledger-portrait", ), pytest.param( "A3", "landscape", - None, - None, "blank-a3", id="a3-landscape", ), pytest.param( "A4", "portrait", - None, - None, "blank-a4", id="a4-portrait", ), pytest.param( "A5", "landscape", - None, - None, "blank-a5", id="a5-landscape", ), pytest.param( - "custom", + (792.0, 612.0), None, - 792.0, - 612.0, "blank-custom", id="custom-dimensions", ), @@ -65,29 +51,23 @@ @pytest.mark.parametrize( - ("page_size", "page_orientation", "custom_height", "custom_width", "output_name"), + ("page_size", "page_orientation", "output_name"), BLANK_PDF_LITERAL_CASES, ) def test_live_blank_pdf_success( pdfrest_api_key: str, pdfrest_live_base_url: str, - page_size: str, + page_size: str | tuple[float, float], page_orientation: str | None, - custom_height: float | None, - custom_width: float | None, output_name: str, ) -> None: - kwargs: dict[str, str | int | float] = { + kwargs: dict[str, str | int | float | tuple[float, float]] = { "page_size": page_size, "page_count": 1, "output": output_name, } if page_orientation is not None: kwargs["page_orientation"] = page_orientation - if custom_height is not None: - kwargs["custom_height"] = custom_height - if custom_width is not None: - kwargs["custom_width"] = custom_width with PdfRestClient( api_key=pdfrest_api_key, @@ -105,29 +85,23 @@ def test_live_blank_pdf_success( @pytest.mark.asyncio @pytest.mark.parametrize( - ("page_size", "page_orientation", "custom_height", "custom_width", "output_name"), + ("page_size", "page_orientation", "output_name"), BLANK_PDF_LITERAL_CASES, ) async def test_live_async_blank_pdf_success( pdfrest_api_key: str, pdfrest_live_base_url: str, - page_size: str, + page_size: str | tuple[float, float], page_orientation: str | None, - custom_height: float | None, - custom_width: float | None, output_name: str, ) -> None: - kwargs: dict[str, str | int | float] = { + kwargs: dict[str, str | int | float | tuple[float, float]] = { "page_size": page_size, "page_count": 2, "output": output_name, } if page_orientation is not None: kwargs["page_orientation"] = page_orientation - if custom_height is not None: - kwargs["custom_height"] = custom_height - if custom_width is not None: - kwargs["custom_width"] = custom_width async with AsyncPdfRestClient( api_key=pdfrest_api_key, diff --git a/tests/test_blank_pdf.py b/tests/test_blank_pdf.py index 0443481f..c8ee5181 100644 --- a/tests/test_blank_pdf.py +++ b/tests/test_blank_pdf.py @@ -297,18 +297,17 @@ async def test_async_blank_pdf_page_count_boundaries_validation( @pytest.mark.parametrize( - ("custom_height", "custom_width", "match"), + ("page_size", "match"), [ - pytest.param(0.0, 10.0, "greater than 0", id="height-zero"), - pytest.param(-1.0, 10.0, "greater than 0", id="height-negative"), - pytest.param(10.0, 0.0, "greater than 0", id="width-zero"), - pytest.param(10.0, -1.0, "greater than 0", id="width-negative"), + pytest.param((0.0, 10.0), "greater than 0", id="height-zero"), + pytest.param((-1.0, 10.0), "greater than 0", id="height-negative"), + pytest.param((10.0, 0.0), "greater than 0", id="width-zero"), + pytest.param((10.0, -1.0), "greater than 0", id="width-negative"), ], ) def test_blank_pdf_custom_dimensions_validation( monkeypatch: pytest.MonkeyPatch, - custom_height: float, - custom_width: float, + page_size: tuple[float, float], match: str, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -319,27 +318,24 @@ def test_blank_pdf_custom_dimensions_validation( pytest.raises(ValidationError, match=match), ): client.blank_pdf( - page_size="custom", + page_size=page_size, page_count=1, - custom_height=custom_height, - custom_width=custom_width, ) @pytest.mark.asyncio @pytest.mark.parametrize( - ("custom_height", "custom_width", "match"), + ("page_size", "match"), [ - pytest.param(0.0, 10.0, "greater than 0", id="height-zero"), - pytest.param(-1.0, 10.0, "greater than 0", id="height-negative"), - pytest.param(10.0, 0.0, "greater than 0", id="width-zero"), - pytest.param(10.0, -1.0, "greater than 0", id="width-negative"), + pytest.param((0.0, 10.0), "greater than 0", id="height-zero"), + pytest.param((-1.0, 10.0), "greater than 0", id="height-negative"), + pytest.param((10.0, 0.0), "greater than 0", id="width-zero"), + pytest.param((10.0, -1.0), "greater than 0", id="width-negative"), ], ) async def test_async_blank_pdf_custom_dimensions_validation( monkeypatch: pytest.MonkeyPatch, - custom_height: float, - custom_width: float, + page_size: tuple[float, float], match: str, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -348,10 +344,8 @@ async def test_async_blank_pdf_custom_dimensions_validation( async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: with pytest.raises(ValidationError, match=match): await client.blank_pdf( - page_size="custom", + page_size=page_size, page_count=1, - custom_height=custom_height, - custom_width=custom_width, ) @@ -458,10 +452,8 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = client.blank_pdf( - page_size="custom", + page_size=(792, 612), page_count=3, - custom_height=792, - custom_width=612, output="custom", extra_query={"trace": "true"}, extra_headers={"X-Debug": "sync"}, @@ -581,10 +573,8 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: response = await client.blank_pdf( - page_size="custom", + page_size=(100, 50), page_count=1, - custom_height=100, - custom_width=50, extra_query={"trace": "async"}, extra_headers={"X-Debug": "async"}, extra_body={"debug": "yes"}, @@ -615,33 +605,18 @@ def test_blank_pdf_validation(monkeypatch: pytest.MonkeyPatch) -> None: with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValueError, match="custom_height and custom_width are required"), + pytest.raises(ValueError, match="Custom page sizes must contain exactly two"), ): - client.blank_pdf(page_size="custom", page_count=1, custom_height=50) - - with ( - PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises( - ValueError, match="custom_height and custom_width can only be provided" - ), - ): - client.blank_pdf( - page_size="A3", - page_count=1, - page_orientation="portrait", - custom_width=10, - ) + client.blank_pdf(page_size=(50,), page_count=1) with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, pytest.raises(ValueError, match="page_orientation must be omitted"), ): client.blank_pdf( - page_size="custom", + page_size=(10, 10), page_count=1, page_orientation="portrait", - custom_width=10, - custom_height=10, ) with ( From ffde7f966b1b3b0a450d92749d36e02a4bc14591 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 17 Feb 2026 14:09:01 -0600 Subject: [PATCH 6/8] blank-pdf: Set default args Assisted-by: Codex --- src/pdfrest/client.py | 12 ++--- src/pdfrest/models/_internal.py | 36 +++++++------ tests/test_blank_pdf.py | 91 ++++++++++++++++++++++++++++++--- 3 files changed, 111 insertions(+), 28 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 9c9ec390..8ec3b4aa 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -3077,8 +3077,8 @@ def add_attachment_to_pdf( def blank_pdf( self, *, - page_size: PdfPageSize, - page_count: int, + page_size: PdfPageSize = "letter", + page_count: int = 1, page_orientation: PdfPageOrientation | None = None, output: str | None = None, extra_query: Query | None = None, @@ -3086,7 +3086,7 @@ def blank_pdf( extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - """Create a blank PDF with the specified size, count, and orientation.""" + """Create a blank PDF with configurable size, count, and orientation.""" payload: dict[str, Any] = { "page_size": page_size, @@ -4516,8 +4516,8 @@ async def add_attachment_to_pdf( async def blank_pdf( self, *, - page_size: PdfPageSize, - page_count: int, + page_size: PdfPageSize = "letter", + page_count: int = 1, page_orientation: PdfPageOrientation | None = None, output: str | None = None, extra_query: Query | None = None, @@ -4525,7 +4525,7 @@ async def blank_pdf( extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - """Asynchronously create a blank PDF with the specified size.""" + """Asynchronously create a blank PDF with configurable size and count.""" payload: dict[str, Any] = { "page_size": page_size, diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 08354a8c..7fdeaec9 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1402,25 +1402,29 @@ def _normalize_custom_page_size(cls, data: Any) -> Any: if not isinstance(data, Mapping): return data - request_data = cast(Mapping[str, Any], data) - page_size = request_data.get("page_size") - if not isinstance(page_size, Sequence) or isinstance( + normalized_data: dict[str, Any] = dict(cast(Mapping[str, Any], data)) + page_size = normalized_data.get("page_size") + if isinstance(page_size, Sequence) and not isinstance( page_size, (str, bytes, bytearray) ): - return request_data - - custom_dimensions = list(page_size) - if len(custom_dimensions) != 2: - msg = ( - "Custom page sizes must contain exactly two values: " - "custom_height and custom_width." - ) - raise ValueError(msg) + custom_dimensions = list(page_size) + if len(custom_dimensions) != 2: + msg = ( + "Custom page sizes must contain exactly two values: " + "custom_height and custom_width." + ) + raise ValueError(msg) + normalized_data["page_size"] = "custom" + normalized_data["custom_height"] = custom_dimensions[0] + normalized_data["custom_width"] = custom_dimensions[1] + + if ( + normalized_data.get("page_size") is not None + and normalized_data.get("page_size") != "custom" + and normalized_data.get("page_orientation") is None + ): + normalized_data["page_orientation"] = "portrait" - normalized_data: dict[str, Any] = dict(request_data) - normalized_data["page_size"] = "custom" - normalized_data["custom_height"] = custom_dimensions[0] - normalized_data["custom_width"] = custom_dimensions[1] return normalized_data @model_validator(mode="after") diff --git a/tests/test_blank_pdf.py b/tests/test_blank_pdf.py index c8ee5181..6b3e70b5 100644 --- a/tests/test_blank_pdf.py +++ b/tests/test_blank_pdf.py @@ -150,6 +150,91 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.output_file.type == "application/pdf" +def test_blank_pdf_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfBlankPayload.model_validate( + { + "page_size": "letter", + "page_count": 1, + "page_orientation": "portrait", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/blank-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response(200, json={"outputId": [output_id]}) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "blank-default.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.blank_pdf() + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +async def test_async_blank_pdf_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfBlankPayload.model_validate( + { + "page_size": "letter", + "page_count": 1, + "page_orientation": "portrait", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/blank-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response(200, json={"outputId": [output_id]}) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "blank-async-default.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.blank_pdf() + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.type == "application/pdf" + + @pytest.mark.parametrize( "page_count", [ @@ -597,12 +682,6 @@ def test_blank_pdf_validation(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) - with ( - PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValueError, match="page_orientation is required"), - ): - client.blank_pdf(page_size="letter", page_count=1) - with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, pytest.raises(ValueError, match="Custom page sizes must contain exactly two"), From b4ffe7e260093cd3719ba6e83a307d1ae2c8ec19 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 17 Feb 2026 14:23:38 -0600 Subject: [PATCH 7/8] blank-pdf: Encapsulate custom page dimensions Assisted-by: Codex --- src/pdfrest/models/_internal.py | 19 ++++---- src/pdfrest/types/__init__.py | 2 + src/pdfrest/types/public.py | 10 +++-- tests/live/test_live_blank_pdf.py | 11 ++--- tests/test_blank_pdf.py | 72 ++++++++++++++++++++++++------- 5 files changed, 82 insertions(+), 32 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 7fdeaec9..b9fbf7fc 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1404,19 +1404,20 @@ def _normalize_custom_page_size(cls, data: Any) -> Any: normalized_data: dict[str, Any] = dict(cast(Mapping[str, Any], data)) page_size = normalized_data.get("page_size") - if isinstance(page_size, Sequence) and not isinstance( - page_size, (str, bytes, bytearray) - ): - custom_dimensions = list(page_size) - if len(custom_dimensions) != 2: + if isinstance(page_size, Mapping): + custom_page_size = cast(Mapping[str, Any], page_size) + if ( + "custom_height" not in custom_page_size + or "custom_width" not in custom_page_size + ): msg = ( - "Custom page sizes must contain exactly two values: " - "custom_height and custom_width." + "Custom page sizes must include both custom_height and " + "custom_width." ) raise ValueError(msg) normalized_data["page_size"] = "custom" - normalized_data["custom_height"] = custom_dimensions[0] - normalized_data["custom_width"] = custom_dimensions[1] + normalized_data["custom_height"] = custom_page_size["custom_height"] + normalized_data["custom_width"] = custom_page_size["custom_width"] if ( normalized_data.get("page_size") is not None diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index b61c4b4f..474356f0 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -15,6 +15,7 @@ PdfAddTextObject, PdfAType, PdfCmykColor, + PdfCustomPageSize, PdfInfoQuery, PdfMergeInput, PdfMergeSource, @@ -50,6 +51,7 @@ "PdfAType", "PdfAddTextObject", "PdfCmykColor", + "PdfCustomPageSize", "PdfInfoQuery", "PdfMergeInput", "PdfMergeSource", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 68c2a859..43391f21 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -27,6 +27,7 @@ "PdfAType", "PdfAddTextObject", "PdfCmykColor", + "PdfCustomPageSize", "PdfInfoQuery", "PdfMergeInput", "PdfMergeSource", @@ -128,6 +129,11 @@ class PdfAddTextObject(TypedDict, total=False): is_right_to_left: bool +class PdfCustomPageSize(TypedDict): + custom_height: Required[float] + custom_width: Required[float] + + PdfPageSelection = str | int | Sequence[str | int] @@ -199,7 +205,5 @@ class PdfMergeSource(TypedDict, total=False): ALL_PDF_RESTRICTIONS: tuple[PdfRestriction, ...] = cast( tuple[PdfRestriction, ...], get_args(PdfRestriction) ) -PdfPageSize = ( - Literal["letter", "legal", "ledger", "A3", "A4", "A5"] | tuple[float, float] -) +PdfPageSize = Literal["letter", "legal", "ledger", "A3", "A4", "A5"] | PdfCustomPageSize PdfPageOrientation = Literal["portrait", "landscape"] diff --git a/tests/live/test_live_blank_pdf.py b/tests/live/test_live_blank_pdf.py index 225a355d..45e6a13c 100644 --- a/tests/live/test_live_blank_pdf.py +++ b/tests/live/test_live_blank_pdf.py @@ -3,6 +3,7 @@ import pytest from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.types import PdfCustomPageSize BLANK_PDF_LITERAL_CASES = [ pytest.param( @@ -42,7 +43,7 @@ id="a5-landscape", ), pytest.param( - (792.0, 612.0), + {"custom_height": 792.0, "custom_width": 612.0}, None, "blank-custom", id="custom-dimensions", @@ -57,11 +58,11 @@ def test_live_blank_pdf_success( pdfrest_api_key: str, pdfrest_live_base_url: str, - page_size: str | tuple[float, float], + page_size: str | PdfCustomPageSize, page_orientation: str | None, output_name: str, ) -> None: - kwargs: dict[str, str | int | float | tuple[float, float]] = { + kwargs: dict[str, str | int | float | PdfCustomPageSize] = { "page_size": page_size, "page_count": 1, "output": output_name, @@ -91,11 +92,11 @@ def test_live_blank_pdf_success( async def test_live_async_blank_pdf_success( pdfrest_api_key: str, pdfrest_live_base_url: str, - page_size: str | tuple[float, float], + page_size: str | PdfCustomPageSize, page_orientation: str | None, output_name: str, ) -> None: - kwargs: dict[str, str | int | float | tuple[float, float]] = { + kwargs: dict[str, str | int | float | PdfCustomPageSize] = { "page_size": page_size, "page_count": 2, "output": output_name, diff --git a/tests/test_blank_pdf.py b/tests/test_blank_pdf.py index 6b3e70b5..7f6fa337 100644 --- a/tests/test_blank_pdf.py +++ b/tests/test_blank_pdf.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from typing import cast import httpx import pytest @@ -9,6 +10,7 @@ from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID from pdfrest.models._internal import PdfBlankPayload +from pdfrest.types import PdfCustomPageSize from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, build_file_info_payload @@ -384,15 +386,31 @@ async def test_async_blank_pdf_page_count_boundaries_validation( @pytest.mark.parametrize( ("page_size", "match"), [ - pytest.param((0.0, 10.0), "greater than 0", id="height-zero"), - pytest.param((-1.0, 10.0), "greater than 0", id="height-negative"), - pytest.param((10.0, 0.0), "greater than 0", id="width-zero"), - pytest.param((10.0, -1.0), "greater than 0", id="width-negative"), + pytest.param( + {"custom_height": 0.0, "custom_width": 10.0}, + "greater than 0", + id="height-zero", + ), + pytest.param( + {"custom_height": -1.0, "custom_width": 10.0}, + "greater than 0", + id="height-negative", + ), + pytest.param( + {"custom_height": 10.0, "custom_width": 0.0}, + "greater than 0", + id="width-zero", + ), + pytest.param( + {"custom_height": 10.0, "custom_width": -1.0}, + "greater than 0", + id="width-negative", + ), ], ) def test_blank_pdf_custom_dimensions_validation( monkeypatch: pytest.MonkeyPatch, - page_size: tuple[float, float], + page_size: PdfCustomPageSize, match: str, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -412,15 +430,31 @@ def test_blank_pdf_custom_dimensions_validation( @pytest.mark.parametrize( ("page_size", "match"), [ - pytest.param((0.0, 10.0), "greater than 0", id="height-zero"), - pytest.param((-1.0, 10.0), "greater than 0", id="height-negative"), - pytest.param((10.0, 0.0), "greater than 0", id="width-zero"), - pytest.param((10.0, -1.0), "greater than 0", id="width-negative"), + pytest.param( + {"custom_height": 0.0, "custom_width": 10.0}, + "greater than 0", + id="height-zero", + ), + pytest.param( + {"custom_height": -1.0, "custom_width": 10.0}, + "greater than 0", + id="height-negative", + ), + pytest.param( + {"custom_height": 10.0, "custom_width": 0.0}, + "greater than 0", + id="width-zero", + ), + pytest.param( + {"custom_height": 10.0, "custom_width": -1.0}, + "greater than 0", + id="width-negative", + ), ], ) async def test_async_blank_pdf_custom_dimensions_validation( monkeypatch: pytest.MonkeyPatch, - page_size: tuple[float, float], + page_size: PdfCustomPageSize, match: str, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -537,7 +571,7 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = client.blank_pdf( - page_size=(792, 612), + page_size={"custom_height": 792, "custom_width": 612}, page_count=3, output="custom", extra_query={"trace": "true"}, @@ -658,7 +692,7 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: response = await client.blank_pdf( - page_size=(100, 50), + page_size={"custom_height": 100, "custom_width": 50}, page_count=1, extra_query={"trace": "async"}, extra_headers={"X-Debug": "async"}, @@ -684,16 +718,24 @@ def test_blank_pdf_validation(monkeypatch: pytest.MonkeyPatch) -> None: with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValueError, match="Custom page sizes must contain exactly two"), + pytest.raises( + ValueError, match="must include both custom_height and custom_width" + ), ): - client.blank_pdf(page_size=(50,), page_count=1) + client.blank_pdf( + page_size=cast( + PdfCustomPageSize, + cast(object, {"custom_height": 50}), + ), + page_count=1, + ) with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, pytest.raises(ValueError, match="page_orientation must be omitted"), ): client.blank_pdf( - page_size=(10, 10), + page_size={"custom_height": 10, "custom_width": 10}, page_count=1, page_orientation="portrait", ) From 5eca38246ec6b9a8cbab75bde9d8c854f21d6d8f Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 17 Feb 2026 15:06:52 -0600 Subject: [PATCH 8/8] blank-pdf: Fix missing test coverage Assisted-by: Codex --- tests/test_blank_pdf.py | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_blank_pdf.py b/tests/test_blank_pdf.py index 7f6fa337..6d845bf0 100644 --- a/tests/test_blank_pdf.py +++ b/tests/test_blank_pdf.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from types import SimpleNamespace from typing import cast import httpx @@ -751,3 +752,50 @@ def test_blank_pdf_validation(monkeypatch: pytest.MonkeyPatch) -> None: page_count=1001, page_orientation="portrait", ) + + +def test_blank_payload_validation_non_mapping_input_skips_normalization() -> None: + with pytest.raises( + ValidationError, + match="page_orientation is required when page_size is not 'custom'", + ): + _ = PdfBlankPayload.model_validate( + SimpleNamespace( + page_size="A4", + page_count=1, + page_orientation=None, + custom_height=None, + custom_width=None, + output=None, + ), + from_attributes=True, + ) + + +def test_blank_payload_requires_custom_dimensions() -> None: + with pytest.raises( + ValidationError, + match="custom_height and custom_width are required when page_size is 'custom'", + ): + _ = PdfBlankPayload.model_validate( + { + "page_size": "custom", + "page_count": 1, + "custom_height": 9.5, + } + ) + + +def test_blank_payload_rejects_custom_dimensions_for_non_custom_page() -> None: + with pytest.raises( + ValidationError, + match="custom_height and custom_width can only be provided when page_size is 'custom'", + ): + _ = PdfBlankPayload.model_validate( + { + "page_size": "A4", + "page_count": 1, + "custom_height": 792.0, + "custom_width": 612.0, + } + )