From 947065822cd2391cda0de3c988017c4d2bf6fd2d Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 16 Dec 2025 11:37:03 -0600 Subject: [PATCH 1/5] Add Convert to Word Assisted-by: Codex --- src/pdfrest/client.py | 53 +++++++++++ src/pdfrest/models/_internal.py | 24 +++++ tests/test_convert_to_word.py | 152 ++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 tests/test_convert_to_word.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index bf053ae7..41aff59d 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -79,6 +79,7 @@ PdfRedactionPreviewPayload, PdfRestRawFileResponse, PdfSplitPayload, + PdfToWordPayload, PngPdfRestPayload, TiffPdfRestPayload, UploadURLs, @@ -2141,6 +2142,32 @@ def merge_pdfs( timeout=timeout, ) + def convert_to_word( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Convert a PDF to a Word document.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/word", + payload=payload, + payload_model=PdfToWordPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_png( self, files: PdfRestFile | Sequence[PdfRestFile], @@ -2572,6 +2599,32 @@ async def merge_pdfs( timeout=timeout, ) + async def convert_to_word( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + 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 convert a PDF to a Word document.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/word", + payload=payload, + payload_model=PdfToWordPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def convert_to_png( self, files: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 7706e839..7a7e04c2 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -476,6 +476,30 @@ def _serialize_pdf_merge_payload( return payload +class PdfToWordPayload(BaseModel): + """Adapt caller options into a pdfRest-ready Word request payload.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + class BmpPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "gray"]]): """Adapt caller options into a pdfRest-ready BMP request payload.""" diff --git a/tests/test_convert_to_word.py b/tests/test_convert_to_word.py new file mode 100644 index 00000000..a9e60cbd --- /dev/null +++ b/tests/test_convert_to_word.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfToWordPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_convert_to_word_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfToWordPayload.model_validate( + {"files": [input_file], "output": "report"} + ).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 == "/word": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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, + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ) + 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.convert_to_word(input_file, output="report") + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + output_file = response.output_file + assert output_file.name == "report.docx" + assert ( + output_file.type + == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + assert response.warning is None + assert str(response.input_id) == str(input_file.id) + + +@pytest.mark.asyncio +async def test_async_convert_to_word_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfToWordPayload.model_validate({"files": [input_file]}).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 == "/word": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ) + 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.convert_to_word(input_file) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async.docx" + assert ( + response.output_file.type + == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + assert str(response.input_id) == str(input_file.id) + + +def test_convert_to_word_validation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + png_file = PdfRestFile.model_validate( + build_file_info_payload( + PdfRestFileID.generate(), + "example.png", + "image/png", + ) + ) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Must be a PDF file"), + ): + client.convert_to_word(png_file) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ), + ): + client.convert_to_word([pdf_file, make_pdf_file(PdfRestFileID.generate())]) From fbfbaa0822f982c77346121314b30337a6a6d58f Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 16 Dec 2025 12:08:25 -0600 Subject: [PATCH 2/5] Add Convert to PDF/X Assisted-by: Codex --- src/pdfrest/client.py | 56 ++++++++++++ src/pdfrest/models/_internal.py | 28 +++++- src/pdfrest/types/__init__.py | 2 + src/pdfrest/types/public.py | 3 + tests/test_convert_to_pdfx.py | 151 ++++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 tests/test_convert_to_pdfx.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 41aff59d..cfcdbc9b 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -79,6 +79,7 @@ PdfRedactionPreviewPayload, PdfRestRawFileResponse, PdfSplitPayload, + PdfToPdfxPayload, PdfToWordPayload, PngPdfRestPayload, TiffPdfRestPayload, @@ -91,6 +92,7 @@ PdfPageSelection, PdfRedactionInstruction, PdfRGBColor, + PdfXType, ) DEFAULT_BASE_URL = "https://api.pdfrest.com" @@ -2168,6 +2170,33 @@ def convert_to_word( timeout=timeout, ) + def convert_to_pdfx( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + output_type: PdfXType, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Convert a PDF to a specified PDF/X version.""" + + payload: dict[str, Any] = {"files": file, "output_type": output_type} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdfx", + payload=payload, + payload_model=PdfToPdfxPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_png( self, files: PdfRestFile | Sequence[PdfRestFile], @@ -2625,6 +2654,33 @@ async def convert_to_word( timeout=timeout, ) + async def convert_to_pdfx( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + output_type: PdfXType, + 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 convert a PDF to a specified PDF/X version.""" + + payload: dict[str, Any] = {"files": file, "output_type": output_type} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdfx", + payload=payload, + payload_model=PdfToPdfxPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def convert_to_png( self, files: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 7a7e04c2..dc8a7da3 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -21,7 +21,7 @@ from pdfrest.types.public import PdfRedactionPreset -from ..types import PdfInfoQuery +from ..types import PdfInfoQuery, PdfXType from . import PdfRestFile from .public import PdfRestFileID @@ -500,6 +500,32 @@ class PdfToWordPayload(BaseModel): ] = None +class PdfToPdfxPayload(BaseModel): + """Adapt caller options into a pdfRest-ready PDF/X request payload.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + output_type: Annotated[PdfXType, Field(serialization_alias="output_type")] + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + + class BmpPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "gray"]]): """Adapt caller options into a pdfRest-ready BMP request payload.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 635be543..9bc36a87 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -10,6 +10,7 @@ PdfRedactionPreset, PdfRedactionType, PdfRGBColor, + PdfXType, ) __all__ = [ @@ -22,4 +23,5 @@ "PdfRedactionInstruction", "PdfRedactionPreset", "PdfRedactionType", + "PdfXType", ] diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 496d9490..1df53284 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -22,6 +22,7 @@ "PdfRedactionInstruction", "PdfRedactionPreset", "PdfRedactionType", + "PdfXType", ) PdfInfoQuery = Literal[ @@ -96,3 +97,5 @@ class PdfMergeSource(TypedDict, total=False): PdfMergeInput = PdfRestFile | PdfMergeSource | tuple[PdfRestFile, PdfPageSelection] + +PdfXType = Literal["PDF/X-1a", "PDF/X-3", "PDF/X-4", "PDF/X-6"] diff --git a/tests/test_convert_to_pdfx.py b/tests/test_convert_to_pdfx.py new file mode 100644 index 00000000..a264aa74 --- /dev/null +++ b/tests/test_convert_to_pdfx.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfToPdfxPayload +from pdfrest.types import PdfXType + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_convert_to_pdfx_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + output_type: PdfXType = "PDF/X-4" + + payload_dump = PdfToPdfxPayload.model_validate( + {"files": [input_file], "output_type": output_type, "output": "print-ready"} + ).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 == "/pdfx": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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, "print-ready.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.convert_to_pdfx( + input_file, + output_type=output_type, + output="print-ready", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "print-ready.pdf" + assert response.output_file.type == "application/pdf" + assert str(response.input_id) == str(input_file.id) + assert response.warning is None + + +@pytest.mark.asyncio +async def test_async_convert_to_pdfx_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + output_id = str(PdfRestFileID.generate()) + output_type: PdfXType = "PDF/X-1a" + + payload_dump = PdfToPdfxPayload.model_validate( + {"files": [input_file], "output_type": output_type} + ).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 == "/pdfx": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.convert_to_pdfx( + input_file, + output_type=output_type, + ) + + 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) == str(input_file.id) + + +def test_convert_to_pdfx_validation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + png_file = PdfRestFile.model_validate( + build_file_info_payload( + PdfRestFileID.generate(), + "example.png", + "image/png", + ) + ) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Field required"), + ): + client.convert_to_pdfx(pdf_file, output_type=None) # type: ignore[arg-type] + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Must be a PDF file"), + ): + client.convert_to_pdfx(png_file, output_type="PDF/X-3") + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="PDF/X-1a"), + ): + client.convert_to_pdfx(pdf_file, output_type="PDF/X-5") # type: ignore[arg-type] From 8f1051c62af500837e1c9251f037761c953caf47 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 16 Dec 2025 12:09:22 -0600 Subject: [PATCH 3/5] Add Flatten Forms tool Assisted-by: Codex --- src/pdfrest/client.py | 53 ++++++++++++ src/pdfrest/models/_internal.py | 23 +++++ tests/test_flatten_pdf_forms.py | 143 ++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 tests/test_flatten_pdf_forms.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index cfcdbc9b..9b4f202d 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -79,6 +79,7 @@ PdfRedactionPreviewPayload, PdfRestRawFileResponse, PdfSplitPayload, + PdfFlattenFormsPayload, PdfToPdfxPayload, PdfToWordPayload, PngPdfRestPayload, @@ -2170,6 +2171,32 @@ def convert_to_word( timeout=timeout, ) + def flatten_pdf_forms( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Flatten form fields in a PDF so they are no longer editable.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/flattened-forms-pdf", + payload=payload, + payload_model=PdfFlattenFormsPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_pdfx( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -2654,6 +2681,32 @@ async def convert_to_word( timeout=timeout, ) + async def flatten_pdf_forms( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + 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 flatten form fields in a PDF.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/flattened-forms-pdf", + payload=payload, + payload_model=PdfFlattenFormsPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def convert_to_pdfx( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index dc8a7da3..1d666824 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -525,6 +525,29 @@ class PdfToPdfxPayload(BaseModel): ] = None +class PdfFlattenFormsPayload(BaseModel): + """Adapt caller options into a pdfRest-ready flatten-forms request payload.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + class BmpPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "gray"]]): """Adapt caller options into a pdfRest-ready BMP request payload.""" diff --git a/tests/test_flatten_pdf_forms.py b/tests/test_flatten_pdf_forms.py new file mode 100644 index 00000000..8b22bd4e --- /dev/null +++ b/tests/test_flatten_pdf_forms.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfFlattenFormsPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_flatten_pdf_forms_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfFlattenFormsPayload.model_validate( + {"files": [input_file], "output": "flattened"} + ).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 == "/flattened-forms-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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, + "flattened.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.flatten_pdf_forms(input_file, output="flattened") + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "flattened.pdf" + assert response.output_file.type == "application/pdf" + assert str(response.input_id) == str(input_file.id) + assert response.warning is None + + +@pytest.mark.asyncio +async def test_async_flatten_pdf_forms_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + output_id = str(PdfRestFileID.generate()) + + payload_dump = PdfFlattenFormsPayload.model_validate( + {"files": [input_file]} + ).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 == "/flattened-forms-pdf": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.flatten_pdf_forms(input_file) + + 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) == str(input_file.id) + + +def test_flatten_pdf_forms_validation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + png_file = PdfRestFile.model_validate( + build_file_info_payload( + PdfRestFileID.generate(), + "example.png", + "image/png", + ) + ) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Must be a PDF file"), + ): + client.flatten_pdf_forms(png_file) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ), + ): + client.flatten_pdf_forms([pdf_file, make_pdf_file(PdfRestFileID.generate())]) From ae740e91cebe250e98dfcafc4e06ba400c68a2b8 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 16 Dec 2025 12:16:58 -0600 Subject: [PATCH 4/5] Fix model import order Assisted-by: Codex --- src/pdfrest/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 9b4f202d..d2b136c9 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -73,13 +73,13 @@ BmpPdfRestPayload, GifPdfRestPayload, JpegPdfRestPayload, + PdfFlattenFormsPayload, PdfInfoPayload, PdfMergePayload, PdfRedactionApplyPayload, PdfRedactionPreviewPayload, PdfRestRawFileResponse, PdfSplitPayload, - PdfFlattenFormsPayload, PdfToPdfxPayload, PdfToWordPayload, PngPdfRestPayload, From 95ca18b7adb12d2ce0315dd87d105744be3aa3d1 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 18 Dec 2025 10:48:10 -0600 Subject: [PATCH 5/5] Fix expected PDF/X `ValidationError` message --- tests/test_convert_to_pdfx.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_convert_to_pdfx.py b/tests/test_convert_to_pdfx.py index a264aa74..fd2918f0 100644 --- a/tests/test_convert_to_pdfx.py +++ b/tests/test_convert_to_pdfx.py @@ -134,7 +134,10 @@ def test_convert_to_pdfx_validation(monkeypatch: pytest.MonkeyPatch) -> None: with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValidationError, match="Field required"), + pytest.raises( + ValidationError, + match="Input should be 'PDF/X-1a', 'PDF/X-3', 'PDF/X-4' or 'PDF/X-6'", + ), ): client.convert_to_pdfx(pdf_file, output_type=None) # type: ignore[arg-type]