diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 61c19397..8ec3b4aa 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,39 @@ def add_attachment_to_pdf( timeout=timeout, ) + def blank_pdf( + self, + *, + page_size: PdfPageSize = "letter", + page_count: int = 1, + page_orientation: PdfPageOrientation | 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 configurable 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 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 +4513,39 @@ async def add_attachment_to_pdf( timeout=timeout, ) + async def blank_pdf( + self, + *, + page_size: PdfPageSize = "letter", + page_count: int = 1, + page_orientation: PdfPageOrientation | 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 configurable size and count.""" + + payload: dict[str, Any] = { + "page_size": page_size, + "page_count": page_count, + } + if page_orientation is not None: + payload["page_orientation"] = page_orientation + 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..b9fbf7fc 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 ( @@ -26,6 +26,8 @@ OcrLanguage, PdfAType, PdfInfoQuery, + PdfPageOrientation, + PdfPageSize, PdfRestriction, PdfXType, SummaryFormat, @@ -1365,6 +1367,89 @@ class PdfAddAttachmentPayload(BaseModel): ] = None +class PdfBlankPayload(BaseModel): + """Adapt caller options into a pdfRest-ready blank PDF request payload.""" + + page_size: Annotated[ + PdfPageSize | Literal["custom"], + 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="before") + @classmethod + def _normalize_custom_page_size(cls, data: Any) -> Any: + if not isinstance(data, Mapping): + return data + + normalized_data: dict[str, Any] = dict(cast(Mapping[str, Any], data)) + page_size = normalized_data.get("page_size") + 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 include both custom_height and " + "custom_width." + ) + raise ValueError(msg) + normalized_data["page_size"] = "custom" + 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 + and normalized_data.get("page_size") != "custom" + and normalized_data.get("page_orientation") is None + ): + normalized_data["page_orientation"] = "portrait" + + return normalized_data + + @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 +1688,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..474356f0 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -15,10 +15,13 @@ PdfAddTextObject, PdfAType, PdfCmykColor, + PdfCustomPageSize, PdfInfoQuery, PdfMergeInput, PdfMergeSource, + PdfPageOrientation, PdfPageSelection, + PdfPageSize, PdfRedactionInstruction, PdfRedactionPreset, PdfRedactionType, @@ -48,10 +51,13 @@ "PdfAType", "PdfAddTextObject", "PdfCmykColor", + "PdfCustomPageSize", "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..43391f21 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -27,10 +27,13 @@ "PdfAType", "PdfAddTextObject", "PdfCmykColor", + "PdfCustomPageSize", "PdfInfoQuery", "PdfMergeInput", "PdfMergeSource", + "PdfPageOrientation", "PdfPageSelection", + "PdfPageSize", "PdfRGBColor", "PdfRedactionInstruction", "PdfRedactionPreset", @@ -126,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] @@ -197,3 +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"] | PdfCustomPageSize +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..45e6a13c --- /dev/null +++ b/tests/live/test_live_blank_pdf.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.types import PdfCustomPageSize + +BLANK_PDF_LITERAL_CASES = [ + pytest.param( + "letter", + "portrait", + "blank-letter", + id="letter-portrait", + ), + pytest.param( + "legal", + "landscape", + "blank-legal", + id="legal-landscape", + ), + pytest.param( + "ledger", + "portrait", + "blank-ledger", + id="ledger-portrait", + ), + pytest.param( + "A3", + "landscape", + "blank-a3", + id="a3-landscape", + ), + pytest.param( + "A4", + "portrait", + "blank-a4", + id="a4-portrait", + ), + pytest.param( + "A5", + "landscape", + "blank-a5", + id="a5-landscape", + ), + pytest.param( + {"custom_height": 792.0, "custom_width": 612.0}, + None, + "blank-custom", + id="custom-dimensions", + ), +] + + +@pytest.mark.parametrize( + ("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 | PdfCustomPageSize, + page_orientation: str | None, + output_name: str, +) -> None: + kwargs: dict[str, str | int | float | PdfCustomPageSize] = { + "page_size": page_size, + "page_count": 1, + "output": output_name, + } + if page_orientation is not None: + kwargs["page_orientation"] = page_orientation + + 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 + assert output_file.name.startswith(output_name) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("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 | PdfCustomPageSize, + page_orientation: str | None, + output_name: str, +) -> None: + kwargs: dict[str, str | int | float | PdfCustomPageSize] = { + "page_size": page_size, + "page_count": 2, + "output": output_name, + } + if page_orientation is not None: + kwargs["page_orientation"] = page_orientation + + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.blank_pdf(**kwargs) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith(output_name) + 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..6d845bf0 --- /dev/null +++ b/tests/test_blank_pdf.py @@ -0,0 +1,801 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import cast + +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 pdfrest.types import PdfCustomPageSize + +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" + + +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", + [ + 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( + ("page_size", "match"), + [ + 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: PdfCustomPageSize, + 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=page_size, + page_count=1, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("page_size", "match"), + [ + 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: PdfCustomPageSize, + 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=page_size, + page_count=1, + ) + + +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 response.input_ids == [] + with pytest.raises(ValueError, match=r"no input id was specified"): + _ = response.input_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_height": 792, "custom_width": 612}, + page_count=3, + 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 response.input_ids == [] + with pytest.raises(ValueError, match=r"no input id was specified"): + _ = response.input_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_height": 100, "custom_width": 50}, + page_count=1, + 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="must include both custom_height and custom_width" + ), + ): + 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={"custom_height": 10, "custom_width": 10}, + page_count=1, + page_orientation="portrait", + ) + + 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", + ) + + +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, + } + )