From 6332cf994ffc2ebe5a078c4b26aadd2ddc24f140 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 18 Dec 2025 14:28:47 -0600 Subject: [PATCH 01/84] Add Linearize PDF Assisted-by: Codex --- src/pdfrest/client.py | 53 +++++ src/pdfrest/models/_internal.py | 24 +++ tests/live/test_live_linearize_pdf.py | 72 +++++++ tests/test_linearize_pdf.py | 282 ++++++++++++++++++++++++++ 4 files changed, 431 insertions(+) create mode 100644 tests/live/test_live_linearize_pdf.py create mode 100644 tests/test_linearize_pdf.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 8818899f..0b8aeb1c 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -79,6 +79,7 @@ JpegPdfRestPayload, PdfCompressPayload, PdfFlattenFormsPayload, + PdfLinearizePayload, PdfInfoPayload, PdfMergePayload, PdfRedactionApplyPayload, @@ -2305,6 +2306,32 @@ def compress_pdf( extra_body=extra_body, timeout=timeout, ) + + def linearize_pdf( + 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: + """Linearize a PDF for optimized fast web view.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/linearized-pdf", + payload=payload, + payload_model=PdfLinearizePayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) def convert_to_pdfx( self, @@ -2848,6 +2875,32 @@ async def compress_pdf( extra_body=extra_body, timeout=timeout, ) + + async def linearize_pdf( + 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 linearize a PDF for optimized fast web view.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/linearized-pdf", + payload=payload, + payload_model=PdfLinearizePayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) async def convert_to_pdfx( self, diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 33cb8747..126914e1 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -626,6 +626,30 @@ def _validate_profile_dependency(self) -> PdfCompressPayload: return self +class PdfLinearizePayload(BaseModel): + """Adapt caller options into a pdfRest-ready linearize PDF 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/live/test_live_linearize_pdf.py b/tests/live/test_live_linearize_pdf.py new file mode 100644 index 00000000..59612691 --- /dev/null +++ b/tests/live/test_live_linearize_pdf.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import pytest + +from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_linearize( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("linearized-live", id="custom-output"), + ], +) +def test_live_linearize_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_linearize: PdfRestFile, + output_name: str | None, +) -> None: + kwargs: dict[str, str] = {} + 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.linearize_pdf(uploaded_pdf_for_linearize, **kwargs) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_linearize.id) + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pdf") + + +def test_live_linearize_pdf_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_linearize: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError), + ): + client.linearize_pdf( + uploaded_pdf_for_linearize, + extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, + ) diff --git a/tests/test_linearize_pdf.py b/tests/test_linearize_pdf.py new file mode 100644 index 00000000..6b212437 --- /dev/null +++ b/tests/test_linearize_pdf.py @@ -0,0 +1,282 @@ +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 PdfLinearizePayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_linearize_pdf_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 = PdfLinearizePayload.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 == "/linearized-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, + "linearized.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.linearize_pdf(input_file) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "linearized.pdf" + assert response.output_file.type == "application/pdf" + assert str(response.input_id) == str(input_file.id) + assert response.warning is None + + +def test_linearize_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + 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 == "/linearized-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["debug"] == "yes" + assert payload["id"] == str(input_file.id) + assert payload["output"] == "linearized" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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, + "linearized-out.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.linearize_pdf( + input_file, + output="linearized", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": "yes"}, + timeout=0.61, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "linearized-out.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.61) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.61) + + +@pytest.mark.asyncio +async def test_async_linearize_pdf_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 = PdfLinearizePayload.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 == "/linearized-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-linearized.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.linearize_pdf(input_file) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-linearized.pdf" + assert response.output_file.type == "application/pdf" + assert str(response.input_id) == str(input_file.id) + + +@pytest.mark.asyncio +async def test_async_linearize_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + 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 == "/linearized-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["flags"] == ["a", "b"] + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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-linearized-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.linearize_pdf( + input_file, + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"flags": ["a", "b"]}, + timeout=0.83, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-linearized-custom.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.83) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.83) + + +@pytest.mark.parametrize( + ("files", "match"), + [ + pytest.param( + "png", + "Must be a PDF file", + id="non-pdf-file", + ), + pytest.param( + "multiple", + "List should have at most 1 item after validation", + id="multiple-files", + ), + ], +) +def test_linearize_pdf_validation( + monkeypatch: pytest.MonkeyPatch, + files: str, + match: str, +) -> 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)) + files_argument = ( + png_file + if files == "png" + else [pdf_file, make_pdf_file(PdfRestFileID.generate())] + ) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match=match), + ): + client.linearize_pdf(files_argument) From d7c815eb654efa1a662c63e54e684c56c7652cad Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 18 Dec 2025 15:08:31 -0600 Subject: [PATCH 02/84] Add Summarize PDF Assisted-by: Codex --- src/pdfrest/client.py | 93 +++++++++ src/pdfrest/models/__init__.py | 2 + src/pdfrest/models/_internal.py | 58 +++++- src/pdfrest/models/public.py | 38 ++++ src/pdfrest/types/__init__.py | 6 + src/pdfrest/types/public.py | 18 ++ tests/live/test_live_summarize_pdf_text.py | 47 +++++ tests/test_summarize_pdf_text.py | 207 +++++++++++++++++++++ 8 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 tests/live/test_live_summarize_pdf_text.py create mode 100644 tests/test_summarize_pdf_text.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 0b8aeb1c..e46198ed 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -66,6 +66,7 @@ PdfRestFileBasedResponse, PdfRestFileID, PdfRestInfoResponse, + SummarizePdfTextResponse, UpResponse, ) @@ -89,6 +90,7 @@ PdfToPdfxPayload, PdfToWordPayload, PngPdfRestPayload, + SummarizePdfTextPayload, TiffPdfRestPayload, UploadURLs, ) @@ -100,6 +102,9 @@ PdfRedactionInstruction, PdfRGBColor, PdfXType, + SummaryFormat, + SummaryOutputFormat, + SummaryOutputType, ) DEFAULT_BASE_URL = "https://api.pdfrest.com" @@ -2106,6 +2111,50 @@ def query_pdf_info( raw_payload = self._send_request(request) return PdfRestInfoResponse.model_validate(raw_payload) + def summarize_pdf_text( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + target_word_count: int | None = 400, + summary_format: SummaryFormat = "overview", + pages: PdfPageSelection | None = None, + output_format: SummaryOutputFormat = "markdown", + output_type: SummaryOutputType = "json", + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> SummarizePdfTextResponse: + """Summarize the textual content of a PDF, Markdown, or text document.""" + + payload: dict[str, Any] = { + "files": file, + "target_word_count": target_word_count, + "summary_format": summary_format, + "output_format": output_format, + "output_type": output_type, + } + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = SummarizePdfTextPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/summarized-pdf-text", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = self._send_request(request) + return SummarizePdfTextResponse.model_validate(raw_payload) + def preview_redactions( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -2633,6 +2682,50 @@ async def query_pdf_info( raw_payload = await self._send_request(request) return PdfRestInfoResponse.model_validate(raw_payload) + async def summarize_pdf_text( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + target_word_count: int | None = 400, + summary_format: SummaryFormat = "overview", + pages: PdfPageSelection | None = None, + output_format: SummaryOutputFormat = "markdown", + output_type: SummaryOutputType = "json", + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> SummarizePdfTextResponse: + """Summarize the textual content of a PDF, Markdown, or text document.""" + + payload: dict[str, Any] = { + "files": file, + "target_word_count": target_word_count, + "summary_format": summary_format, + "output_format": output_format, + "output_type": output_type, + } + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = SummarizePdfTextPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/summarized-pdf-text", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = await self._send_request(request) + return SummarizePdfTextResponse.model_validate(raw_payload) + async def preview_redactions( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index 54c9aeb4..f81577e1 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -5,6 +5,7 @@ PdfRestFileBasedResponse, PdfRestFileID, PdfRestInfoResponse, + SummarizePdfTextResponse, UpResponse, ) @@ -15,5 +16,6 @@ "PdfRestFileBasedResponse", "PdfRestFileID", "PdfRestInfoResponse", + "SummarizePdfTextResponse", "UpResponse", ] diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 126914e1..fb8349dc 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -21,7 +21,13 @@ from pdfrest.types.public import PdfRedactionPreset -from ..types import PdfInfoQuery, PdfXType +from ..types import ( + PdfInfoQuery, + PdfXType, + SummaryFormat, + SummaryOutputFormat, + SummaryOutputType, +) from . import PdfRestFile from .public import PdfRestFileID @@ -248,6 +254,56 @@ class PdfInfoPayload(BaseModel): ] +class SummarizePdfTextPayload(BaseModel): + """Adapt caller options into a pdfRest-ready summarize 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", + "text/markdown", + "text/plain", + error_msg="Must be a PDF, Markdown, or plain text file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] + target_word_count: Annotated[ + int | None, Field(serialization_alias="target_word_count", ge=1, default=400) + ] = 400 + summary_format: Annotated[ + SummaryFormat, Field(serialization_alias="summary_format", default="overview") + ] = "overview" + pages: Annotated[ + list[AscendingPageRange] | None, + Field(serialization_alias="pages", min_length=1, default=None), + BeforeValidator(_ensure_list), + BeforeValidator(_split_comma_list), + BeforeValidator(_int_to_string), + PlainSerializer(_serialize_page_ranges), + ] = None + output_format: Annotated[ + SummaryOutputFormat, + Field(serialization_alias="output_format", default="markdown"), + ] = "markdown" + output_type: Annotated[ + SummaryOutputType, Field(serialization_alias="output_type", default="json") + ] = "json" + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + RgbChannel = Annotated[int, Field(ge=0, le=255)] diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 3de11476..59a96ec8 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -26,6 +26,7 @@ "PdfRestFileBasedResponse", "PdfRestFileID", "PdfRestInfoResponse", + "SummarizePdfTextResponse", "UpResponse", ) @@ -310,6 +311,43 @@ class PdfRestDeletionResponse(BaseModel): min_length=1, ), ] +class SummarizePdfTextResponse(BaseModel): + """Response returned by the summarize-pdf-text tool.""" + + model_config = ConfigDict(extra="allow") + + summary: Annotated[ + str | None, + Field( + description="Inline summary content when output_type is json.", + default=None, + ), + ] = None + input_id: Annotated[ + PdfRestFileID, + Field( + validation_alias=AliasChoices("input_id", "inputId"), + description="The id of the input file.", + ), + ] + output_url: Annotated[ + HttpUrl | None, + Field( + alias="outputUrl", + validation_alias=AliasChoices("output_url", "outputUrl"), + description="Download URL for file output.", + default=None, + ), + ] = None + output_id: Annotated[ + PdfRestFileID | None, + Field( + alias="outputId", + validation_alias=AliasChoices("output_id", "outputId"), + description="The id of the generated output when output_type is file.", + default=None, + ), + ] = None class PdfRestInfoResponse(BaseModel): diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 9bc36a87..d1c16809 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -11,6 +11,9 @@ PdfRedactionType, PdfRGBColor, PdfXType, + SummaryFormat, + SummaryOutputFormat, + SummaryOutputType, ) __all__ = [ @@ -24,4 +27,7 @@ "PdfRedactionPreset", "PdfRedactionType", "PdfXType", + "SummaryFormat", + "SummaryOutputFormat", + "SummaryOutputType", ] diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 1df53284..10fc2028 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -23,6 +23,9 @@ "PdfRedactionPreset", "PdfRedactionType", "PdfXType", + "SummaryFormat", + "SummaryOutputFormat", + "SummaryOutputType", ) PdfInfoQuery = Literal[ @@ -99,3 +102,18 @@ 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"] + +SummaryFormat = Literal[ + "overview", + "highlight", + "abstract", + "bullet_points", + "numbered_list", + "table_of_contents", + "outline", + "question_answer", + "action_items", +] + +SummaryOutputFormat = Literal["plaintext", "markdown"] +SummaryOutputType = Literal["json", "file"] diff --git a/tests/live/test_live_summarize_pdf_text.py b/tests/live/test_live_summarize_pdf_text.py new file mode 100644 index 00000000..25d287b0 --- /dev/null +++ b/tests/live/test_live_summarize_pdf_text.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import pytest + +from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest.models import SummarizePdfTextResponse + +from ..resources import get_test_resource_path + + +def test_live_summarize_pdf_text_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + response = client.summarize_pdf_text( + uploaded, + target_word_count=40, + output_type="json", + summary_format="overview", + ) + + assert isinstance(response, SummarizePdfTextResponse) + assert response.summary + assert response.input_id == uploaded.id + + +def test_live_summarize_pdf_text_invalid_format( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + with pytest.raises(PdfRestApiError, match="error"): + client.summarize_pdf_text( + uploaded, + extra_body={"summary_format": "invalid-style"}, + ) diff --git a/tests/test_summarize_pdf_text.py b/tests/test_summarize_pdf_text.py new file mode 100644 index 00000000..99f481f9 --- /dev/null +++ b/tests/test_summarize_pdf_text.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileID, SummarizePdfTextResponse +from pdfrest.models._internal import SummarizePdfTextPayload + +from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file + + +def _make_text_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + { + "id": file_id, + "name": "notes.txt", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/plain", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + + +def test_summarize_payload_rejects_invalid_mime() -> None: + file_id = str(PdfRestFileID.generate()) + image_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "image.png", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "image/png", + "size": 10, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + + with pytest.raises( + ValidationError, match="Must be a PDF, Markdown, or plain text file" + ): + SummarizePdfTextPayload.model_validate({"files": [image_file]}) + + +def test_summarize_payload_invalid_page_range() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + + with pytest.raises( + ValidationError, match="The start page must be less than or equal to the end" + ): + SummarizePdfTextPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) + + +def test_summarize_pdf_text_json_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_text_file(str(PdfRestFileID.generate(1))) + payload_dump = SummarizePdfTextPayload.model_validate( + { + "files": [input_file], + "target_word_count": 120, + "summary_format": "bullet_points", + "pages": ["1-3"], + "output_format": "plaintext", + "output_type": "json", + "output": "summary", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/summarized-pdf-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "summary": "Key points...", + "inputId": str(input_file.id), + }, + ) + 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.summarize_pdf_text( + input_file, + target_word_count=120, + summary_format="bullet_points", + pages=["1-3"], + output_format="plaintext", + output_type="json", + output="summary", + ) + + assert seen == {"post": 1} + assert isinstance(response, SummarizePdfTextResponse) + assert response.summary == "Key points..." + assert response.input_id == input_file.id + assert response.output_id is None + assert response.output_url is None + + +def test_summarize_pdf_text_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + payload_dump = SummarizePdfTextPayload.model_validate( + { + "files": [input_file], + "output_type": "file", + "output_format": "markdown", + "summary_format": "overview", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + 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 == "/summarized-pdf-text": + 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")) + for key, value in payload_dump.items(): + assert payload[key] == value + assert payload["debug"] is True + return httpx.Response( + 200, + json={ + "outputUrl": f"https://api.pdfrest.com/resource/{output_id}?format=file", + "outputId": output_id, + "inputId": str(input_file.id), + }, + ) + 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.summarize_pdf_text( + input_file, + output_type="file", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.25, + ) + + assert isinstance(response, SummarizePdfTextResponse) + assert response.output_id == output_id + assert response.output_url + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.25) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.25) + + +@pytest.mark.asyncio +async def test_async_summarize_pdf_text_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = SummarizePdfTextPayload.model_validate( + {"files": [input_file], "output_type": "json"} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/summarized-pdf-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + for key, value in payload_dump.items(): + assert payload[key] == value + return httpx.Response( + 200, + json={ + "summary": "Async summary", + "inputId": str(input_file.id), + }, + ) + 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.summarize_pdf_text(input_file, output_type="json") + + assert seen == {"post": 1} + assert isinstance(response, SummarizePdfTextResponse) + assert response.summary == "Async summary" + assert response.input_id == input_file.id From a7de4256f09a7ebc8306534f09bb40f0cae91fb9 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 18 Dec 2025 15:29:55 -0600 Subject: [PATCH 03/84] Add Translate PDF Assisted-by: Codex --- src/pdfrest/client.py | 94 ++++++++++ src/pdfrest/models/__init__.py | 2 + src/pdfrest/models/_internal.py | 53 ++++++ src/pdfrest/models/public.py | 40 ++++ src/pdfrest/types/__init__.py | 4 + src/pdfrest/types/public.py | 5 + tests/live/test_live_translate_pdf_text.py | 48 +++++ tests/test_translate_pdf_text.py | 208 +++++++++++++++++++++ 8 files changed, 454 insertions(+) create mode 100644 tests/live/test_live_translate_pdf_text.py create mode 100644 tests/test_translate_pdf_text.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index e46198ed..f046a4de 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -67,6 +67,7 @@ PdfRestFileID, PdfRestInfoResponse, SummarizePdfTextResponse, + TranslatePdfTextResponse, UpResponse, ) @@ -92,6 +93,7 @@ PngPdfRestPayload, SummarizePdfTextPayload, TiffPdfRestPayload, + TranslatePdfTextPayload, UploadURLs, ) from .types import ( @@ -105,6 +107,8 @@ SummaryFormat, SummaryOutputFormat, SummaryOutputType, + TranslateOutputFormat, + TranslateOutputType, ) DEFAULT_BASE_URL = "https://api.pdfrest.com" @@ -2155,6 +2159,51 @@ def summarize_pdf_text( raw_payload = self._send_request(request) return SummarizePdfTextResponse.model_validate(raw_payload) + def translate_pdf_text( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + target_language: str, + source_language: str | None = None, + pages: PdfPageSelection | None = None, + output_format: TranslateOutputFormat = "markdown", + output_type: TranslateOutputType = "json", + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> TranslatePdfTextResponse: + """Translate the textual content of a PDF, Markdown, or text document.""" + + payload: dict[str, Any] = { + "files": file, + "target_language": target_language, + "output_format": output_format, + "output_type": output_type, + } + if source_language is not None: + payload["source_language"] = source_language + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = TranslatePdfTextPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/translated-pdf-text", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = self._send_request(request) + return TranslatePdfTextResponse.model_validate(raw_payload) + def preview_redactions( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -2726,6 +2775,51 @@ async def summarize_pdf_text( raw_payload = await self._send_request(request) return SummarizePdfTextResponse.model_validate(raw_payload) + async def translate_pdf_text( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + target_language: str, + source_language: str | None = None, + pages: PdfPageSelection | None = None, + output_format: TranslateOutputFormat = "markdown", + output_type: TranslateOutputType = "json", + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> TranslatePdfTextResponse: + """Translate the textual content of a PDF, Markdown, or text document.""" + + payload: dict[str, Any] = { + "files": file, + "target_language": target_language, + "output_format": output_format, + "output_type": output_type, + } + if source_language is not None: + payload["source_language"] = source_language + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = TranslatePdfTextPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/translated-pdf-text", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = await self._send_request(request) + return TranslatePdfTextResponse.model_validate(raw_payload) + async def preview_redactions( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index f81577e1..92907075 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -6,6 +6,7 @@ PdfRestFileID, PdfRestInfoResponse, SummarizePdfTextResponse, + TranslatePdfTextResponse, UpResponse, ) @@ -17,5 +18,6 @@ "PdfRestFileID", "PdfRestInfoResponse", "SummarizePdfTextResponse", + "TranslatePdfTextResponse", "UpResponse", ] diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index fb8349dc..8c79046a 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -27,6 +27,8 @@ SummaryFormat, SummaryOutputFormat, SummaryOutputType, + TranslateOutputFormat, + TranslateOutputType, ) from . import PdfRestFile from .public import PdfRestFileID @@ -304,6 +306,57 @@ class SummarizePdfTextPayload(BaseModel): ] = None +class TranslatePdfTextPayload(BaseModel): + """Adapt caller options into a pdfRest-ready translate 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", + "text/markdown", + "text/plain", + error_msg="Must be a PDF, Markdown, or plain text file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] + target_language: Annotated[ + str, Field(serialization_alias="target_language", min_length=1) + ] + source_language: Annotated[ + str | None, + Field(serialization_alias="source_language", min_length=1, default=None), + ] = None + pages: Annotated[ + list[AscendingPageRange] | None, + Field(serialization_alias="pages", min_length=1, default=None), + BeforeValidator(_ensure_list), + BeforeValidator(_split_comma_list), + BeforeValidator(_int_to_string), + PlainSerializer(_serialize_page_ranges), + ] = None + output_format: Annotated[ + TranslateOutputFormat, + Field(serialization_alias="output_format", default="markdown"), + ] = "markdown" + output_type: Annotated[ + TranslateOutputType, Field(serialization_alias="output_type", default="json") + ] = "json" + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + RgbChannel = Annotated[int, Field(ge=0, le=255)] diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 59a96ec8..01b168bc 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -27,6 +27,7 @@ "PdfRestFileID", "PdfRestInfoResponse", "SummarizePdfTextResponse", + "TranslatePdfTextResponse", "UpResponse", ) @@ -350,6 +351,45 @@ class SummarizePdfTextResponse(BaseModel): ] = None +class TranslatePdfTextResponse(BaseModel): + """Response returned by the translated-pdf-text tool.""" + + model_config = ConfigDict(extra="allow") + + translation: Annotated[ + str | None, + Field( + description="Inline translation content when output_type is json.", + default=None, + ), + ] = None + input_id: Annotated[ + PdfRestFileID, + Field( + validation_alias=AliasChoices("input_id", "inputId"), + description="The id of the input file.", + ), + ] + output_url: Annotated[ + HttpUrl | None, + Field( + alias="outputUrl", + validation_alias=AliasChoices("output_url", "outputUrl"), + description="Download URL for file output.", + default=None, + ), + ] = None + output_id: Annotated[ + PdfRestFileID | None, + Field( + alias="outputId", + validation_alias=AliasChoices("output_id", "outputId"), + description="The id of the generated output when output_type is file.", + default=None, + ), + ] = None + + class PdfRestInfoResponse(BaseModel): """A response containing the output from the /info route.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index d1c16809..87cb53b8 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -14,6 +14,8 @@ SummaryFormat, SummaryOutputFormat, SummaryOutputType, + TranslateOutputFormat, + TranslateOutputType, ) __all__ = [ @@ -30,4 +32,6 @@ "SummaryFormat", "SummaryOutputFormat", "SummaryOutputType", + "TranslateOutputFormat", + "TranslateOutputType", ] diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 10fc2028..1c692c5e 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -26,6 +26,8 @@ "SummaryFormat", "SummaryOutputFormat", "SummaryOutputType", + "TranslateOutputFormat", + "TranslateOutputType", ) PdfInfoQuery = Literal[ @@ -117,3 +119,6 @@ class PdfMergeSource(TypedDict, total=False): SummaryOutputFormat = Literal["plaintext", "markdown"] SummaryOutputType = Literal["json", "file"] + +TranslateOutputFormat = Literal["plaintext", "markdown"] +TranslateOutputType = Literal["json", "file"] diff --git a/tests/live/test_live_translate_pdf_text.py b/tests/live/test_live_translate_pdf_text.py new file mode 100644 index 00000000..da35d638 --- /dev/null +++ b/tests/live/test_live_translate_pdf_text.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import pytest + +from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest.models import TranslatePdfTextResponse + +from ..resources import get_test_resource_path + + +def test_live_translate_pdf_text_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + response = client.translate_pdf_text( + uploaded, + target_language="fr", + output_type="json", + output_format="plaintext", + ) + + assert isinstance(response, TranslatePdfTextResponse) + assert response.translation + assert response.input_id == uploaded.id + + +def test_live_translate_pdf_text_invalid_output_format( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + with pytest.raises(PdfRestApiError, match="error"): + client.translate_pdf_text( + uploaded, + target_language="es", + extra_body={"output_format": "invalid-format"}, + ) diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py new file mode 100644 index 00000000..5f2fd1b3 --- /dev/null +++ b/tests/test_translate_pdf_text.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileID, TranslatePdfTextResponse +from pdfrest.models._internal import TranslatePdfTextPayload + +from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file + + +def _make_markdown_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + { + "id": file_id, + "name": "notes.md", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/markdown", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + + +def test_translate_payload_rejects_invalid_mime() -> None: + file_id = str(PdfRestFileID.generate()) + image_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "image.png", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "image/png", + "size": 10, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + + with pytest.raises( + ValidationError, match="Must be a PDF, Markdown, or plain text file" + ): + TranslatePdfTextPayload.model_validate( + {"files": [image_file], "target_language": "fr"} + ) + + +def test_translate_payload_requires_target_language() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises(ValidationError): + TranslatePdfTextPayload.model_validate({"files": [file_repr]}) + + +def test_translate_pdf_text_json_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_markdown_file(str(PdfRestFileID.generate(1))) + payload_dump = TranslatePdfTextPayload.model_validate( + { + "files": [input_file], + "target_language": "fr", + "source_language": "en", + "pages": ["1-2"], + "output_format": "plaintext", + "output_type": "json", + "output": "translation", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/translated-pdf-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "translation": "Bonjour", + "inputId": str(input_file.id), + }, + ) + 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.translate_pdf_text( + input_file, + target_language="fr", + source_language="en", + pages=["1-2"], + output_format="plaintext", + output_type="json", + output="translation", + ) + + assert seen == {"post": 1} + assert isinstance(response, TranslatePdfTextResponse) + assert response.translation == "Bonjour" + assert response.input_id == input_file.id + assert response.output_id is None + assert response.output_url is None + + +def test_translate_pdf_text_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + payload_dump = TranslatePdfTextPayload.model_validate( + { + "files": [input_file], + "target_language": "es", + "output_type": "file", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + 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 == "/translated-pdf-text": + 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")) + for key, value in payload_dump.items(): + assert payload[key] == value + assert payload["debug"] is True + return httpx.Response( + 200, + json={ + "outputUrl": f"https://api.pdfrest.com/resource/{output_id}?format=file", + "outputId": output_id, + "inputId": str(input_file.id), + }, + ) + 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.translate_pdf_text( + input_file, + target_language="es", + output_type="file", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.3, + ) + + assert isinstance(response, TranslatePdfTextResponse) + assert response.output_id == output_id + assert response.output_url + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.3) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.3) + + +@pytest.mark.asyncio +async def test_async_translate_pdf_text_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = TranslatePdfTextPayload.model_validate( + {"files": [input_file], "target_language": "de", "output_type": "json"} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/translated-pdf-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + for key, value in payload_dump.items(): + assert payload[key] == value + return httpx.Response( + 200, + json={ + "translation": "Hallo", + "inputId": str(input_file.id), + }, + ) + 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.translate_pdf_text( + input_file, target_language="de", output_type="json" + ) + + assert seen == {"post": 1} + assert isinstance(response, TranslatePdfTextResponse) + assert response.translation == "Hallo" + assert response.input_id == input_file.id From 935fda2d79b15b4d36fbf9f4b6db6666e0f86e0c Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 18 Dec 2025 15:34:23 -0600 Subject: [PATCH 04/84] Add Extract Images Assisted-by: Codex --- src/pdfrest/client.py | 117 ++++++++++++++ src/pdfrest/models/__init__.py | 2 + src/pdfrest/models/_internal.py | 32 ++++ src/pdfrest/models/public.py | 26 +++ tests/live/test_live_extract_images.py | 42 +++++ tests/test_extract_images.py | 211 +++++++++++++++++++++++++ 6 files changed, 430 insertions(+) create mode 100644 tests/live/test_live_extract_images.py create mode 100644 tests/test_extract_images.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index f046a4de..6efbfe8d 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -61,6 +61,7 @@ ) from .models import ( PdfRestDeletionResponse, + ExtractImagesResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -77,6 +78,7 @@ BasePdfRestGraphicPayload, BmpPdfRestPayload, DeletePayload, + ExtractImagesPayload, GifPdfRestPayload, JpegPdfRestPayload, PdfCompressPayload, @@ -2204,6 +2206,60 @@ def translate_pdf_text( raw_payload = self._send_request(request) return TranslatePdfTextResponse.model_validate(raw_payload) + def extract_images( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + pages: PdfPageSelection | 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, + ) -> ExtractImagesResponse: + """Extract embedded images from a PDF.""" + + payload: dict[str, Any] = {"files": file} + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = ExtractImagesPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/extracted-images", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = self._send_request(request) + raw_response = PdfRestRawFileResponse.model_validate(raw_payload) + output_ids = raw_response.ids or [] + output_files = [ + self.fetch_file_info( + str(file_id), + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + for file_id in output_ids + ] + input_id = raw_response.input_id[0] if raw_response.input_id else "" + return ExtractImagesResponse.model_validate( + { + "input_id": input_id, + "output_files": [ + file.model_dump(mode="json", by_alias=True) for file in output_files + ], + "warning": raw_response.warning, + } + ) + def preview_redactions( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -2820,6 +2876,67 @@ async def translate_pdf_text( raw_payload = await self._send_request(request) return TranslatePdfTextResponse.model_validate(raw_payload) + async def extract_images( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + pages: PdfPageSelection | 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, + ) -> ExtractImagesResponse: + """Extract embedded images from a PDF.""" + + payload: dict[str, Any] = {"files": file} + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = ExtractImagesPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/extracted-images", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = await self._send_request(request) + raw_response = PdfRestRawFileResponse.model_validate(raw_payload) + output_ids = raw_response.ids or [] + semaphore = asyncio.Semaphore(DEFAULT_FILE_INFO_CONCURRENCY) + + async def fetch(file_id: str) -> PdfRestFile: + async with semaphore: + return await self.fetch_file_info( + file_id, + extra_query=extra_query, + extra_headers=extra_headers, + timeout=timeout, + ) + + output_files: list[PdfRestFile] = [] + if output_ids: + output_files = list( + await asyncio.gather(*(fetch(fid) for fid in output_ids)) + ) + input_id = raw_response.input_id[0] if raw_response.input_id else "" + return ExtractImagesResponse.model_validate( + { + "input_id": input_id, + "output_files": [ + file.model_dump(mode="json", by_alias=True) for file in output_files + ], + "warning": raw_response.warning, + } + ) + async def preview_redactions( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index 92907075..6d4f8ad8 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -1,5 +1,6 @@ from .public import ( PdfRestDeletionResponse, + ExtractImagesResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -12,6 +13,7 @@ __all__ = [ "PdfRestDeletionResponse", + "ExtractImagesResponse", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 8c79046a..70aecba1 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -357,6 +357,38 @@ class TranslatePdfTextPayload(BaseModel): ] = None +class ExtractImagesPayload(BaseModel): + """Adapt caller options into a pdfRest-ready extract images 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), + ] + pages: Annotated[ + list[AscendingPageRange] | None, + Field(serialization_alias="pages", min_length=1, default=None), + BeforeValidator(_ensure_list), + BeforeValidator(_split_comma_list), + BeforeValidator(_int_to_string), + PlainSerializer(_serialize_page_ranges), + ] = None + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + RgbChannel = Annotated[int, Field(ge=0, le=255)] diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 01b168bc..82c362db 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -21,6 +21,7 @@ __all__ = ( "PdfRestDeletionResponse", + "ExtractImagesResponse", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", @@ -390,6 +391,31 @@ class TranslatePdfTextResponse(BaseModel): ] = None +class ExtractImagesResponse(BaseModel): + """Response returned by the extracted-images tool.""" + + model_config = ConfigDict(extra="allow") + + input_id: Annotated[ + PdfRestFileID, + Field( + validation_alias=AliasChoices("input_id", "inputId"), + description="The id of the input file.", + ), + ] + output_files: Annotated[ + list[PdfRestFile], + Field( + description="The list of extracted image files.", + validation_alias=AliasChoices("output_files", "outputFiles"), + ), + ] + warning: Annotated[ + str | None, + Field(description="A warning that was generated during extraction."), + ] = None + + class PdfRestInfoResponse(BaseModel): """A response containing the output from the /info route.""" diff --git a/tests/live/test_live_extract_images.py b/tests/live/test_live_extract_images.py new file mode 100644 index 00000000..b89df400 --- /dev/null +++ b/tests/live/test_live_extract_images.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pytest + +from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest.models import ExtractImagesResponse + +from ..resources import get_test_resource_path + + +def test_live_extract_images_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + response = client.extract_images(uploaded) + + assert isinstance(response, ExtractImagesResponse) + assert response.output_files + assert response.input_id == uploaded.id + + +def test_live_extract_images_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + with pytest.raises(PdfRestApiError): + client.extract_images( + uploaded, + extra_body={"pages": "last-1"}, + ) diff --git a/tests/test_extract_images.py b/tests/test_extract_images.py new file mode 100644 index 00000000..2ef96842 --- /dev/null +++ b/tests/test_extract_images.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import ExtractImagesResponse, PdfRestFile, PdfRestFileID +from pdfrest.models._internal import ExtractImagesPayload + +from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file + + +def _make_png_file(file_id: str, name: str) -> PdfRestFile: + return PdfRestFile.model_validate( + { + "id": file_id, + "name": name, + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "image/png", + "size": 10, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + + +def test_extract_images_payload_rejects_non_pdf() -> None: + file_id = str(PdfRestFileID.generate()) + text_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "notes.txt", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/plain", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + with pytest.raises(ValidationError, match="Must be a PDF file"): + ExtractImagesPayload.model_validate({"files": [text_file]}) + + +def test_extract_images_payload_invalid_page_range() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises( + ValidationError, match="The start page must be less than or equal to the end" + ): + ExtractImagesPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) + + +def test_extract_images_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id_1 = str(PdfRestFileID.generate()) + output_id_2 = str(PdfRestFileID.generate()) + + payload_dump = ExtractImagesPayload.model_validate( + {"files": [input_file], "pages": ["1-3"], "output": "images"} + ).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 == "/extracted-images": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [str(input_file.id)], + "outputId": [output_id_1, output_id_2], + }, + ) + if request.method == "GET" and request.url.path in { + f"/resource/{output_id_1}", + f"/resource/{output_id_2}", + }: + seen["get"] += 1 + return httpx.Response( + 200, + json=_make_png_file( + output_id_1 + if request.url.path.endswith(output_id_1) + else output_id_2, + "image.png", + ).model_dump(mode="json", by_alias=True), + ) + 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.extract_images(input_file, pages=["1-3"], output="images") + + assert seen == {"post": 1, "get": 2} + assert isinstance(response, ExtractImagesResponse) + assert len(response.output_files) == 2 + assert response.input_id == input_file.id + + +def test_extract_images_request_customization( + 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 = ExtractImagesPayload.model_validate( + {"files": [input_file], "pages": ["1-last"]} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/extracted-images": + 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 == payload_dump | {"debug": True} + return httpx.Response( + 200, + json={ + "inputId": str(input_file.id), + "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=_make_png_file(output_id, "debug.png").model_dump( + mode="json", by_alias=True + ), + ) + 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.extract_images( + input_file, + pages=["1-last"], + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.3, + ) + + assert isinstance(response, ExtractImagesResponse) + assert len(response.output_files) == 1 + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.3) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.3) + + +@pytest.mark.asyncio +async def test_async_extract_images_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 = ExtractImagesPayload.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 == "/extracted-images": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [str(input_file.id)], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=_make_png_file(output_id, "async.png").model_dump( + mode="json", by_alias=True + ), + ) + 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.extract_images(input_file) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, ExtractImagesResponse) + assert len(response.output_files) == 1 + assert response.input_id == input_file.id From 760c98fd0d0b974f1013abcbb7ec0372bfbd02ec Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 18 Dec 2025 15:55:36 -0600 Subject: [PATCH 05/84] Add Extract Text Assisted-by: Codex --- src/pdfrest/client.py | 70 +++++++++++ src/pdfrest/models/__init__.py | 2 + src/pdfrest/models/_internal.py | 32 +++++ src/pdfrest/models/public.py | 44 +++++++ tests/live/test_live_extract_text.py | 42 +++++++ tests/test_extract_text.py | 168 +++++++++++++++++++++++++++ 6 files changed, 358 insertions(+) create mode 100644 tests/live/test_live_extract_text.py create mode 100644 tests/test_extract_text.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 6efbfe8d..6918bef4 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -62,6 +62,7 @@ from .models import ( PdfRestDeletionResponse, ExtractImagesResponse, + ExtractTextResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -79,6 +80,7 @@ BmpPdfRestPayload, DeletePayload, ExtractImagesPayload, + ExtractTextPayload, GifPdfRestPayload, JpegPdfRestPayload, PdfCompressPayload, @@ -2260,6 +2262,40 @@ def extract_images( } ) + def extract_text( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + pages: PdfPageSelection | 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, + ) -> ExtractTextResponse: + """Extract text content from a PDF.""" + + payload: dict[str, Any] = {"files": file} + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = ExtractTextPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/extracted-text", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = self._send_request(request) + return ExtractTextResponse.model_validate(raw_payload) + def preview_redactions( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -2937,6 +2973,40 @@ async def fetch(file_id: str) -> PdfRestFile: } ) + async def extract_text( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + pages: PdfPageSelection | 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, + ) -> ExtractTextResponse: + """Extract text content from a PDF.""" + + payload: dict[str, Any] = {"files": file} + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = ExtractTextPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/extracted-text", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = await self._send_request(request) + return ExtractTextResponse.model_validate(raw_payload) + async def preview_redactions( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index 6d4f8ad8..6cb78e06 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -1,6 +1,7 @@ from .public import ( PdfRestDeletionResponse, ExtractImagesResponse, + ExtractTextResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -14,6 +15,7 @@ __all__ = [ "PdfRestDeletionResponse", "ExtractImagesResponse", + "ExtractTextResponse", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 70aecba1..a5060f42 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -306,6 +306,38 @@ class SummarizePdfTextPayload(BaseModel): ] = None +class ExtractTextPayload(BaseModel): + """Adapt caller options into a pdfRest-ready extract text 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), + ] + pages: Annotated[ + list[AscendingPageRange] | None, + Field(serialization_alias="pages", min_length=1, default=None), + BeforeValidator(_ensure_list), + BeforeValidator(_split_comma_list), + BeforeValidator(_int_to_string), + PlainSerializer(_serialize_page_ranges), + ] = None + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + class TranslatePdfTextPayload(BaseModel): """Adapt caller options into a pdfRest-ready translate request payload.""" diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 82c362db..ecd97892 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -22,6 +22,7 @@ __all__ = ( "PdfRestDeletionResponse", "ExtractImagesResponse", + "ExtractTextResponse", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", @@ -416,6 +417,49 @@ class ExtractImagesResponse(BaseModel): ] = None +class ExtractTextResponse(BaseModel): + """Response returned by the extracted-text tool.""" + + model_config = ConfigDict(extra="allow") + + text: Annotated[ + str | None, + Field( + description="Inline extracted text when output_type is json.", + default=None, + ), + ] = None + input_id: Annotated[ + PdfRestFileID, + Field( + validation_alias=AliasChoices("input_id", "inputId"), + description="The id of the input file.", + ), + ] + output_url: Annotated[ + HttpUrl | None, + Field( + alias="outputUrl", + validation_alias=AliasChoices("output_url", "outputUrl"), + description="Download URL for file output.", + default=None, + ), + ] = None + output_id: Annotated[ + PdfRestFileID | None, + Field( + alias="outputId", + validation_alias=AliasChoices("output_id", "outputId"), + description="The id of the generated output when output_type is file.", + default=None, + ), + ] = None + warning: Annotated[ + str | None, + Field(description="A warning that was generated during text extraction."), + ] = None + + class PdfRestInfoResponse(BaseModel): """A response containing the output from the /info route.""" diff --git a/tests/live/test_live_extract_text.py b/tests/live/test_live_extract_text.py new file mode 100644 index 00000000..18d98f71 --- /dev/null +++ b/tests/live/test_live_extract_text.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pytest + +from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest.models import ExtractTextResponse + +from ..resources import get_test_resource_path + + +def test_live_extract_text_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + response = client.extract_text(uploaded, output=None) + + assert isinstance(response, ExtractTextResponse) + assert response.text + assert response.input_id == uploaded.id + + +def test_live_extract_text_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + with pytest.raises(PdfRestApiError): + client.extract_text( + uploaded, + extra_body={"pages": "last-1"}, + ) diff --git a/tests/test_extract_text.py b/tests/test_extract_text.py new file mode 100644 index 00000000..048a636a --- /dev/null +++ b/tests/test_extract_text.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import ExtractTextResponse, PdfRestFile, PdfRestFileID +from pdfrest.models._internal import ExtractTextPayload + +from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file + + +def test_extract_text_payload_rejects_non_pdf() -> None: + file_id = str(PdfRestFileID.generate()) + text_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "notes.txt", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/plain", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + with pytest.raises(ValidationError, match="Must be a PDF file"): + ExtractTextPayload.model_validate({"files": [text_file]}) + + +def test_extract_text_payload_invalid_page_range() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises( + ValidationError, match="The start page must be less than or equal to the end" + ): + ExtractTextPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) + + +def test_extract_text_json_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + payload_dump = ExtractTextPayload.model_validate( + {"files": [input_file], "pages": ["1-3"], "output": "text"} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/extracted-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "text": "Example extracted text", + "inputId": str(input_file.id), + }, + ) + 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.extract_text( + input_file, + pages=["1-3"], + output="text", + ) + + assert seen == {"post": 1} + assert isinstance(response, ExtractTextResponse) + assert response.text == "Example extracted text" + assert response.input_id == input_file.id + assert response.output_id is None + assert response.output_url is None + + +def test_extract_text_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + payload_dump = ExtractTextPayload.model_validate( + {"files": [input_file], "output": "file-output"} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + 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 == "/extracted-text": + 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 == payload_dump | {"debug": True} + return httpx.Response( + 200, + json={ + "outputUrl": f"https://api.pdfrest.com/resource/{output_id}?format=file", + "outputId": output_id, + "inputId": str(input_file.id), + }, + ) + 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.extract_text( + input_file, + output="file-output", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.35, + ) + + assert isinstance(response, ExtractTextResponse) + assert response.output_id == output_id + assert response.output_url + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.35) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.35) + + +@pytest.mark.asyncio +async def test_async_extract_text_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = ExtractTextPayload.model_validate( + {"files": [input_file]} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/extracted-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "text": "Async text", + "inputId": str(input_file.id), + }, + ) + 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.extract_text(input_file) + + assert seen == {"post": 1} + assert isinstance(response, ExtractTextResponse) + assert response.text == "Async text" + assert response.input_id == input_file.id From 1ccf51fe74ffeb44fd069d1422014ac9ab5867aa Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 18 Dec 2025 15:59:20 -0600 Subject: [PATCH 06/84] Add Convert to Markdown Assisted-by: Codex --- src/pdfrest/client.py | 82 +++++++++ src/pdfrest/models/__init__.py | 2 + src/pdfrest/models/_internal.py | 39 +++++ src/pdfrest/models/public.py | 44 +++++ tests/live/test_live_convert_to_markdown.py | 46 +++++ tests/test_convert_to_markdown.py | 182 ++++++++++++++++++++ 6 files changed, 395 insertions(+) create mode 100644 tests/live/test_live_convert_to_markdown.py create mode 100644 tests/test_convert_to_markdown.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 6918bef4..04bf7aa3 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -61,6 +61,7 @@ ) from .models import ( PdfRestDeletionResponse, + ConvertToMarkdownResponse, ExtractImagesResponse, ExtractTextResponse, PdfRestErrorResponse, @@ -79,6 +80,7 @@ BasePdfRestGraphicPayload, BmpPdfRestPayload, DeletePayload, + ConvertToMarkdownPayload, ExtractImagesPayload, ExtractTextPayload, GifPdfRestPayload, @@ -2163,6 +2165,46 @@ def summarize_pdf_text( raw_payload = self._send_request(request) return SummarizePdfTextResponse.model_validate(raw_payload) + def convert_to_markdown( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + pages: PdfPageSelection | None = None, + output_type: SummaryOutputType = "json", + output_format: SummaryOutputFormat = "markdown", + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> ConvertToMarkdownResponse: + """Convert a PDF to Markdown.""" + + payload: dict[str, Any] = { + "files": file, + "output_type": output_type, + "output_format": output_format, + } + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = ConvertToMarkdownPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/markdown", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = self._send_request(request) + return ConvertToMarkdownResponse.model_validate(raw_payload) + def translate_pdf_text( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -2867,6 +2909,46 @@ async def summarize_pdf_text( raw_payload = await self._send_request(request) return SummarizePdfTextResponse.model_validate(raw_payload) + async def convert_to_markdown( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + pages: PdfPageSelection | None = None, + output_type: SummaryOutputType = "json", + output_format: SummaryOutputFormat = "markdown", + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> ConvertToMarkdownResponse: + """Convert a PDF to Markdown.""" + + payload: dict[str, Any] = { + "files": file, + "output_type": output_type, + "output_format": output_format, + } + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = ConvertToMarkdownPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/markdown", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = await self._send_request(request) + return ConvertToMarkdownResponse.model_validate(raw_payload) + async def translate_pdf_text( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index 6cb78e06..ce017acf 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -1,5 +1,6 @@ from .public import ( PdfRestDeletionResponse, + ConvertToMarkdownResponse, ExtractImagesResponse, ExtractTextResponse, PdfRestErrorResponse, @@ -14,6 +15,7 @@ __all__ = [ "PdfRestDeletionResponse", + "ConvertToMarkdownResponse", "ExtractImagesResponse", "ExtractTextResponse", "PdfRestErrorResponse", diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index a5060f42..1066dc37 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -338,6 +338,45 @@ class ExtractTextPayload(BaseModel): ] = None +class ConvertToMarkdownPayload(BaseModel): + """Adapt caller options into a pdfRest-ready markdown conversion 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), + ] + pages: Annotated[ + list[AscendingPageRange] | None, + Field(serialization_alias="pages", min_length=1, default=None), + BeforeValidator(_ensure_list), + BeforeValidator(_split_comma_list), + BeforeValidator(_int_to_string), + PlainSerializer(_serialize_page_ranges), + ] = None + output_type: Annotated[ + SummaryOutputType, Field(serialization_alias="output_type", default="json") + ] = "json" + output_format: Annotated[ + SummaryOutputFormat, + Field(serialization_alias="output_format", default="markdown"), + ] = "markdown" + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + class TranslatePdfTextPayload(BaseModel): """Adapt caller options into a pdfRest-ready translate request payload.""" diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index ecd97892..46d343b1 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -21,6 +21,7 @@ __all__ = ( "PdfRestDeletionResponse", + "ConvertToMarkdownResponse", "ExtractImagesResponse", "ExtractTextResponse", "PdfRestErrorResponse", @@ -460,6 +461,49 @@ class ExtractTextResponse(BaseModel): ] = None +class ConvertToMarkdownResponse(BaseModel): + """Response returned by the markdown conversion tool.""" + + model_config = ConfigDict(extra="allow") + + markdown: Annotated[ + str | None, + Field( + description="Inline markdown content when output_type is json.", + default=None, + ), + ] = None + input_id: Annotated[ + PdfRestFileID, + Field( + validation_alias=AliasChoices("input_id", "inputId"), + description="The id of the input file.", + ), + ] + output_url: Annotated[ + HttpUrl | None, + Field( + alias="outputUrl", + validation_alias=AliasChoices("output_url", "outputUrl"), + description="Download URL for file output.", + default=None, + ), + ] = None + output_id: Annotated[ + PdfRestFileID | None, + Field( + alias="outputId", + validation_alias=AliasChoices("output_id", "outputId"), + description="The id of the generated output when output_type is file.", + default=None, + ), + ] = None + warning: Annotated[ + str | None, + Field(description="A warning that was generated during markdown conversion."), + ] = None + + class PdfRestInfoResponse(BaseModel): """A response containing the output from the /info route.""" diff --git a/tests/live/test_live_convert_to_markdown.py b/tests/live/test_live_convert_to_markdown.py new file mode 100644 index 00000000..be0c1aef --- /dev/null +++ b/tests/live/test_live_convert_to_markdown.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import pytest + +from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest.models import ConvertToMarkdownResponse + +from ..resources import get_test_resource_path + + +def test_live_convert_to_markdown_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + response = client.convert_to_markdown( + uploaded, + output_type="json", + output_format="markdown", + ) + + assert isinstance(response, ConvertToMarkdownResponse) + assert response.markdown + assert response.input_id == uploaded.id + + +def test_live_convert_to_markdown_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + with pytest.raises(PdfRestApiError): + client.convert_to_markdown( + uploaded, + extra_body={"pages": "last-1"}, + ) diff --git a/tests/test_convert_to_markdown.py b/tests/test_convert_to_markdown.py new file mode 100644 index 00000000..fd7c3958 --- /dev/null +++ b/tests/test_convert_to_markdown.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import ConvertToMarkdownResponse, PdfRestFile, PdfRestFileID +from pdfrest.models._internal import ConvertToMarkdownPayload + +from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file + + +def test_convert_to_markdown_payload_rejects_non_pdf() -> None: + file_id = str(PdfRestFileID.generate()) + text_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "notes.txt", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/plain", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + with pytest.raises(ValidationError, match="Must be a PDF file"): + ConvertToMarkdownPayload.model_validate({"files": [text_file]}) + + +def test_convert_to_markdown_payload_invalid_page_range() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises( + ValidationError, match="The start page must be less than or equal to the end" + ): + ConvertToMarkdownPayload.model_validate( + {"files": [file_repr], "pages": ["5-2"]} + ) + + +def test_convert_to_markdown_json_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + payload_dump = ConvertToMarkdownPayload.model_validate( + { + "files": [input_file], + "pages": ["1-3"], + "output": "md", + "output_type": "json", + "output_format": "markdown", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/markdown": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + for key, value in payload_dump.items(): + assert payload[key] == value + return httpx.Response( + 200, + json={ + "markdown": "# Title", + "inputId": str(input_file.id), + }, + ) + 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_markdown( + input_file, + pages=["1-3"], + output="md", + output_type="json", + output_format="markdown", + ) + + assert seen == {"post": 1} + assert isinstance(response, ConvertToMarkdownResponse) + assert response.markdown == "# Title" + assert response.input_id == input_file.id + assert response.output_id is None + assert response.output_url is None + + +def test_convert_to_markdown_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + payload_dump = ConvertToMarkdownPayload.model_validate( + {"files": [input_file], "output_type": "file", "output_format": "markdown"} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + 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 == "/markdown": + 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")) + for key, value in payload_dump.items(): + assert payload[key] == value + assert payload["debug"] is True + return httpx.Response( + 200, + json={ + "outputUrl": f"https://api.pdfrest.com/resource/{output_id}?format=file", + "outputId": output_id, + "inputId": str(input_file.id), + }, + ) + 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_markdown( + input_file, + output_type="file", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.4, + ) + + assert isinstance(response, ConvertToMarkdownResponse) + assert response.output_id == output_id + assert response.output_url + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.4) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.4) + + +@pytest.mark.asyncio +async def test_async_convert_to_markdown_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = ConvertToMarkdownPayload.model_validate( + {"files": [input_file], "output_type": "json"} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/markdown": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + for key, value in payload_dump.items(): + assert payload[key] == value + return httpx.Response( + 200, + json={ + "markdown": "Async md", + "inputId": str(input_file.id), + }, + ) + 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_markdown(input_file, output_type="json") + + assert seen == {"post": 1} + assert isinstance(response, ConvertToMarkdownResponse) + assert response.markdown == "Async md" + assert response.input_id == input_file.id From 2ac7e0002b924765b68801dab3f0b48843d18a33 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 18 Dec 2025 16:11:00 -0600 Subject: [PATCH 07/84] Add OCR PDF Assisted-by: Codex --- src/pdfrest/client.py | 94 ++++++++++++++++ src/pdfrest/models/__init__.py | 2 + src/pdfrest/models/_internal.py | 32 ++++++ src/pdfrest/models/public.py | 37 ++++++ tests/live/test_live_ocr_pdf.py | 42 +++++++ tests/test_ocr_pdf.py | 192 ++++++++++++++++++++++++++++++++ 6 files changed, 399 insertions(+) create mode 100644 tests/live/test_live_ocr_pdf.py create mode 100644 tests/test_ocr_pdf.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 04bf7aa3..81af585d 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -64,6 +64,7 @@ ConvertToMarkdownResponse, ExtractImagesResponse, ExtractTextResponse, + OcrPdfResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -86,6 +87,7 @@ GifPdfRestPayload, JpegPdfRestPayload, PdfCompressPayload, + OcrPdfPayload, PdfFlattenFormsPayload, PdfLinearizePayload, PdfInfoPayload, @@ -2205,6 +2207,52 @@ def convert_to_markdown( raw_payload = self._send_request(request) return ConvertToMarkdownResponse.model_validate(raw_payload) + def ocr_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + pages: PdfPageSelection | 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, + ) -> OcrPdfResponse: + """Perform OCR on a PDF to extract searchable text.""" + + payload: dict[str, Any] = {"files": file} + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = OcrPdfPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/pdf-with-ocr-text", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = self._send_request(request) + raw_response = PdfRestRawFileResponse.model_validate(raw_payload) + output_ids = raw_response.ids or [] + input_id = raw_response.input_id[0] if raw_response.input_id else "" + return OcrPdfResponse.model_validate( + { + "input_id": input_id, + "output_id": output_ids[0] if output_ids else None, + "output_url": raw_response.output_urls[0] + if raw_response.output_urls + else None, + "warning": raw_response.warning, + } + ) + def translate_pdf_text( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -2949,6 +2997,52 @@ async def convert_to_markdown( raw_payload = await self._send_request(request) return ConvertToMarkdownResponse.model_validate(raw_payload) + async def ocr_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + pages: PdfPageSelection | 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, + ) -> OcrPdfResponse: + """Perform OCR on a PDF to extract searchable text.""" + + payload: dict[str, Any] = {"files": file} + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + validated_payload = OcrPdfPayload.model_validate(payload) + request = self.prepare_request( + "POST", + "/pdf-with-ocr-text", + json_body=validated_payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = await self._send_request(request) + raw_response = PdfRestRawFileResponse.model_validate(raw_payload) + output_ids = raw_response.ids or [] + input_id = raw_response.input_id[0] if raw_response.input_id else "" + return OcrPdfResponse.model_validate( + { + "input_id": input_id, + "output_id": output_ids[0] if output_ids else None, + "output_url": raw_response.output_urls[0] + if raw_response.output_urls + else None, + "warning": raw_response.warning, + } + ) + async def translate_pdf_text( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index ce017acf..a3a9fdb2 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -3,6 +3,7 @@ ConvertToMarkdownResponse, ExtractImagesResponse, ExtractTextResponse, + OcrPdfResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -18,6 +19,7 @@ "ConvertToMarkdownResponse", "ExtractImagesResponse", "ExtractTextResponse", + "OcrPdfResponse", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 1066dc37..47ebde0d 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -306,6 +306,38 @@ class SummarizePdfTextPayload(BaseModel): ] = None +class OcrPdfPayload(BaseModel): + """Adapt caller options into a pdfRest-ready OCR 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), + ] + pages: Annotated[ + list[AscendingPageRange] | None, + Field(serialization_alias="pages", min_length=1, default=None), + BeforeValidator(_ensure_list), + BeforeValidator(_split_comma_list), + BeforeValidator(_int_to_string), + PlainSerializer(_serialize_page_ranges), + ] = None + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + class ExtractTextPayload(BaseModel): """Adapt caller options into a pdfRest-ready extract text request payload.""" diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 46d343b1..15daf64e 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -24,6 +24,7 @@ "ConvertToMarkdownResponse", "ExtractImagesResponse", "ExtractTextResponse", + "OcrPdfResponse", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", @@ -504,6 +505,42 @@ class ConvertToMarkdownResponse(BaseModel): ] = None +class OcrPdfResponse(BaseModel): + """Response returned by the pdf-with-ocr-text tool.""" + + model_config = ConfigDict(extra="allow") + + input_id: Annotated[ + PdfRestFileID, + Field( + validation_alias=AliasChoices("input_id", "inputId"), + description="The id of the input file.", + ), + ] + output_url: Annotated[ + HttpUrl | None, + Field( + alias="outputUrl", + validation_alias=AliasChoices("output_url", "outputUrl"), + description="Download URL for file output.", + default=None, + ), + ] = None + output_id: Annotated[ + PdfRestFileID | None, + Field( + alias="outputId", + validation_alias=AliasChoices("output_id", "outputId"), + description="The id of the generated output file.", + default=None, + ), + ] = None + warning: Annotated[ + str | None, + Field(description="A warning that was generated during OCR."), + ] = None + + class PdfRestInfoResponse(BaseModel): """A response containing the output from the /info route.""" diff --git a/tests/live/test_live_ocr_pdf.py b/tests/live/test_live_ocr_pdf.py new file mode 100644 index 00000000..43eeb2be --- /dev/null +++ b/tests/live/test_live_ocr_pdf.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pytest + +from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest.models import OcrPdfResponse + +from ..resources import get_test_resource_path + + +def test_live_ocr_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + response = client.ocr_pdf(uploaded) + + assert isinstance(response, OcrPdfResponse) + assert response.output_id + assert response.input_id == uploaded.id + + +def test_live_ocr_pdf_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + with pytest.raises(PdfRestApiError): + client.ocr_pdf( + uploaded, + extra_body={"pages": "last-1"}, + ) diff --git a/tests/test_ocr_pdf.py b/tests/test_ocr_pdf.py new file mode 100644 index 00000000..56b45bf0 --- /dev/null +++ b/tests/test_ocr_pdf.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import OcrPdfResponse, PdfRestFile, PdfRestFileID +from pdfrest.models._internal import OcrPdfPayload + +from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file + + +def test_ocr_payload_rejects_non_pdf() -> None: + file_id = str(PdfRestFileID.generate()) + text_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "notes.txt", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/plain", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + with pytest.raises(ValidationError, match="Must be a PDF file"): + OcrPdfPayload.model_validate({"files": [text_file]}) + + +def test_ocr_payload_invalid_page_range() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises( + ValidationError, match="The start page must be less than or equal to the end" + ): + OcrPdfPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) + + +def test_ocr_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + payload_dump = OcrPdfPayload.model_validate( + {"files": [input_file], "pages": ["1-3"], "output": "ocr"} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + 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 == "/pdf-with-ocr-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": str(input_file.id), + "outputId": output_id, + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=make_pdf_file(output_id, "ocr.pdf").model_dump( + mode="json", by_alias=True + ), + ) + 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.ocr_pdf( + input_file, + pages=["1-3"], + output="ocr", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, OcrPdfResponse) + assert response.output_id == output_id + assert response.output_url is None # not provided in mocked response + assert response.input_id == input_file.id + + +def test_ocr_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + payload_dump = OcrPdfPayload.model_validate({"files": [input_file]}).model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + 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 == "/pdf-with-ocr-text": + 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 == payload_dump | {"debug": True} + return httpx.Response( + 200, + json={ + "outputId": output_id, + "inputId": str(input_file.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=make_pdf_file(output_id, "custom-ocr.pdf").model_dump( + mode="json", by_alias=True + ), + ) + 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.ocr_pdf( + input_file, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.4, + ) + + assert isinstance(response, OcrPdfResponse) + assert response.output_id == output_id + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.4) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.4) + + +@pytest.mark.asyncio +async def test_async_ocr_pdf_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = OcrPdfPayload.model_validate({"files": [input_file]}).model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + 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 == "/pdf-with-ocr-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "outputId": output_id, + "inputId": str(input_file.id), + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=make_pdf_file(output_id, "async-ocr.pdf").model_dump( + mode="json", by_alias=True + ), + ) + 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.ocr_pdf(input_file) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, OcrPdfResponse) + assert response.output_id == output_id + assert response.input_id == input_file.id From f78d6f9ef17075e11b65b415913ef2d78b2a72b5 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 11:13:42 -0600 Subject: [PATCH 08/84] Refactor OCR PDF to utilize PdfRestFileBasedResponse Assisted-by: Codex --- src/pdfrest/client.py | 55 ++++++--------------------------- src/pdfrest/models/__init__.py | 2 -- src/pdfrest/models/public.py | 37 ---------------------- tests/live/test_live_ocr_pdf.py | 7 +++-- tests/test_ocr_pdf.py | 16 +++++----- 5 files changed, 22 insertions(+), 95 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 81af585d..8e554990 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -64,7 +64,6 @@ ConvertToMarkdownResponse, ExtractImagesResponse, ExtractTextResponse, - OcrPdfResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -2217,7 +2216,7 @@ def ocr_pdf( extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> OcrPdfResponse: + ) -> PdfRestFileBasedResponse: """Perform OCR on a PDF to extract searchable text.""" payload: dict[str, Any] = {"files": file} @@ -2226,32 +2225,15 @@ def ocr_pdf( if output is not None: payload["output"] = output - validated_payload = OcrPdfPayload.model_validate(payload) - request = self.prepare_request( - "POST", - "/pdf-with-ocr-text", - json_body=validated_payload.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return self._post_file_operation( + endpoint="/pdf-with-ocr-text", + payload=payload, + payload_model=OcrPdfPayload, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = self._send_request(request) - raw_response = PdfRestRawFileResponse.model_validate(raw_payload) - output_ids = raw_response.ids or [] - input_id = raw_response.input_id[0] if raw_response.input_id else "" - return OcrPdfResponse.model_validate( - { - "input_id": input_id, - "output_id": output_ids[0] if output_ids else None, - "output_url": raw_response.output_urls[0] - if raw_response.output_urls - else None, - "warning": raw_response.warning, - } - ) def translate_pdf_text( self, @@ -3007,7 +2989,7 @@ async def ocr_pdf( extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> OcrPdfResponse: + ) -> PdfRestFileBasedResponse: """Perform OCR on a PDF to extract searchable text.""" payload: dict[str, Any] = {"files": file} @@ -3016,32 +2998,15 @@ async def ocr_pdf( if output is not None: payload["output"] = output - validated_payload = OcrPdfPayload.model_validate(payload) - request = self.prepare_request( - "POST", - "/pdf-with-ocr-text", - json_body=validated_payload.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return await self._post_file_operation( + endpoint="/pdf-with-ocr-text", + payload=payload, + payload_model=OcrPdfPayload, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = await self._send_request(request) - raw_response = PdfRestRawFileResponse.model_validate(raw_payload) - output_ids = raw_response.ids or [] - input_id = raw_response.input_id[0] if raw_response.input_id else "" - return OcrPdfResponse.model_validate( - { - "input_id": input_id, - "output_id": output_ids[0] if output_ids else None, - "output_url": raw_response.output_urls[0] - if raw_response.output_urls - else None, - "warning": raw_response.warning, - } - ) async def translate_pdf_text( self, diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index a3a9fdb2..ce017acf 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -3,7 +3,6 @@ ConvertToMarkdownResponse, ExtractImagesResponse, ExtractTextResponse, - OcrPdfResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -19,7 +18,6 @@ "ConvertToMarkdownResponse", "ExtractImagesResponse", "ExtractTextResponse", - "OcrPdfResponse", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 15daf64e..46d343b1 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -24,7 +24,6 @@ "ConvertToMarkdownResponse", "ExtractImagesResponse", "ExtractTextResponse", - "OcrPdfResponse", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", @@ -505,42 +504,6 @@ class ConvertToMarkdownResponse(BaseModel): ] = None -class OcrPdfResponse(BaseModel): - """Response returned by the pdf-with-ocr-text tool.""" - - model_config = ConfigDict(extra="allow") - - input_id: Annotated[ - PdfRestFileID, - Field( - validation_alias=AliasChoices("input_id", "inputId"), - description="The id of the input file.", - ), - ] - output_url: Annotated[ - HttpUrl | None, - Field( - alias="outputUrl", - validation_alias=AliasChoices("output_url", "outputUrl"), - description="Download URL for file output.", - default=None, - ), - ] = None - output_id: Annotated[ - PdfRestFileID | None, - Field( - alias="outputId", - validation_alias=AliasChoices("output_id", "outputId"), - description="The id of the generated output file.", - default=None, - ), - ] = None - warning: Annotated[ - str | None, - Field(description="A warning that was generated during OCR."), - ] = None - - class PdfRestInfoResponse(BaseModel): """A response containing the output from the /info route.""" diff --git a/tests/live/test_live_ocr_pdf.py b/tests/live/test_live_ocr_pdf.py index 43eeb2be..065a7022 100644 --- a/tests/live/test_live_ocr_pdf.py +++ b/tests/live/test_live_ocr_pdf.py @@ -3,7 +3,7 @@ import pytest from pdfrest import PdfRestApiError, PdfRestClient -from pdfrest.models import OcrPdfResponse +from pdfrest.models import PdfRestFileBasedResponse from ..resources import get_test_resource_path @@ -20,8 +20,9 @@ def test_live_ocr_pdf_success( uploaded = client.files.create_from_paths([resource])[0] response = client.ocr_pdf(uploaded) - assert isinstance(response, OcrPdfResponse) - assert response.output_id + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + assert response.output_file.id assert response.input_id == uploaded.id diff --git a/tests/test_ocr_pdf.py b/tests/test_ocr_pdf.py index 56b45bf0..b5059e30 100644 --- a/tests/test_ocr_pdf.py +++ b/tests/test_ocr_pdf.py @@ -7,7 +7,7 @@ from pydantic import ValidationError from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import OcrPdfResponse, PdfRestFile, PdfRestFileID +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID from pdfrest.models._internal import OcrPdfPayload from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file @@ -80,9 +80,9 @@ def handler(request: httpx.Request) -> httpx.Response: ) assert seen == {"post": 1, "get": 1} - assert isinstance(response, OcrPdfResponse) - assert response.output_id == output_id - assert response.output_url is None # not provided in mocked response + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.id == output_id + assert response.output_file.name == "ocr.pdf" assert response.input_id == input_file.id @@ -134,8 +134,8 @@ def handler(request: httpx.Request) -> httpx.Response: timeout=0.4, ) - assert isinstance(response, OcrPdfResponse) - assert response.output_id == output_id + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.id == output_id timeout_value = captured_timeout["value"] assert timeout_value is not None if isinstance(timeout_value, dict): @@ -187,6 +187,6 @@ def handler(request: httpx.Request) -> httpx.Response: response = await client.ocr_pdf(input_file) assert seen == {"post": 1, "get": 1} - assert isinstance(response, OcrPdfResponse) - assert response.output_id == output_id + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.id == output_id assert response.input_id == input_file.id From 52728ac99c35918c1d3e6e980486c9c59cf7aca1 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 12:08:16 -0600 Subject: [PATCH 09/84] Remove and replace ExtractImagesResponse Assisted-by: Codex --- src/pdfrest/client.py | 78 ++++---------------------- src/pdfrest/models/__init__.py | 2 - src/pdfrest/models/public.py | 26 --------- tests/live/test_live_extract_images.py | 4 +- tests/test_extract_images.py | 8 +-- 5 files changed, 16 insertions(+), 102 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 8e554990..e00be917 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -62,7 +62,6 @@ from .models import ( PdfRestDeletionResponse, ConvertToMarkdownResponse, - ExtractImagesResponse, ExtractTextResponse, PdfRestErrorResponse, PdfRestFile, @@ -2290,7 +2289,7 @@ def extract_images( extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> ExtractImagesResponse: + ) -> PdfRestFileBasedResponse: """Extract embedded images from a PDF.""" payload: dict[str, Any] = {"files": file} @@ -2299,40 +2298,15 @@ def extract_images( if output is not None: payload["output"] = output - validated_payload = ExtractImagesPayload.model_validate(payload) - request = self.prepare_request( - "POST", - "/extracted-images", - json_body=validated_payload.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return self._post_file_operation( + endpoint="/extracted-images", + payload=payload, + payload_model=ExtractImagesPayload, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = self._send_request(request) - raw_response = PdfRestRawFileResponse.model_validate(raw_payload) - output_ids = raw_response.ids or [] - output_files = [ - self.fetch_file_info( - str(file_id), - extra_query=extra_query, - extra_headers=extra_headers, - timeout=timeout, - ) - for file_id in output_ids - ] - input_id = raw_response.input_id[0] if raw_response.input_id else "" - return ExtractImagesResponse.model_validate( - { - "input_id": input_id, - "output_files": [ - file.model_dump(mode="json", by_alias=True) for file in output_files - ], - "warning": raw_response.warning, - } - ) def extract_text( self, @@ -3063,7 +3037,7 @@ async def extract_images( extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> ExtractImagesResponse: + ) -> PdfRestFileBasedResponse: """Extract embedded images from a PDF.""" payload: dict[str, Any] = {"files": file} @@ -3072,47 +3046,15 @@ async def extract_images( if output is not None: payload["output"] = output - validated_payload = ExtractImagesPayload.model_validate(payload) - request = self.prepare_request( - "POST", - "/extracted-images", - json_body=validated_payload.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return await self._post_file_operation( + endpoint="/extracted-images", + payload=payload, + payload_model=ExtractImagesPayload, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = await self._send_request(request) - raw_response = PdfRestRawFileResponse.model_validate(raw_payload) - output_ids = raw_response.ids or [] - semaphore = asyncio.Semaphore(DEFAULT_FILE_INFO_CONCURRENCY) - - async def fetch(file_id: str) -> PdfRestFile: - async with semaphore: - return await self.fetch_file_info( - file_id, - extra_query=extra_query, - extra_headers=extra_headers, - timeout=timeout, - ) - - output_files: list[PdfRestFile] = [] - if output_ids: - output_files = list( - await asyncio.gather(*(fetch(fid) for fid in output_ids)) - ) - input_id = raw_response.input_id[0] if raw_response.input_id else "" - return ExtractImagesResponse.model_validate( - { - "input_id": input_id, - "output_files": [ - file.model_dump(mode="json", by_alias=True) for file in output_files - ], - "warning": raw_response.warning, - } - ) async def extract_text( self, diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index ce017acf..6ab74f89 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -1,7 +1,6 @@ from .public import ( PdfRestDeletionResponse, ConvertToMarkdownResponse, - ExtractImagesResponse, ExtractTextResponse, PdfRestErrorResponse, PdfRestFile, @@ -16,7 +15,6 @@ __all__ = [ "PdfRestDeletionResponse", "ConvertToMarkdownResponse", - "ExtractImagesResponse", "ExtractTextResponse", "PdfRestErrorResponse", "PdfRestFile", diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 46d343b1..8e25bfb0 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -22,7 +22,6 @@ __all__ = ( "PdfRestDeletionResponse", "ConvertToMarkdownResponse", - "ExtractImagesResponse", "ExtractTextResponse", "PdfRestErrorResponse", "PdfRestFile", @@ -393,31 +392,6 @@ class TranslatePdfTextResponse(BaseModel): ] = None -class ExtractImagesResponse(BaseModel): - """Response returned by the extracted-images tool.""" - - model_config = ConfigDict(extra="allow") - - input_id: Annotated[ - PdfRestFileID, - Field( - validation_alias=AliasChoices("input_id", "inputId"), - description="The id of the input file.", - ), - ] - output_files: Annotated[ - list[PdfRestFile], - Field( - description="The list of extracted image files.", - validation_alias=AliasChoices("output_files", "outputFiles"), - ), - ] - warning: Annotated[ - str | None, - Field(description="A warning that was generated during extraction."), - ] = None - - class ExtractTextResponse(BaseModel): """Response returned by the extracted-text tool.""" diff --git a/tests/live/test_live_extract_images.py b/tests/live/test_live_extract_images.py index b89df400..7d8abd39 100644 --- a/tests/live/test_live_extract_images.py +++ b/tests/live/test_live_extract_images.py @@ -3,7 +3,7 @@ import pytest from pdfrest import PdfRestApiError, PdfRestClient -from pdfrest.models import ExtractImagesResponse +from pdfrest.models import PdfRestFileBasedResponse from ..resources import get_test_resource_path @@ -20,7 +20,7 @@ def test_live_extract_images_success( uploaded = client.files.create_from_paths([resource])[0] response = client.extract_images(uploaded) - assert isinstance(response, ExtractImagesResponse) + assert isinstance(response, PdfRestFileBasedResponse) assert response.output_files assert response.input_id == uploaded.id diff --git a/tests/test_extract_images.py b/tests/test_extract_images.py index 2ef96842..5dea441b 100644 --- a/tests/test_extract_images.py +++ b/tests/test_extract_images.py @@ -7,7 +7,7 @@ from pydantic import ValidationError from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import ExtractImagesResponse, PdfRestFile, PdfRestFileID +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID from pdfrest.models._internal import ExtractImagesPayload from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file @@ -98,7 +98,7 @@ def handler(request: httpx.Request) -> httpx.Response: response = client.extract_images(input_file, pages=["1-3"], output="images") assert seen == {"post": 1, "get": 2} - assert isinstance(response, ExtractImagesResponse) + assert isinstance(response, PdfRestFileBasedResponse) assert len(response.output_files) == 2 assert response.input_id == input_file.id @@ -152,7 +152,7 @@ def handler(request: httpx.Request) -> httpx.Response: timeout=0.3, ) - assert isinstance(response, ExtractImagesResponse) + assert isinstance(response, PdfRestFileBasedResponse) assert len(response.output_files) == 1 timeout_value = captured_timeout["value"] assert timeout_value is not None @@ -206,6 +206,6 @@ def handler(request: httpx.Request) -> httpx.Response: response = await client.extract_images(input_file) assert seen == {"post": 1, "get": 1} - assert isinstance(response, ExtractImagesResponse) + assert isinstance(response, PdfRestFileBasedResponse) assert len(response.output_files) == 1 assert response.input_id == input_file.id From 4a351939aeb5e09a36c97762d3f135a91f5a4b9e Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 13:23:47 -0600 Subject: [PATCH 10/84] Split Translate PDF methods by output type Assisted-by: Codex --- src/pdfrest/client.py | 89 ++++++++++++++++++++-- src/pdfrest/models/_internal.py | 4 +- src/pdfrest/types/__init__.py | 2 - src/pdfrest/types/public.py | 2 - tests/live/test_live_translate_pdf_text.py | 24 +++++- tests/test_translate_pdf_text.py | 19 +++-- 6 files changed, 117 insertions(+), 23 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index e00be917..d0decbfd 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -114,7 +114,6 @@ SummaryOutputFormat, SummaryOutputType, TranslateOutputFormat, - TranslateOutputType, ) DEFAULT_BASE_URL = "https://api.pdfrest.com" @@ -2242,20 +2241,19 @@ def translate_pdf_text( source_language: str | None = None, pages: PdfPageSelection | None = None, output_format: TranslateOutputFormat = "markdown", - output_type: TranslateOutputType = "json", output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> TranslatePdfTextResponse: - """Translate the textual content of a PDF, Markdown, or text document.""" + """Translate the textual content of a PDF, Markdown, or text document (JSON).""" payload: dict[str, Any] = { "files": file, "target_language": target_language, "output_format": output_format, - "output_type": output_type, + "output_type": "json", } if source_language is not None: payload["source_language"] = source_language @@ -2279,6 +2277,45 @@ def translate_pdf_text( raw_payload = self._send_request(request) return TranslatePdfTextResponse.model_validate(raw_payload) + def translate_pdf_text_to_file( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + target_language: str, + source_language: str | None = None, + pages: PdfPageSelection | None = None, + output_format: TranslateOutputFormat = "markdown", + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Translate textual content and receive a file-based response.""" + + payload: dict[str, Any] = { + "files": file, + "target_language": target_language, + "output_format": output_format, + "output_type": "file", + } + if source_language is not None: + payload["source_language"] = source_language + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/translated-pdf-text", + payload=payload, + payload_model=TranslatePdfTextPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def extract_images( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -2990,20 +3027,19 @@ async def translate_pdf_text( source_language: str | None = None, pages: PdfPageSelection | None = None, output_format: TranslateOutputFormat = "markdown", - output_type: TranslateOutputType = "json", output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> TranslatePdfTextResponse: - """Translate the textual content of a PDF, Markdown, or text document.""" + """Translate the textual content of a PDF, Markdown, or text document (JSON).""" payload: dict[str, Any] = { "files": file, "target_language": target_language, "output_format": output_format, - "output_type": output_type, + "output_type": "json", } if source_language is not None: payload["source_language"] = source_language @@ -3027,6 +3063,45 @@ async def translate_pdf_text( raw_payload = await self._send_request(request) return TranslatePdfTextResponse.model_validate(raw_payload) + async def translate_pdf_text_to_file( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + target_language: str, + source_language: str | None = None, + pages: PdfPageSelection | None = None, + output_format: TranslateOutputFormat = "markdown", + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Translate textual content and receive a file-based response.""" + + payload: dict[str, Any] = { + "files": file, + "target_language": target_language, + "output_format": output_format, + "output_type": "file", + } + if source_language is not None: + payload["source_language"] = source_language + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/translated-pdf-text", + payload=payload, + payload_model=TranslatePdfTextPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def extract_images( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 47ebde0d..41a0b8a1 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -28,7 +28,6 @@ SummaryOutputFormat, SummaryOutputType, TranslateOutputFormat, - TranslateOutputType, ) from . import PdfRestFile from .public import PdfRestFileID @@ -451,7 +450,8 @@ class TranslatePdfTextPayload(BaseModel): Field(serialization_alias="output_format", default="markdown"), ] = "markdown" output_type: Annotated[ - TranslateOutputType, Field(serialization_alias="output_type", default="json") + Literal["json", "file"], + Field(serialization_alias="output_type", default="json"), ] = "json" output: Annotated[ str | None, diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 87cb53b8..adf09638 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -15,7 +15,6 @@ SummaryOutputFormat, SummaryOutputType, TranslateOutputFormat, - TranslateOutputType, ) __all__ = [ @@ -33,5 +32,4 @@ "SummaryOutputFormat", "SummaryOutputType", "TranslateOutputFormat", - "TranslateOutputType", ] diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 1c692c5e..915968cf 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -27,7 +27,6 @@ "SummaryOutputFormat", "SummaryOutputType", "TranslateOutputFormat", - "TranslateOutputType", ) PdfInfoQuery = Literal[ @@ -121,4 +120,3 @@ class PdfMergeSource(TypedDict, total=False): SummaryOutputType = Literal["json", "file"] TranslateOutputFormat = Literal["plaintext", "markdown"] -TranslateOutputType = Literal["json", "file"] diff --git a/tests/live/test_live_translate_pdf_text.py b/tests/live/test_live_translate_pdf_text.py index da35d638..fdb1e2ae 100644 --- a/tests/live/test_live_translate_pdf_text.py +++ b/tests/live/test_live_translate_pdf_text.py @@ -3,7 +3,7 @@ import pytest from pdfrest import PdfRestApiError, PdfRestClient -from pdfrest.models import TranslatePdfTextResponse +from pdfrest.models import PdfRestFileBasedResponse, TranslatePdfTextResponse from ..resources import get_test_resource_path @@ -21,7 +21,6 @@ def test_live_translate_pdf_text_success( response = client.translate_pdf_text( uploaded, target_language="fr", - output_type="json", output_format="plaintext", ) @@ -46,3 +45,24 @@ def test_live_translate_pdf_text_invalid_output_format( target_language="es", extra_body={"output_format": "invalid-format"}, ) + + +def test_live_translate_pdf_text_file_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + response = client.translate_pdf_text_to_file( + uploaded, + target_language="fr", + output_format="plaintext", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + assert response.input_id == uploaded.id diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py index 5f2fd1b3..8b45c88c 100644 --- a/tests/test_translate_pdf_text.py +++ b/tests/test_translate_pdf_text.py @@ -7,7 +7,12 @@ from pydantic import ValidationError from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import PdfRestFile, PdfRestFileID, TranslatePdfTextResponse +from pdfrest.models import ( + PdfRestFile, + PdfRestFileBasedResponse, + PdfRestFileID, + TranslatePdfTextResponse, +) from pdfrest.models._internal import TranslatePdfTextPayload from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file @@ -95,7 +100,6 @@ def handler(request: httpx.Request) -> httpx.Response: source_language="en", pages=["1-2"], output_format="plaintext", - output_type="json", output="translation", ) @@ -145,19 +149,17 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: - response = client.translate_pdf_text( + response = client.translate_pdf_text_to_file( input_file, target_language="es", - output_type="file", extra_query={"trace": "true"}, extra_headers={"X-Debug": "sync"}, extra_body={"debug": True}, timeout=0.3, ) - assert isinstance(response, TranslatePdfTextResponse) - assert response.output_id == output_id - assert response.output_url + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.id == output_id timeout_value = captured_timeout["value"] assert timeout_value is not None if isinstance(timeout_value, dict): @@ -199,7 +201,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.translate_pdf_text( - input_file, target_language="de", output_type="json" + input_file, + target_language="de", ) assert seen == {"post": 1} From 3104a63bd79eec3cbac14add2623fc31d4125716 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 15:06:00 -0600 Subject: [PATCH 11/84] client.py: Ruff format imports --- 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 d0decbfd..90666b86 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -87,8 +87,8 @@ PdfCompressPayload, OcrPdfPayload, PdfFlattenFormsPayload, - PdfLinearizePayload, PdfInfoPayload, + PdfLinearizePayload, PdfMergePayload, PdfRedactionApplyPayload, PdfRedactionPreviewPayload, From 9c3f650a3bf6bcd03eef8b29e349f9cf7a8342cb Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 16:13:10 -0600 Subject: [PATCH 12/84] Add Convert to Excel Assisted-by: Codex --- src/pdfrest/client.py | 53 ++++++ src/pdfrest/models/_internal.py | 24 +++ tests/test_convert_to_excel.py | 275 ++++++++++++++++++++++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 tests/test_convert_to_excel.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 90666b86..6c3c8eac 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -94,6 +94,7 @@ PdfRedactionPreviewPayload, PdfRestRawFileResponse, PdfSplitPayload, + PdfToExcelPayload, PdfToPdfxPayload, PdfToWordPayload, PngPdfRestPayload, @@ -2495,6 +2496,32 @@ def merge_pdfs( timeout=timeout, ) + def convert_to_excel( + 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 an Excel spreadsheet.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/excel", + payload=payload, + payload_model=PdfToExcelPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_word( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -3323,6 +3350,32 @@ async def merge_pdfs( timeout=timeout, ) + async def convert_to_excel( + 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 an Excel spreadsheet.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/excel", + payload=payload, + payload_model=PdfToExcelPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def convert_to_word( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 41a0b8a1..3a18662e 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -763,6 +763,30 @@ class PdfToWordPayload(BaseModel): ] = None +class PdfToExcelPayload(BaseModel): + """Adapt caller options into a pdfRest-ready Excel 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 PdfToPdfxPayload(BaseModel): """Adapt caller options into a pdfRest-ready PDF/X request payload.""" diff --git a/tests/test_convert_to_excel.py b/tests/test_convert_to_excel.py new file mode 100644 index 00000000..42346aac --- /dev/null +++ b/tests/test_convert_to_excel.py @@ -0,0 +1,275 @@ +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 PdfToExcelPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_convert_to_excel_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 = PdfToExcelPayload.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 == "/excel": + 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.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + ) + 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_excel(input_file, output="report") + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + output_file = response.output_file + assert output_file.name == "report.xlsx" + assert ( + output_file.type + == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + assert response.warning is None + assert str(response.input_id) == str(input_file.id) + + +def test_convert_to_excel_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + 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 == "/excel": + 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["debug"] is True + assert payload["id"] == str(input_file.id) + assert payload["output"] == "custom" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + ) + 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_excel( + input_file, + output="custom", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.4, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "custom.xlsx" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.4) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.4) + + +@pytest.mark.asyncio +async def test_async_convert_to_excel_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 = PdfToExcelPayload.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 == "/excel": + 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.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + ) + 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_excel(input_file) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async.xlsx" + assert ( + response.output_file.type + == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + assert str(response.input_id) == str(input_file.id) + + +@pytest.mark.asyncio +async def test_async_convert_to_excel_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + 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 == "/excel": + 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["debug"] == "yes" + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + ) + 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_excel( + input_file, + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": "yes"}, + timeout=0.55, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-custom.xlsx" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.55) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.55) + + +def test_convert_to_excel_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_excel(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_excel([pdf_file, make_pdf_file(PdfRestFileID.generate())]) From e615b1cf81d1fe7bcae12ce32b0695837ce2bc88 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 16:18:59 -0600 Subject: [PATCH 13/84] Add conversion to PowerPoint Assisted-by: Codex --- src/pdfrest/client.py | 53 ++++++ src/pdfrest/models/_internal.py | 24 +++ tests/test_convert_to_powerpoint.py | 277 ++++++++++++++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 tests/test_convert_to_powerpoint.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 6c3c8eac..24a1a232 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -96,6 +96,7 @@ PdfSplitPayload, PdfToExcelPayload, PdfToPdfxPayload, + PdfToPowerpointPayload, PdfToWordPayload, PngPdfRestPayload, SummarizePdfTextPayload, @@ -2522,6 +2523,32 @@ def convert_to_excel( timeout=timeout, ) + def convert_to_powerpoint( + 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 PowerPoint presentation.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/powerpoint", + payload=payload, + payload_model=PdfToPowerpointPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_word( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -3376,6 +3403,32 @@ async def convert_to_excel( timeout=timeout, ) + async def convert_to_powerpoint( + 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 PowerPoint presentation.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/powerpoint", + payload=payload, + payload_model=PdfToPowerpointPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def convert_to_word( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 3a18662e..89b95fc9 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -787,6 +787,30 @@ class PdfToExcelPayload(BaseModel): ] = None +class PdfToPowerpointPayload(BaseModel): + """Adapt caller options into a pdfRest-ready PowerPoint 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 PdfToPdfxPayload(BaseModel): """Adapt caller options into a pdfRest-ready PDF/X request payload.""" diff --git a/tests/test_convert_to_powerpoint.py b/tests/test_convert_to_powerpoint.py new file mode 100644 index 00000000..a8c1daa0 --- /dev/null +++ b/tests/test_convert_to_powerpoint.py @@ -0,0 +1,277 @@ +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 PdfToPowerpointPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_convert_to_powerpoint_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 = PdfToPowerpointPayload.model_validate( + {"files": [input_file], "output": "slides"} + ).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 == "/powerpoint": + 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, + "slides.pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ), + ) + 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_powerpoint(input_file, output="slides") + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + output_file = response.output_file + assert output_file.name == "slides.pptx" + assert ( + output_file.type + == "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ) + assert response.warning is None + assert str(response.input_id) == str(input_file.id) + + +def test_convert_to_powerpoint_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + 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 == "/powerpoint": + 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["debug"] is True + assert payload["id"] == str(input_file.id) + assert payload["output"] == "custom" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ), + ) + 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_powerpoint( + input_file, + output="custom", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.4, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "custom.pptx" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.4) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.4) + + +@pytest.mark.asyncio +async def test_async_convert_to_powerpoint_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 = PdfToPowerpointPayload.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 == "/powerpoint": + 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.pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ), + ) + 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_powerpoint(input_file) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async.pptx" + assert ( + response.output_file.type + == "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ) + assert str(response.input_id) == str(input_file.id) + + +@pytest.mark.asyncio +async def test_async_convert_to_powerpoint_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + 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 == "/powerpoint": + 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["debug"] == "yes" + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ), + ) + 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_powerpoint( + input_file, + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": "yes"}, + timeout=0.55, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-custom.pptx" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.55) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.55) + + +def test_convert_to_powerpoint_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_powerpoint(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_powerpoint( + [pdf_file, make_pdf_file(PdfRestFileID.generate())] + ) From e9c51298cb5b75602e1fd5e932f2c5a5831dc206 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 16:24:18 -0600 Subject: [PATCH 14/84] Add missing live Excel test Assisted-by: Codex --- tests/live/test_live_convert_to_excel.py | 114 +++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/live/test_live_convert_to_excel.py diff --git a/tests/live/test_live_convert_to_excel.py b/tests/live/test_live_convert_to_excel.py new file mode 100644 index 00000000..26068b28 --- /dev/null +++ b/tests/live/test_live_convert_to_excel.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_excel( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("live-excel", id="custom-output"), + ], +) +def test_live_convert_to_excel_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_excel: PdfRestFile, + output_name: str | None, +) -> None: + kwargs: dict[str, str] = {} + 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.convert_to_excel(uploaded_pdf_for_excel, **kwargs) + + assert response.output_files + output_file = response.output_file + assert ( + output_file.type + == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + assert str(response.input_id) == str(uploaded_pdf_for_excel.id) + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".xlsx") + + +@pytest.mark.asyncio +async def test_live_async_convert_to_excel_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_excel: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_to_excel(uploaded_pdf_for_excel, output="async") + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async") + assert ( + output_file.type + == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + assert str(response.input_id) == str(uploaded_pdf_for_excel.id) + + +def test_live_convert_to_excel_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_excel: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError), + ): + client.convert_to_excel( + uploaded_pdf_for_excel, + extra_body={"id": "00000000-0000-0000-0000-000000000000"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_to_excel_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_excel: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError): + await client.convert_to_excel( + uploaded_pdf_for_excel, + extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, + ) From 33b8ee2e0f466d807fef687763d3636af9403dfd Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 16:24:27 -0600 Subject: [PATCH 15/84] Add missing live PowerPoint test Assisted-by: Codex --- tests/live/test_live_convert_to_powerpoint.py | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/live/test_live_convert_to_powerpoint.py diff --git a/tests/live/test_live_convert_to_powerpoint.py b/tests/live/test_live_convert_to_powerpoint.py new file mode 100644 index 00000000..f46da580 --- /dev/null +++ b/tests/live/test_live_convert_to_powerpoint.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_powerpoint( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("live-powerpoint", id="custom-output"), + ], +) +def test_live_convert_to_powerpoint_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_powerpoint: PdfRestFile, + output_name: str | None, +) -> None: + kwargs: dict[str, str] = {} + 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.convert_to_powerpoint(uploaded_pdf_for_powerpoint, **kwargs) + + assert response.output_files + output_file = response.output_file + assert ( + output_file.type + == "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ) + assert str(response.input_id) == str(uploaded_pdf_for_powerpoint.id) + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pptx") + + +@pytest.mark.asyncio +async def test_live_async_convert_to_powerpoint_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_powerpoint: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_to_powerpoint( + uploaded_pdf_for_powerpoint, output="async" + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async") + assert ( + output_file.type + == "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ) + assert str(response.input_id) == str(uploaded_pdf_for_powerpoint.id) + + +def test_live_convert_to_powerpoint_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_powerpoint: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError), + ): + client.convert_to_powerpoint( + uploaded_pdf_for_powerpoint, + extra_body={"id": "00000000-0000-0000-0000-000000000000"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_to_powerpoint_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_powerpoint: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError): + await client.convert_to_powerpoint( + uploaded_pdf_for_powerpoint, + extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, + ) From e814bf4c8f20f7da74d8fecfa0ebb2d7f79bdfa3 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 16:43:13 -0600 Subject: [PATCH 16/84] Add XFA to AcroForms Assisted-by: Codex --- src/pdfrest/client.py | 53 ++++ src/pdfrest/models/_internal.py | 24 ++ .../test_live_convert_xfa_to_acroforms.py | 110 +++++++ tests/test_convert_xfa_to_acroforms.py | 271 ++++++++++++++++++ 4 files changed, 458 insertions(+) create mode 100644 tests/live/test_live_convert_xfa_to_acroforms.py create mode 100644 tests/test_convert_xfa_to_acroforms.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 24a1a232..74484a2e 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -98,6 +98,7 @@ PdfToPdfxPayload, PdfToPowerpointPayload, PdfToWordPayload, + PdfXfaToAcroformsPayload, PngPdfRestPayload, SummarizePdfTextPayload, TiffPdfRestPayload, @@ -2549,6 +2550,32 @@ def convert_to_powerpoint( timeout=timeout, ) + def convert_xfa_to_acroforms( + 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 an XFA PDF to an AcroForm-enabled PDF.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-acroforms", + payload=payload, + payload_model=PdfXfaToAcroformsPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_word( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -3429,6 +3456,32 @@ async def convert_to_powerpoint( timeout=timeout, ) + async def convert_xfa_to_acroforms( + 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 an XFA PDF to an AcroForm-enabled PDF.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-acroforms", + payload=payload, + payload_model=PdfXfaToAcroformsPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def convert_to_word( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 89b95fc9..792b3639 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -918,6 +918,30 @@ def _validate_profile_dependency(self) -> PdfCompressPayload: return self +class PdfXfaToAcroformsPayload(BaseModel): + """Adapt caller options into a pdfRest-ready XFA-to-AcroForms 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 PdfLinearizePayload(BaseModel): """Adapt caller options into a pdfRest-ready linearize PDF request payload.""" diff --git a/tests/live/test_live_convert_xfa_to_acroforms.py b/tests/live/test_live_convert_xfa_to_acroforms.py new file mode 100644 index 00000000..428fccb2 --- /dev/null +++ b/tests/live/test_live_convert_xfa_to_acroforms.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_acroforms( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("live-acroforms", id="custom-output"), + ], +) +def test_live_convert_xfa_to_acroforms_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_acroforms: PdfRestFile, + output_name: str | None, +) -> None: + kwargs: dict[str, str] = {} + 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.convert_xfa_to_acroforms(uploaded_pdf_for_acroforms, **kwargs) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_acroforms.id) + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pdf") + + +def test_live_convert_xfa_to_acroforms_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_acroforms: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError), + ): + client.convert_xfa_to_acroforms( + uploaded_pdf_for_acroforms, + extra_body={"id": "00000000-0000-0000-0000-000000000000"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_xfa_to_acroforms_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_acroforms: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_xfa_to_acroforms( + uploaded_pdf_for_acroforms, output="async" + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_acroforms.id) + + +@pytest.mark.asyncio +async def test_live_async_convert_xfa_to_acroforms_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_acroforms: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError): + await client.convert_xfa_to_acroforms( + uploaded_pdf_for_acroforms, + extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, + ) diff --git a/tests/test_convert_xfa_to_acroforms.py b/tests/test_convert_xfa_to_acroforms.py new file mode 100644 index 00000000..6080d22f --- /dev/null +++ b/tests/test_convert_xfa_to_acroforms.py @@ -0,0 +1,271 @@ +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 PdfXfaToAcroformsPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_convert_xfa_to_acroforms_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 = PdfXfaToAcroformsPayload.model_validate( + {"files": [input_file], "output": "acro"} + ).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 == "/pdf-with-acroforms": + 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, + "acro.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_xfa_to_acroforms(input_file, output="acro") + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + output_file = response.output_file + assert output_file.name == "acro.pdf" + assert output_file.type == "application/pdf" + assert response.warning is None + assert str(response.input_id) == str(input_file.id) + + +def test_convert_xfa_to_acroforms_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + 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 == "/pdf-with-acroforms": + 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["debug"] == "yes" + assert payload["id"] == str(input_file.id) + assert payload["output"] == "custom" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.convert_xfa_to_acroforms( + input_file, + output="custom", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": "yes"}, + timeout=0.31, + ) + + 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.31) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.31) + + +@pytest.mark.asyncio +async def test_async_convert_xfa_to_acroforms_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 = PdfXfaToAcroformsPayload.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 == "/pdf-with-acroforms": + 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_xfa_to_acroforms(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) + + +@pytest.mark.asyncio +async def test_async_convert_xfa_to_acroforms_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + 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 == "/pdf-with-acroforms": + 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["debug"] == "yes" + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.convert_xfa_to_acroforms( + input_file, + 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_convert_xfa_to_acroforms_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_xfa_to_acroforms(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_xfa_to_acroforms( + [pdf_file, make_pdf_file(PdfRestFileID.generate())] + ) From b98145609939a8e27f20bc6a28ddb9f488becf4e Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 16:53:48 -0600 Subject: [PATCH 17/84] Add Flatten Transparencies Assisted-by: Codex --- src/pdfrest/client.py | 57 ++++ src/pdfrest/models/_internal.py | 25 ++ .../live/test_live_flatten_transparencies.py | 113 +++++++ tests/test_flatten_transparencies.py | 297 ++++++++++++++++++ 4 files changed, 492 insertions(+) create mode 100644 tests/live/test_live_flatten_transparencies.py create mode 100644 tests/test_flatten_transparencies.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 74484a2e..6a598188 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -93,6 +93,7 @@ PdfRedactionApplyPayload, PdfRedactionPreviewPayload, PdfRestRawFileResponse, + PdfFlattenTransparenciesPayload, PdfSplitPayload, PdfToExcelPayload, PdfToPdfxPayload, @@ -2661,6 +2662,34 @@ def compress_pdf( timeout=timeout, ) + + def flatten_transparencies( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + output: str | None = None, + quality: Literal["low", "medium", "high"] = "medium", + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Flatten transparent objects in a PDF.""" + + payload: dict[str, Any] = {"files": file, "quality": quality} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/flattened-transparencies-pdf", + payload=payload, + payload_model=PdfFlattenTransparenciesPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def linearize_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -3567,6 +3596,34 @@ async def compress_pdf( timeout=timeout, ) + + async def flatten_transparencies( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + output: str | None = None, + quality: Literal["low", "medium", "high"] = "medium", + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously flatten transparent objects in a PDF.""" + + payload: dict[str, Any] = {"files": file, "quality": quality} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/flattened-transparencies-pdf", + payload=payload, + payload_model=PdfFlattenTransparenciesPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def linearize_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 792b3639..da78e122 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -966,6 +966,31 @@ class PdfLinearizePayload(BaseModel): ] = None +class PdfFlattenTransparenciesPayload(BaseModel): + """Adapt caller options into a pdfRest-ready flatten-transparencies 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 + quality: Literal["low", "medium", "high"] = "medium" + + class BmpPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "gray"]]): """Adapt caller options into a pdfRest-ready BMP request payload.""" diff --git a/tests/live/test_live_flatten_transparencies.py b/tests/live/test_live_flatten_transparencies.py new file mode 100644 index 00000000..f936fe6b --- /dev/null +++ b/tests/live/test_live_flatten_transparencies.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_transparencies( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.mark.parametrize( + "output_name,quality", + [ + pytest.param(None, "medium", id="default-output"), + pytest.param("flatten-transparency", "high", id="custom-output-high"), + ], +) +def test_live_flatten_transparencies_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_transparencies: PdfRestFile, + output_name: str | None, + quality: str, +) -> None: + kwargs: dict[str, str] = {"quality": quality} + 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.flatten_transparencies( + uploaded_pdf_for_transparencies, **kwargs + ) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_transparencies.id) + 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_flatten_transparencies_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_transparencies: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.flatten_transparencies( + uploaded_pdf_for_transparencies, output="async", quality="low" + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_transparencies.id) + + +def test_live_flatten_transparencies_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_transparencies: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError), + ): + client.flatten_transparencies( + uploaded_pdf_for_transparencies, + extra_body={"id": "00000000-0000-0000-0000-000000000000"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_flatten_transparencies_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_transparencies: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError): + await client.flatten_transparencies( + uploaded_pdf_for_transparencies, + extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, + ) diff --git a/tests/test_flatten_transparencies.py b/tests/test_flatten_transparencies.py new file mode 100644 index 00000000..0035fd70 --- /dev/null +++ b/tests/test_flatten_transparencies.py @@ -0,0 +1,297 @@ +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 PdfFlattenTransparenciesPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_flatten_transparencies_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 = PdfFlattenTransparenciesPayload.model_validate( + {"files": [input_file], "output": "flattened", "quality": "high"} + ).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-transparencies-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_transparencies( + input_file, output="flattened", quality="high" + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + output_file = response.output_file + assert output_file.name == "flattened.pdf" + assert output_file.type == "application/pdf" + assert response.warning is None + assert str(response.input_id) == str(input_file.id) + + +def test_flatten_transparencies_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + 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 == "/flattened-transparencies-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["debug"] == "yes" + assert payload["id"] == str(input_file.id) + assert payload["output"] == "custom" + assert payload["quality"] == "low" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.flatten_transparencies( + input_file, + output="custom", + quality="low", + 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_flatten_transparencies_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 = PdfFlattenTransparenciesPayload.model_validate( + {"files": [input_file], "quality": "medium"} + ).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-transparencies-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_transparencies(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) + + +@pytest.mark.asyncio +async def test_async_flatten_transparencies_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + 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 == "/flattened-transparencies-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["debug"] == "yes" + assert payload["id"] == str(input_file.id) + assert payload["quality"] == "high" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.flatten_transparencies( + input_file, + quality="high", + 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_flatten_transparencies_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_transparencies(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_transparencies( + [pdf_file, make_pdf_file(PdfRestFileID.generate())] + ) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, match="Input should be 'low', 'medium' or 'high'" + ), + ): + client.flatten_transparencies(pdf_file, quality="ultra") # type: ignore[arg-type] From 112003945ef40e8be963a162c3efbb59d01ba5c5 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 16:55:52 -0600 Subject: [PATCH 18/84] Add Rasterize PDF Assisted-by: Codex --- src/pdfrest/client.py | 53 ++++++ src/pdfrest/models/_internal.py | 24 +++ tests/live/test_live_rasterize_pdf.py | 110 +++++++++++ tests/test_rasterize_pdf.py | 265 ++++++++++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 tests/live/test_live_rasterize_pdf.py create mode 100644 tests/test_rasterize_pdf.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 6a598188..b213eb77 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -94,6 +94,7 @@ PdfRedactionPreviewPayload, PdfRestRawFileResponse, PdfFlattenTransparenciesPayload, + PdfRasterizePayload, PdfSplitPayload, PdfToExcelPayload, PdfToPdfxPayload, @@ -2716,6 +2717,32 @@ def linearize_pdf( timeout=timeout, ) + def rasterize_pdf( + 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: + """Rasterize a PDF into a flattened bitmap-based PDF.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/rasterized-pdf", + payload=payload, + payload_model=PdfRasterizePayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_pdfx( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -3650,6 +3677,32 @@ async def linearize_pdf( timeout=timeout, ) + async def rasterize_pdf( + 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 rasterize a PDF into a flattened bitmap-based PDF.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/rasterized-pdf", + payload=payload, + payload_model=PdfRasterizePayload, + 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 da78e122..22a4b8fc 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -966,6 +966,30 @@ class PdfLinearizePayload(BaseModel): ] = None +class PdfRasterizePayload(BaseModel): + """Adapt caller options into a pdfRest-ready rasterize PDF 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 PdfFlattenTransparenciesPayload(BaseModel): """Adapt caller options into a pdfRest-ready flatten-transparencies request payload.""" diff --git a/tests/live/test_live_rasterize_pdf.py b/tests/live/test_live_rasterize_pdf.py new file mode 100644 index 00000000..70f41c20 --- /dev/null +++ b/tests/live/test_live_rasterize_pdf.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_rasterize( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("rasterized-live", id="custom-output"), + ], +) +def test_live_rasterize_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_rasterize: PdfRestFile, + output_name: str | None, +) -> None: + kwargs: dict[str, str] = {} + 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.rasterize_pdf(uploaded_pdf_for_rasterize, **kwargs) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_rasterize.id) + 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_rasterize_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_rasterize: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.rasterize_pdf( + uploaded_pdf_for_rasterize, output="async" + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_rasterize.id) + + +def test_live_rasterize_pdf_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_rasterize: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError), + ): + client.rasterize_pdf( + uploaded_pdf_for_rasterize, + extra_body={"id": "00000000-0000-0000-0000-000000000000"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_rasterize_pdf_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_rasterize: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError): + await client.rasterize_pdf( + uploaded_pdf_for_rasterize, + extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, + ) diff --git a/tests/test_rasterize_pdf.py b/tests/test_rasterize_pdf.py new file mode 100644 index 00000000..707ab223 --- /dev/null +++ b/tests/test_rasterize_pdf.py @@ -0,0 +1,265 @@ +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 PdfRasterizePayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_rasterize_pdf_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 = PdfRasterizePayload.model_validate( + {"files": [input_file], "output": "rasterized"} + ).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 == "/rasterized-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, + "rasterized.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.rasterize_pdf(input_file, output="rasterized") + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + output_file = response.output_file + assert output_file.name == "rasterized.pdf" + assert output_file.type == "application/pdf" + assert response.warning is None + assert str(response.input_id) == str(input_file.id) + + +def test_rasterize_pdf_request_customization(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + 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 == "/rasterized-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["debug"] == "yes" + assert payload["id"] == str(input_file.id) + assert payload["output"] == "custom" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.rasterize_pdf( + input_file, + output="custom", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": "yes"}, + timeout=0.31, + ) + + 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.31) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.31) + + +@pytest.mark.asyncio +async def test_async_rasterize_pdf_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 = PdfRasterizePayload.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 == "/rasterized-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.rasterize_pdf(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) + + +@pytest.mark.asyncio +async def test_async_rasterize_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + 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 == "/rasterized-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["debug"] == "yes" + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.rasterize_pdf( + input_file, + 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_rasterize_pdf_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.rasterize_pdf(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.rasterize_pdf([pdf_file, make_pdf_file(PdfRestFileID.generate())]) From 971ec822b398d62a2d183f37a38b5582a0137e78 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 16:56:39 -0600 Subject: [PATCH 19/84] Fix problems reported by ruff Assisted-by: Codex --- src/pdfrest/client.py | 4 ++-- tests/live/test_live_flatten_transparencies.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index b213eb77..fc37a504 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -87,14 +87,14 @@ PdfCompressPayload, OcrPdfPayload, PdfFlattenFormsPayload, + PdfFlattenTransparenciesPayload, PdfInfoPayload, PdfLinearizePayload, PdfMergePayload, + PdfRasterizePayload, PdfRedactionApplyPayload, PdfRedactionPreviewPayload, PdfRestRawFileResponse, - PdfFlattenTransparenciesPayload, - PdfRasterizePayload, PdfSplitPayload, PdfToExcelPayload, PdfToPdfxPayload, diff --git a/tests/live/test_live_flatten_transparencies.py b/tests/live/test_live_flatten_transparencies.py index f936fe6b..f7a8bb49 100644 --- a/tests/live/test_live_flatten_transparencies.py +++ b/tests/live/test_live_flatten_transparencies.py @@ -22,7 +22,7 @@ def uploaded_pdf_for_transparencies( @pytest.mark.parametrize( - "output_name,quality", + ("output_name", "quality"), [ pytest.param(None, "medium", id="default-output"), pytest.param("flatten-transparency", "high", id="custom-output-high"), From bde50f10fffdaced6a25c26bf8bf0b2b92692d90 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 17:08:02 -0600 Subject: [PATCH 20/84] Add Flatten Annotations Assisted-by: Codex --- src/pdfrest/client.py | 53 ++++ src/pdfrest/models/_internal.py | 24 ++ tests/live/test_live_flatten_annotations.py | 110 ++++++++ tests/test_flatten_annotations.py | 281 ++++++++++++++++++++ 4 files changed, 468 insertions(+) create mode 100644 tests/live/test_live_flatten_annotations.py create mode 100644 tests/test_flatten_annotations.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index fc37a504..647e38d5 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -86,6 +86,7 @@ JpegPdfRestPayload, PdfCompressPayload, OcrPdfPayload, + PdfFlattenAnnotationsPayload, PdfFlattenFormsPayload, PdfFlattenTransparenciesPayload, PdfInfoPayload, @@ -2717,6 +2718,32 @@ def linearize_pdf( timeout=timeout, ) + def flatten_annotations( + 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 annotations into the PDF content.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/flattened-annotations-pdf", + payload=payload, + payload_model=PdfFlattenAnnotationsPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def rasterize_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -3677,6 +3704,32 @@ async def linearize_pdf( timeout=timeout, ) + async def flatten_annotations( + 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 annotations into the PDF content.""" + + payload: dict[str, Any] = {"files": file} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/flattened-annotations-pdf", + payload=payload, + payload_model=PdfFlattenAnnotationsPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def rasterize_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 22a4b8fc..91dacfc3 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1015,6 +1015,30 @@ class PdfFlattenTransparenciesPayload(BaseModel): quality: Literal["low", "medium", "high"] = "medium" +class PdfFlattenAnnotationsPayload(BaseModel): + """Adapt caller options into a pdfRest-ready flatten-annotations 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/live/test_live_flatten_annotations.py b/tests/live/test_live_flatten_annotations.py new file mode 100644 index 00000000..9a669fe2 --- /dev/null +++ b/tests/live/test_live_flatten_annotations.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_annotations( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("flatten-annotations", id="custom-output"), + ], +) +def test_live_flatten_annotations_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_annotations: PdfRestFile, + output_name: str | None, +) -> None: + kwargs: dict[str, str] = {} + 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.flatten_annotations(uploaded_pdf_for_annotations, **kwargs) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_annotations.id) + 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_flatten_annotations_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_annotations: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.flatten_annotations( + uploaded_pdf_for_annotations, output="async" + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_annotations.id) + + +def test_live_flatten_annotations_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_annotations: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError), + ): + client.flatten_annotations( + uploaded_pdf_for_annotations, + extra_body={"id": "00000000-0000-0000-0000-000000000000"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_flatten_annotations_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_annotations: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError): + await client.flatten_annotations( + uploaded_pdf_for_annotations, + extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, + ) diff --git a/tests/test_flatten_annotations.py b/tests/test_flatten_annotations.py new file mode 100644 index 00000000..d5407a3d --- /dev/null +++ b/tests/test_flatten_annotations.py @@ -0,0 +1,281 @@ +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 PdfFlattenAnnotationsPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def test_flatten_annotations_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 = PdfFlattenAnnotationsPayload.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-annotations-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_annotations(input_file, output="flattened") + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + output_file = response.output_file + assert output_file.name == "flattened.pdf" + assert output_file.type == "application/pdf" + assert response.warning is None + assert str(response.input_id) == str(input_file.id) + + +def test_flatten_annotations_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + 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 == "/flattened-annotations-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["debug"] == "yes" + assert payload["id"] == str(input_file.id) + assert payload["output"] == "custom" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.flatten_annotations( + input_file, + 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_flatten_annotations_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 = PdfFlattenAnnotationsPayload.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-annotations-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_annotations(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) + + +@pytest.mark.asyncio +async def test_async_flatten_annotations_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + 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 == "/flattened-annotations-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["debug"] == "yes" + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={ + "inputId": [input_file.id], + "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.flatten_annotations( + input_file, + 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_flatten_annotations_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_annotations(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_annotations([pdf_file, make_pdf_file(PdfRestFileID.generate())]) From 8c116dcbe4d14ecdadf3f4cf874ba40be9f35e3f Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 17:17:24 -0600 Subject: [PATCH 21/84] Remove erroneous `output_format` parameter from Markdown live test --- tests/live/test_live_convert_to_markdown.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/live/test_live_convert_to_markdown.py b/tests/live/test_live_convert_to_markdown.py index be0c1aef..f86215af 100644 --- a/tests/live/test_live_convert_to_markdown.py +++ b/tests/live/test_live_convert_to_markdown.py @@ -21,7 +21,6 @@ def test_live_convert_to_markdown_success( response = client.convert_to_markdown( uploaded, output_type="json", - output_format="markdown", ) assert isinstance(response, ConvertToMarkdownResponse) From e6de765a3c9992061c696d4f6c83a21583daf7d6 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 17:26:26 -0600 Subject: [PATCH 22/84] Add missing `page_break_comments` parameter to Markdown conversion Assisted-by: Codex --- src/pdfrest/client.py | 6 ++++++ src/pdfrest/models/_internal.py | 4 ++++ tests/test_convert_to_markdown.py | 24 +++++++++++++++++++++--- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 647e38d5..627e72e7 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -2177,6 +2177,7 @@ def convert_to_markdown( pages: PdfPageSelection | None = None, output_type: SummaryOutputType = "json", output_format: SummaryOutputFormat = "markdown", + page_break_comments: Literal["on", "off"] | None = None, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -2192,6 +2193,8 @@ def convert_to_markdown( } if pages is not None: payload["pages"] = pages + if page_break_comments is not None: + payload["page_break_comments"] = page_break_comments if output is not None: payload["output"] = output @@ -3121,6 +3124,7 @@ async def convert_to_markdown( pages: PdfPageSelection | None = None, output_type: SummaryOutputType = "json", output_format: SummaryOutputFormat = "markdown", + page_break_comments: Literal["on", "off"] | None = None, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -3136,6 +3140,8 @@ async def convert_to_markdown( } if pages is not None: payload["pages"] = pages + if page_break_comments is not None: + payload["page_break_comments"] = page_break_comments if output is not None: payload["output"] = output diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 91dacfc3..4e799c6e 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -401,6 +401,10 @@ class ConvertToMarkdownPayload(BaseModel): SummaryOutputFormat, Field(serialization_alias="output_format", default="markdown"), ] = "markdown" + page_break_comments: Annotated[ + Literal["on", "off"] | None, + Field(serialization_alias="page_break_comments", default=None), + ] = None output: Annotated[ str | None, Field(serialization_alias="output", min_length=1, default=None), diff --git a/tests/test_convert_to_markdown.py b/tests/test_convert_to_markdown.py index fd7c3958..88c9135a 100644 --- a/tests/test_convert_to_markdown.py +++ b/tests/test_convert_to_markdown.py @@ -40,6 +40,14 @@ def test_convert_to_markdown_payload_invalid_page_range() -> None: ) +def test_convert_to_markdown_payload_invalid_page_break_comments() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises(ValidationError, match="Input should be 'on' or 'off'"): + ConvertToMarkdownPayload.model_validate( + {"files": [file_repr], "page_break_comments": "maybe"} + ) + + def test_convert_to_markdown_json_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) @@ -50,6 +58,7 @@ def test_convert_to_markdown_json_success(monkeypatch: pytest.MonkeyPatch) -> No "output": "md", "output_type": "json", "output_format": "markdown", + "page_break_comments": "on", } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) @@ -79,6 +88,7 @@ def handler(request: httpx.Request) -> httpx.Response: output="md", output_type="json", output_format="markdown", + page_break_comments="on", ) assert seen == {"post": 1} @@ -95,7 +105,12 @@ def test_convert_to_markdown_request_customization( monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) payload_dump = ConvertToMarkdownPayload.model_validate( - {"files": [input_file], "output_type": "file", "output_format": "markdown"} + { + "files": [input_file], + "output_type": "file", + "output_format": "markdown", + "page_break_comments": "off", + } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) output_id = str(PdfRestFileID.generate()) captured_timeout: dict[str, float | dict[str, float] | None] = {} @@ -129,6 +144,7 @@ def handler(request: httpx.Request) -> httpx.Response: extra_headers={"X-Debug": "sync"}, extra_body={"debug": True}, timeout=0.4, + page_break_comments="off", ) assert isinstance(response, ConvertToMarkdownResponse) @@ -151,7 +167,7 @@ async def test_async_convert_to_markdown_success( monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(2)) payload_dump = ConvertToMarkdownPayload.model_validate( - {"files": [input_file], "output_type": "json"} + {"files": [input_file], "output_type": "json", "page_break_comments": "off"} ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) seen: dict[str, int] = {"post": 0} @@ -174,7 +190,9 @@ 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.convert_to_markdown(input_file, output_type="json") + response = await client.convert_to_markdown( + input_file, output_type="json", page_break_comments="off" + ) assert seen == {"post": 1} assert isinstance(response, ConvertToMarkdownResponse) From 2d7d556d42ae7b1a24abaf2795f3c2eb6c0ff7d6 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 17:27:04 -0600 Subject: [PATCH 23/84] Test Extract Images live with PDF with images --- tests/live/test_live_extract_images.py | 4 ++-- tests/resources/duckhat.pdf | Bin 0 -> 88669 bytes 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 tests/resources/duckhat.pdf diff --git a/tests/live/test_live_extract_images.py b/tests/live/test_live_extract_images.py index 7d8abd39..faaff70d 100644 --- a/tests/live/test_live_extract_images.py +++ b/tests/live/test_live_extract_images.py @@ -12,7 +12,7 @@ def test_live_extract_images_success( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: - resource = get_test_resource_path("report.pdf") + resource = get_test_resource_path("duckhat.pdf") with PdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, @@ -29,7 +29,7 @@ def test_live_extract_images_invalid_pages( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: - resource = get_test_resource_path("report.pdf") + resource = get_test_resource_path("duckhat.pdf") with PdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, diff --git a/tests/resources/duckhat.pdf b/tests/resources/duckhat.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8dbaff230a95fb7edd930cd827ed4c3e5772613e GIT binary patch literal 88669 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 Q)icoBsG@>%vDx_l0J(x3$N&HU literal 0 HcmV?d00001 From ec3edca7e017fa4900df4bdc35b2667d252cddb8 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 17:39:36 -0600 Subject: [PATCH 24/84] Extract Text: Add missing body parameters Assisted-by: Codex --- src/pdfrest/client.py | 20 ++++++++++++++++++++ src/pdfrest/models/_internal.py | 5 +++++ tests/live/test_live_extract_text.py | 10 +++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 627e72e7..e13f7811 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -2359,6 +2359,11 @@ def extract_text( file: PdfRestFile | Sequence[PdfRestFile], *, pages: PdfPageSelection | None = None, + full_text: Literal["off", "by_page", "document"] = "document", + preserve_line_breaks: Literal["off", "on"] = "off", + word_style: Literal["off", "on"] = "off", + word_coordinates: Literal["off", "on"] = "off", + output_type: Literal["json", "file"] = "json", output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -2370,6 +2375,11 @@ def extract_text( payload: dict[str, Any] = {"files": file} if pages is not None: payload["pages"] = pages + payload["full_text"] = full_text + payload["preserve_line_breaks"] = preserve_line_breaks + payload["word_style"] = word_style + payload["word_coordinates"] = word_coordinates + payload["output_type"] = output_type if output is not None: payload["output"] = output @@ -3306,6 +3316,11 @@ async def extract_text( file: PdfRestFile | Sequence[PdfRestFile], *, pages: PdfPageSelection | None = None, + full_text: Literal["off", "by_page", "document"] = "document", + preserve_line_breaks: Literal["off", "on"] = "off", + word_style: Literal["off", "on"] = "off", + word_coordinates: Literal["off", "on"] = "off", + output_type: Literal["json", "file"] = "json", output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -3317,6 +3332,11 @@ async def extract_text( payload: dict[str, Any] = {"files": file} if pages is not None: payload["pages"] = pages + payload["full_text"] = full_text + payload["preserve_line_breaks"] = preserve_line_breaks + payload["word_style"] = word_style + payload["word_coordinates"] = word_coordinates + payload["output_type"] = output_type if output is not None: payload["output"] = output diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 4e799c6e..831ad7bd 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -362,6 +362,11 @@ class ExtractTextPayload(BaseModel): BeforeValidator(_int_to_string), PlainSerializer(_serialize_page_ranges), ] = None + full_text: Literal["off", "by_page", "document"] = "document" + preserve_line_breaks: Literal["off", "on"] = "off" + word_style: Literal["off", "on"] = "off" + word_coordinates: Literal["off", "on"] = "off" + output_type: Literal["json", "file"] = "json" output: Annotated[ str | None, Field(serialization_alias="output", min_length=1, default=None), diff --git a/tests/live/test_live_extract_text.py b/tests/live/test_live_extract_text.py index 18d98f71..b76590ff 100644 --- a/tests/live/test_live_extract_text.py +++ b/tests/live/test_live_extract_text.py @@ -18,7 +18,14 @@ def test_live_extract_text_success( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - response = client.extract_text(uploaded, output=None) + response = client.extract_text( + uploaded, + output_type="json", + full_text="document", + preserve_line_breaks="on", + word_style="off", + word_coordinates="off", + ) assert isinstance(response, ExtractTextResponse) assert response.text @@ -39,4 +46,5 @@ def test_live_extract_text_invalid_pages( client.extract_text( uploaded, extra_body={"pages": "last-1"}, + output_type="json", ) From 3e736da52d87ae0679c3c7929aeb2256fe42cacb Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 17:51:02 -0600 Subject: [PATCH 25/84] Translate PDF: Fix name of destination language parameter Assisted-by: Codex --- src/pdfrest/client.py | 28 +++++++--------------- src/pdfrest/models/_internal.py | 9 +++---- tests/live/test_live_translate_pdf_text.py | 6 ++--- tests/test_translate_pdf_text.py | 16 ++++++------- 4 files changed, 21 insertions(+), 38 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index e13f7811..408d567a 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -2246,8 +2246,7 @@ def translate_pdf_text( self, file: PdfRestFile | Sequence[PdfRestFile], *, - target_language: str, - source_language: str | None = None, + output_language: str, pages: PdfPageSelection | None = None, output_format: TranslateOutputFormat = "markdown", output: str | None = None, @@ -2260,12 +2259,10 @@ def translate_pdf_text( payload: dict[str, Any] = { "files": file, - "target_language": target_language, + "output_language": output_language, "output_format": output_format, "output_type": "json", } - if source_language is not None: - payload["source_language"] = source_language if pages is not None: payload["pages"] = pages if output is not None: @@ -2290,8 +2287,7 @@ def translate_pdf_text_to_file( self, file: PdfRestFile | Sequence[PdfRestFile], *, - target_language: str, - source_language: str | None = None, + output_language: str, pages: PdfPageSelection | None = None, output_format: TranslateOutputFormat = "markdown", output: str | None = None, @@ -2304,12 +2300,10 @@ def translate_pdf_text_to_file( payload: dict[str, Any] = { "files": file, - "target_language": target_language, + "output_language": output_language, "output_format": output_format, "output_type": "file", } - if source_language is not None: - payload["source_language"] = source_language if pages is not None: payload["pages"] = pages if output is not None: @@ -3203,8 +3197,7 @@ async def translate_pdf_text( self, file: PdfRestFile | Sequence[PdfRestFile], *, - target_language: str, - source_language: str | None = None, + output_language: str, pages: PdfPageSelection | None = None, output_format: TranslateOutputFormat = "markdown", output: str | None = None, @@ -3217,12 +3210,10 @@ async def translate_pdf_text( payload: dict[str, Any] = { "files": file, - "target_language": target_language, + "output_language": output_language, "output_format": output_format, "output_type": "json", } - if source_language is not None: - payload["source_language"] = source_language if pages is not None: payload["pages"] = pages if output is not None: @@ -3247,8 +3238,7 @@ async def translate_pdf_text_to_file( self, file: PdfRestFile | Sequence[PdfRestFile], *, - target_language: str, - source_language: str | None = None, + output_language: str, pages: PdfPageSelection | None = None, output_format: TranslateOutputFormat = "markdown", output: str | None = None, @@ -3261,12 +3251,10 @@ async def translate_pdf_text_to_file( payload: dict[str, Any] = { "files": file, - "target_language": target_language, + "output_language": output_language, "output_format": output_format, "output_type": "file", } - if source_language is not None: - payload["source_language"] = source_language if pages is not None: payload["pages"] = pages if output is not None: diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 831ad7bd..eba57bed 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -439,13 +439,10 @@ class TranslatePdfTextPayload(BaseModel): ), PlainSerializer(_serialize_as_first_file_id), ] - target_language: Annotated[ - str, Field(serialization_alias="target_language", min_length=1) + output_language: Annotated[ + str, + Field(serialization_alias="output_language", min_length=1), ] - source_language: Annotated[ - str | None, - Field(serialization_alias="source_language", min_length=1, default=None), - ] = None pages: Annotated[ list[AscendingPageRange] | None, Field(serialization_alias="pages", min_length=1, default=None), diff --git a/tests/live/test_live_translate_pdf_text.py b/tests/live/test_live_translate_pdf_text.py index fdb1e2ae..0ade6cd3 100644 --- a/tests/live/test_live_translate_pdf_text.py +++ b/tests/live/test_live_translate_pdf_text.py @@ -20,7 +20,7 @@ def test_live_translate_pdf_text_success( uploaded = client.files.create_from_paths([resource])[0] response = client.translate_pdf_text( uploaded, - target_language="fr", + output_language="fr", output_format="plaintext", ) @@ -42,7 +42,7 @@ def test_live_translate_pdf_text_invalid_output_format( with pytest.raises(PdfRestApiError, match="error"): client.translate_pdf_text( uploaded, - target_language="es", + output_language="es", extra_body={"output_format": "invalid-format"}, ) @@ -59,7 +59,7 @@ def test_live_translate_pdf_text_file_success( uploaded = client.files.create_from_paths([resource])[0] response = client.translate_pdf_text_to_file( uploaded, - target_language="fr", + output_language="fr", output_format="plaintext", ) diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py index 8b45c88c..1eab644d 100644 --- a/tests/test_translate_pdf_text.py +++ b/tests/test_translate_pdf_text.py @@ -50,7 +50,7 @@ def test_translate_payload_rejects_invalid_mime() -> None: ValidationError, match="Must be a PDF, Markdown, or plain text file" ): TranslatePdfTextPayload.model_validate( - {"files": [image_file], "target_language": "fr"} + {"files": [image_file], "output_language": "fr"} ) @@ -66,8 +66,7 @@ def test_translate_pdf_text_json_success(monkeypatch: pytest.MonkeyPatch) -> Non payload_dump = TranslatePdfTextPayload.model_validate( { "files": [input_file], - "target_language": "fr", - "source_language": "en", + "output_language": "fr", "pages": ["1-2"], "output_format": "plaintext", "output_type": "json", @@ -96,8 +95,7 @@ def handler(request: httpx.Request) -> httpx.Response: with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = client.translate_pdf_text( input_file, - target_language="fr", - source_language="en", + output_language="fr", pages=["1-2"], output_format="plaintext", output="translation", @@ -119,7 +117,7 @@ def test_translate_pdf_text_request_customization( payload_dump = TranslatePdfTextPayload.model_validate( { "files": [input_file], - "target_language": "es", + "output_language": "es", "output_type": "file", } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) @@ -151,7 +149,7 @@ def handler(request: httpx.Request) -> httpx.Response: with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = client.translate_pdf_text_to_file( input_file, - target_language="es", + output_language="es", extra_query={"trace": "true"}, extra_headers={"X-Debug": "sync"}, extra_body={"debug": True}, @@ -177,7 +175,7 @@ async def test_async_translate_pdf_text_success( monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(2)) payload_dump = TranslatePdfTextPayload.model_validate( - {"files": [input_file], "target_language": "de", "output_type": "json"} + {"files": [input_file], "output_language": "de", "output_type": "json"} ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) seen: dict[str, int] = {"post": 0} @@ -202,7 +200,7 @@ def handler(request: httpx.Request) -> httpx.Response: async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: response = await client.translate_pdf_text( input_file, - target_language="de", + output_language="de", ) assert seen == {"post": 1} From 541f2ed4dfb151e71c5a5b6cd96acfd5fe24fc3c Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 17:54:39 -0600 Subject: [PATCH 26/84] Translate PDF live test: Fix expected field name Assisted-by: Codex --- src/pdfrest/models/public.py | 2 +- tests/live/test_live_translate_pdf_text.py | 2 +- tests/test_translate_pdf_text.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 8e25bfb0..c34a354a 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -358,7 +358,7 @@ class TranslatePdfTextResponse(BaseModel): model_config = ConfigDict(extra="allow") - translation: Annotated[ + translated_text: Annotated[ str | None, Field( description="Inline translation content when output_type is json.", diff --git a/tests/live/test_live_translate_pdf_text.py b/tests/live/test_live_translate_pdf_text.py index 0ade6cd3..c254fd5e 100644 --- a/tests/live/test_live_translate_pdf_text.py +++ b/tests/live/test_live_translate_pdf_text.py @@ -25,7 +25,7 @@ def test_live_translate_pdf_text_success( ) assert isinstance(response, TranslatePdfTextResponse) - assert response.translation + assert response.translated_text assert response.input_id == uploaded.id diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py index 1eab644d..ce9c8078 100644 --- a/tests/test_translate_pdf_text.py +++ b/tests/test_translate_pdf_text.py @@ -103,7 +103,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen == {"post": 1} assert isinstance(response, TranslatePdfTextResponse) - assert response.translation == "Bonjour" + assert response.translated_text == "Bonjour" assert response.input_id == input_file.id assert response.output_id is None assert response.output_url is None @@ -205,5 +205,5 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen == {"post": 1} assert isinstance(response, TranslatePdfTextResponse) - assert response.translation == "Hallo" + assert response.translated_text == "Hallo" assert response.input_id == input_file.id From 2e8f3711b53fc2381e9c02f552fc951af7e57484 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 17:57:21 -0600 Subject: [PATCH 27/84] Extract Text live test: Fix incorrect return field Assisted-by: Codex --- src/pdfrest/models/public.py | 4 +++- tests/live/test_live_extract_text.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index c34a354a..882d1b1b 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -397,9 +397,11 @@ class ExtractTextResponse(BaseModel): model_config = ConfigDict(extra="allow") - text: Annotated[ + full_text: Annotated[ str | None, Field( + alias="fullText", + validation_alias=AliasChoices("full_text", "fullText"), description="Inline extracted text when output_type is json.", default=None, ), diff --git a/tests/live/test_live_extract_text.py b/tests/live/test_live_extract_text.py index b76590ff..bfbeb2cc 100644 --- a/tests/live/test_live_extract_text.py +++ b/tests/live/test_live_extract_text.py @@ -28,7 +28,7 @@ def test_live_extract_text_success( ) assert isinstance(response, ExtractTextResponse) - assert response.text + assert response.full_text assert response.input_id == uploaded.id From fc55159ab29219c8642620319c6cc8580b911ebb Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 18:01:29 -0600 Subject: [PATCH 28/84] Extract Text test: Remove lingering `.text` --- tests/test_extract_text.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_extract_text.py b/tests/test_extract_text.py index 048a636a..f8f01eaa 100644 --- a/tests/test_extract_text.py +++ b/tests/test_extract_text.py @@ -72,7 +72,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen == {"post": 1} assert isinstance(response, ExtractTextResponse) - assert response.text == "Example extracted text" + assert response.full_text == "Example extracted text" assert response.input_id == input_file.id assert response.output_id is None assert response.output_url is None @@ -164,5 +164,5 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen == {"post": 1} assert isinstance(response, ExtractTextResponse) - assert response.text == "Async text" + assert response.full_text == "Async text" assert response.input_id == input_file.id From 1bed8c0c7bb880ec322f49d8f2b400527c803c34 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 18:14:59 -0600 Subject: [PATCH 29/84] Convert to Markdown: Excise `output_format` completely Assisted-by: Codex --- src/pdfrest/client.py | 4 ---- src/pdfrest/models/_internal.py | 4 ---- tests/test_convert_to_markdown.py | 3 --- 3 files changed, 11 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 408d567a..fcdd894c 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -2176,7 +2176,6 @@ def convert_to_markdown( *, pages: PdfPageSelection | None = None, output_type: SummaryOutputType = "json", - output_format: SummaryOutputFormat = "markdown", page_break_comments: Literal["on", "off"] | None = None, output: str | None = None, extra_query: Query | None = None, @@ -2189,7 +2188,6 @@ def convert_to_markdown( payload: dict[str, Any] = { "files": file, "output_type": output_type, - "output_format": output_format, } if pages is not None: payload["pages"] = pages @@ -3127,7 +3125,6 @@ async def convert_to_markdown( *, pages: PdfPageSelection | None = None, output_type: SummaryOutputType = "json", - output_format: SummaryOutputFormat = "markdown", page_break_comments: Literal["on", "off"] | None = None, output: str | None = None, extra_query: Query | None = None, @@ -3140,7 +3137,6 @@ async def convert_to_markdown( payload: dict[str, Any] = { "files": file, "output_type": output_type, - "output_format": output_format, } if pages is not None: payload["pages"] = pages diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index eba57bed..7475d18c 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -402,10 +402,6 @@ class ConvertToMarkdownPayload(BaseModel): output_type: Annotated[ SummaryOutputType, Field(serialization_alias="output_type", default="json") ] = "json" - output_format: Annotated[ - SummaryOutputFormat, - Field(serialization_alias="output_format", default="markdown"), - ] = "markdown" page_break_comments: Annotated[ Literal["on", "off"] | None, Field(serialization_alias="page_break_comments", default=None), diff --git a/tests/test_convert_to_markdown.py b/tests/test_convert_to_markdown.py index 88c9135a..2ca6d219 100644 --- a/tests/test_convert_to_markdown.py +++ b/tests/test_convert_to_markdown.py @@ -57,7 +57,6 @@ def test_convert_to_markdown_json_success(monkeypatch: pytest.MonkeyPatch) -> No "pages": ["1-3"], "output": "md", "output_type": "json", - "output_format": "markdown", "page_break_comments": "on", } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) @@ -87,7 +86,6 @@ def handler(request: httpx.Request) -> httpx.Response: pages=["1-3"], output="md", output_type="json", - output_format="markdown", page_break_comments="on", ) @@ -108,7 +106,6 @@ def test_convert_to_markdown_request_customization( { "files": [input_file], "output_type": "file", - "output_format": "markdown", "page_break_comments": "off", } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) From c0d1750cce43dc4f51979e2c99dc03635115ce9f Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 18:29:31 -0600 Subject: [PATCH 30/84] Extract Text test: Fix expected fields Assisted-by: Codex --- tests/test_extract_text.py | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/tests/test_extract_text.py b/tests/test_extract_text.py index f8f01eaa..242be3ac 100644 --- a/tests/test_extract_text.py +++ b/tests/test_extract_text.py @@ -42,7 +42,16 @@ def test_extract_text_json_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) payload_dump = ExtractTextPayload.model_validate( - {"files": [input_file], "pages": ["1-3"], "output": "text"} + { + "files": [input_file], + "pages": ["1-3"], + "output": "text", + "full_text": "document", + "preserve_line_breaks": "off", + "word_style": "off", + "word_coordinates": "off", + "output_type": "json", + } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) seen: dict[str, int] = {"post": 0} @@ -55,7 +64,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "text": "Example extracted text", + "fullText": "Example extracted text", "inputId": str(input_file.id), }, ) @@ -84,7 +93,15 @@ def test_extract_text_request_customization( monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) payload_dump = ExtractTextPayload.model_validate( - {"files": [input_file], "output": "file-output"} + { + "files": [input_file], + "output": "file-output", + "full_text": "document", + "preserve_line_breaks": "off", + "word_style": "off", + "word_coordinates": "off", + "output_type": "json", + } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) output_id = str(PdfRestFileID.generate()) captured_timeout: dict[str, float | dict[str, float] | None] = {} @@ -138,7 +155,14 @@ async def test_async_extract_text_success( monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(2)) payload_dump = ExtractTextPayload.model_validate( - {"files": [input_file]} + { + "files": [input_file], + "full_text": "document", + "preserve_line_breaks": "off", + "word_style": "off", + "word_coordinates": "off", + "output_type": "json", + } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) seen: dict[str, int] = {"post": 0} @@ -150,10 +174,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert payload == payload_dump return httpx.Response( 200, - json={ - "text": "Async text", - "inputId": str(input_file.id), - }, + json={"fullText": "Async text", "inputId": str(input_file.id)}, ) msg = f"Unexpected request {request.method} {request.url}" raise AssertionError(msg) From 431642156dd2de0e2bf08d92d830760c4cd39991 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 18:42:24 -0600 Subject: [PATCH 31/84] Translate PDF test: Fix expected translated text field Assisted-by: Codex --- src/pdfrest/models/public.py | 2 ++ tests/test_translate_pdf_text.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 882d1b1b..76c3416e 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -361,6 +361,8 @@ class TranslatePdfTextResponse(BaseModel): translated_text: Annotated[ str | None, Field( + alias="translated_text", + validation_alias=AliasChoices("translated_text", "translatedText"), description="Inline translation content when output_type is json.", default=None, ), diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py index ce9c8078..6769f5c5 100644 --- a/tests/test_translate_pdf_text.py +++ b/tests/test_translate_pdf_text.py @@ -84,7 +84,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "translation": "Bonjour", + "translated_text": "Bonjour", "inputId": str(input_file.id), }, ) @@ -189,7 +189,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "translation": "Hallo", + "translated_text": "Hallo", "inputId": str(input_file.id), }, ) From ab2e6d831b5b98fbbbb33f828a187a2bcae2f551 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 19 Dec 2025 18:44:14 -0600 Subject: [PATCH 32/84] Translate PDF test: Fix unexpected GET Assisted-by: Codex --- tests/test_translate_pdf_text.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py index 6769f5c5..47df9ba6 100644 --- a/tests/test_translate_pdf_text.py +++ b/tests/test_translate_pdf_text.py @@ -142,6 +142,16 @@ def handler(request: httpx.Request) -> httpx.Response: "inputId": str(input_file.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=_make_markdown_file(output_id).model_dump( + mode="json", by_alias=True + ), + ) msg = f"Unexpected request {request.method} {request.url}" raise AssertionError(msg) From 97821c4b7c4bb188a3b92bc446836251fd214045 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 6 Jan 2026 15:17:31 -0600 Subject: [PATCH 33/84] Split Summarize PDF method by response file type - Add additional method to summarize to output file - Use PdfRestFileBasedResponse for summarize to file Assisted-by: Codex --- src/pdfrest/client.py | 104 +++++++++++++-- src/pdfrest/models/__init__.py | 4 +- src/pdfrest/models/public.py | 4 +- tests/live/test_live_summarize_pdf_text.py | 25 +++- tests/test_summarize_pdf_text.py | 147 +++++++++++++++++++-- 5 files changed, 256 insertions(+), 28 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index fcdd894c..59c83897 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -60,9 +60,9 @@ translate_httpx_error, ) from .models import ( - PdfRestDeletionResponse, ConvertToMarkdownResponse, ExtractTextResponse, + PdfRestDeletionResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -78,14 +78,14 @@ from .models._internal import ( BasePdfRestGraphicPayload, BmpPdfRestPayload, - DeletePayload, ConvertToMarkdownPayload, + DeletePayload, ExtractImagesPayload, ExtractTextPayload, GifPdfRestPayload, JpegPdfRestPayload, - PdfCompressPayload, OcrPdfPayload, + PdfCompressPayload, PdfFlattenAnnotationsPayload, PdfFlattenFormsPayload, PdfFlattenTransparenciesPayload, @@ -2134,21 +2134,24 @@ def summarize_pdf_text( summary_format: SummaryFormat = "overview", pages: PdfPageSelection | None = None, output_format: SummaryOutputFormat = "markdown", - output_type: SummaryOutputType = "json", output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> SummarizePdfTextResponse: - """Summarize the textual content of a PDF, Markdown, or text document.""" + """Summarize the textual content of a PDF, Markdown, or text document. + + Always requests JSON output and returns the inline summary response defined in + the pdfRest API reference. + """ payload: dict[str, Any] = { "files": file, "target_word_count": target_word_count, "summary_format": summary_format, "output_format": output_format, - "output_type": output_type, + "output_type": "json", } if pages is not None: payload["pages"] = pages @@ -2170,6 +2173,44 @@ def summarize_pdf_text( raw_payload = self._send_request(request) return SummarizePdfTextResponse.model_validate(raw_payload) + def summarize_pdf_text_to_file( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + target_word_count: int | None = 400, + summary_format: SummaryFormat = "overview", + pages: PdfPageSelection | None = None, + output_format: SummaryOutputFormat = "markdown", + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Summarize a document and return the result as a downloadable file.""" + + payload: dict[str, Any] = { + "files": file, + "target_word_count": target_word_count, + "summary_format": summary_format, + "output_format": output_format, + "output_type": "file", + } + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/summarized-pdf-text", + payload=payload, + payload_model=SummarizePdfTextPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_markdown( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -2668,7 +2709,6 @@ def compress_pdf( extra_body=extra_body, timeout=timeout, ) - def flatten_transparencies( self, @@ -3083,21 +3123,24 @@ async def summarize_pdf_text( summary_format: SummaryFormat = "overview", pages: PdfPageSelection | None = None, output_format: SummaryOutputFormat = "markdown", - output_type: SummaryOutputType = "json", output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> SummarizePdfTextResponse: - """Summarize the textual content of a PDF, Markdown, or text document.""" + """Summarize the textual content of a PDF, Markdown, or text document. + + Always requests JSON output and returns the inline summary response defined in + the pdfRest API reference. + """ payload: dict[str, Any] = { "files": file, "target_word_count": target_word_count, "summary_format": summary_format, "output_format": output_format, - "output_type": output_type, + "output_type": "json", } if pages is not None: payload["pages"] = pages @@ -3119,6 +3162,44 @@ async def summarize_pdf_text( raw_payload = await self._send_request(request) return SummarizePdfTextResponse.model_validate(raw_payload) + async def summarize_pdf_text_to_file( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + target_word_count: int | None = 400, + summary_format: SummaryFormat = "overview", + pages: PdfPageSelection | None = None, + output_format: SummaryOutputFormat = "markdown", + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Summarize a document and return the result as a downloadable file.""" + + payload: dict[str, Any] = { + "files": file, + "target_word_count": target_word_count, + "summary_format": summary_format, + "output_format": output_format, + "output_type": "file", + } + if pages is not None: + payload["pages"] = pages + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/summarized-pdf-text", + payload=payload, + payload_model=SummarizePdfTextPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def convert_to_markdown( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -3659,7 +3740,6 @@ async def compress_pdf( extra_body=extra_body, timeout=timeout, ) - async def flatten_transparencies( self, @@ -3687,7 +3767,7 @@ async def flatten_transparencies( extra_body=extra_body, timeout=timeout, ) - + async def linearize_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index 6ab74f89..eb9ee359 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -1,7 +1,7 @@ from .public import ( - PdfRestDeletionResponse, ConvertToMarkdownResponse, ExtractTextResponse, + PdfRestDeletionResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -13,9 +13,9 @@ ) __all__ = [ - "PdfRestDeletionResponse", "ConvertToMarkdownResponse", "ExtractTextResponse", + "PdfRestDeletionResponse", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 76c3416e..dd5c916b 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -20,9 +20,9 @@ from typing_extensions import override __all__ = ( - "PdfRestDeletionResponse", "ConvertToMarkdownResponse", "ExtractTextResponse", + "PdfRestDeletionResponse", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", @@ -314,6 +314,8 @@ class PdfRestDeletionResponse(BaseModel): min_length=1, ), ] + + class SummarizePdfTextResponse(BaseModel): """Response returned by the summarize-pdf-text tool.""" diff --git a/tests/live/test_live_summarize_pdf_text.py b/tests/live/test_live_summarize_pdf_text.py index 25d287b0..27920fd8 100644 --- a/tests/live/test_live_summarize_pdf_text.py +++ b/tests/live/test_live_summarize_pdf_text.py @@ -3,7 +3,7 @@ import pytest from pdfrest import PdfRestApiError, PdfRestClient -from pdfrest.models import SummarizePdfTextResponse +from pdfrest.models import PdfRestFileBasedResponse, SummarizePdfTextResponse from ..resources import get_test_resource_path @@ -21,7 +21,6 @@ def test_live_summarize_pdf_text_success( response = client.summarize_pdf_text( uploaded, target_word_count=40, - output_type="json", summary_format="overview", ) @@ -30,6 +29,28 @@ def test_live_summarize_pdf_text_success( assert response.input_id == uploaded.id +def test_live_summarize_pdf_text_to_file_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + response = client.summarize_pdf_text_to_file( + uploaded, + target_word_count=40, + summary_format="overview", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + assert response.output_file.id + assert response.input_id == uploaded.id + + def test_live_summarize_pdf_text_invalid_format( pdfrest_api_key: str, pdfrest_live_base_url: str, diff --git a/tests/test_summarize_pdf_text.py b/tests/test_summarize_pdf_text.py index 99f481f9..cbcf490b 100644 --- a/tests/test_summarize_pdf_text.py +++ b/tests/test_summarize_pdf_text.py @@ -7,10 +7,20 @@ from pydantic import ValidationError from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import PdfRestFile, PdfRestFileID, SummarizePdfTextResponse +from pdfrest.models import ( + PdfRestFile, + PdfRestFileBasedResponse, + PdfRestFileID, + SummarizePdfTextResponse, +) from pdfrest.models._internal import SummarizePdfTextPayload -from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) def _make_text_file(file_id: str) -> PdfRestFile: @@ -96,7 +106,6 @@ def handler(request: httpx.Request) -> httpx.Response: summary_format="bullet_points", pages=["1-3"], output_format="plaintext", - output_type="json", output="summary", ) @@ -108,7 +117,66 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.output_url is None -def test_summarize_pdf_text_request_customization( +def test_summarize_pdf_text_to_file_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_text_file(str(PdfRestFileID.generate(1))) + payload_dump = SummarizePdfTextPayload.model_validate( + { + "files": [input_file], + "target_word_count": 200, + "summary_format": "bullet_points", + "pages": ["2-last"], + "output_format": "plaintext", + "output_type": "file", + "output": "summary", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + 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 == "/summarized-pdf-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "outputId": output_id, + "inputId": str(input_file.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, "summary.txt", "text/plain"), + ) + 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.summarize_pdf_text_to_file( + input_file, + target_word_count=200, + summary_format="bullet_points", + pages=["2-last"], + output_format="plaintext", + output="summary", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.id == output_id + assert response.output_file.name == "summary.txt" + assert response.input_id == input_file.id + + +def test_summarize_pdf_text_to_file_request_customization( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -124,9 +192,11 @@ def test_summarize_pdf_text_request_customization( output_id = str(PdfRestFileID.generate()) captured_timeout: dict[str, float | dict[str, float] | None] = {} + seen: dict[str, int] = {"post": 0, "get": 0} def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/summarized-pdf-text": + seen["post"] += 1 assert request.url.params["trace"] == "true" assert request.headers["X-Debug"] == "sync" captured_timeout["value"] = request.extensions.get("timeout") @@ -137,28 +207,36 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "outputUrl": f"https://api.pdfrest.com/resource/{output_id}?format=file", "outputId": output_id, "inputId": str(input_file.id), }, ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + 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, "summary.txt", "text/plain"), + ) 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.summarize_pdf_text( + response = client.summarize_pdf_text_to_file( input_file, - output_type="file", extra_query={"trace": "true"}, extra_headers={"X-Debug": "sync"}, extra_body={"debug": True}, timeout=0.25, ) - assert isinstance(response, SummarizePdfTextResponse) - assert response.output_id == output_id - assert response.output_url + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.id == output_id + assert response.output_file.name == "summary.txt" timeout_value = captured_timeout["value"] assert timeout_value is not None if isinstance(timeout_value, dict): @@ -199,9 +277,56 @@ 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.summarize_pdf_text(input_file, output_type="json") + response = await client.summarize_pdf_text(input_file) assert seen == {"post": 1} assert isinstance(response, SummarizePdfTextResponse) assert response.summary == "Async summary" assert response.input_id == input_file.id + + +@pytest.mark.asyncio +async def test_async_summarize_pdf_text_to_file_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = SummarizePdfTextPayload.model_validate( + {"files": [input_file], "output_type": "file"} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + 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 == "/summarized-pdf-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + for key, value in payload_dump.items(): + assert payload[key] == value + return httpx.Response( + 200, + json={ + "outputId": output_id, + "inputId": str(input_file.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, "async-summary.txt", "text/plain" + ), + ) + 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.summarize_pdf_text_to_file(input_file) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.id == output_id + assert response.input_id == input_file.id From 6f080ec2565651c88c8ebd6b597160b590701a4a Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 6 Jan 2026 16:41:38 -0600 Subject: [PATCH 34/84] Translate PDF: Improve response types - `TranslatePdfTextResponse` gets missing `source_languages` and `output_language` fields. - Adds `TranslatePdfTextFileResponse`. This inherits from `PdfRestFileBasedResponse` with additional Translate PDF fields. - Add `FileBasedResponse` TypeVar bound to `PdfRestFileBasedResponse` so classes like `TranslatePdfTextFileResponse` can reuse file fetch logic. Assisted-by: Codex --- src/pdfrest/client.py | 59 +++++++++++++--------- src/pdfrest/models/__init__.py | 2 + src/pdfrest/models/public.py | 44 ++++++++++++++++ tests/live/test_live_translate_pdf_text.py | 11 +++- tests/test_translate_pdf_text.py | 16 +++++- 5 files changed, 103 insertions(+), 29 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 59c83897..f2a09726 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -69,12 +69,10 @@ PdfRestFileID, PdfRestInfoResponse, SummarizePdfTextResponse, + TranslatePdfTextFileResponse, TranslatePdfTextResponse, UpResponse, ) - -__all__ = ("AsyncPdfRestClient", "PdfRestClient") - from .models._internal import ( BasePdfRestGraphicPayload, BmpPdfRestPayload, @@ -122,6 +120,9 @@ TranslateOutputFormat, ) +__all__ = ("AsyncPdfRestClient", "PdfRestClient") +FileResponseModel = TypeVar("FileResponseModel", bound=PdfRestFileBasedResponse) + DEFAULT_BASE_URL = "https://api.pdfrest.com" API_KEY_ENV_VAR = "PDFREST_API_KEY" API_KEY_HEADER_NAME = "Api-Key" @@ -986,11 +987,12 @@ def _post_file_operation( endpoint: str, payload: dict[str, Any], payload_model: type[BaseModel], + response_model: type[FileResponseModel] = PdfRestFileBasedResponse, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> PdfRestFileBasedResponse: + ) -> FileResponseModel: job_options = payload_model.model_validate(payload) json_body = job_options.model_dump( mode="json", by_alias=True, exclude_none=True, exclude_unset=True @@ -1018,15 +1020,17 @@ def _post_file_operation( for file_id in output_ids ] - return PdfRestFileBasedResponse.model_validate( - { - "input_id": [str(file_id) for file_id in raw_response.input_id], - "output_file": [ - file.model_dump(mode="json", by_alias=True) for file in output_files - ], - "warning": raw_response.warning, - } - ) + response_payload: dict[str, Any] = { + "input_id": [str(file_id) for file_id in raw_response.input_id], + "output_file": [ + file.model_dump(mode="json", by_alias=True) for file in output_files + ], + "warning": raw_response.warning, + } + if raw_response.model_extra: + response_payload.update(raw_response.model_extra) + + return response_model.model_validate(response_payload) def send_request(self, request: _RequestModel) -> Any: return self._send_request(request) @@ -1250,11 +1254,12 @@ async def _post_file_operation( endpoint: str, payload: dict[str, Any], payload_model: type[BaseModel], + response_model: type[FileResponseModel] = PdfRestFileBasedResponse, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> PdfRestFileBasedResponse: + ) -> FileResponseModel: job_options = payload_model.model_validate(payload) request = self.prepare_request( "POST", @@ -1290,15 +1295,17 @@ async def throttled_fetch_file_info(file_id: str) -> PdfRestFile: ) ) - return PdfRestFileBasedResponse.model_validate( - { - "input_id": [str(file_id) for file_id in raw_response.input_id], - "output_file": [ - file.model_dump(mode="json", by_alias=True) for file in output_files - ], - "warning": raw_response.warning, - } - ) + response_payload: dict[str, Any] = { + "input_id": [str(file_id) for file_id in raw_response.input_id], + "output_file": [ + file.model_dump(mode="json", by_alias=True) for file in output_files + ], + "warning": raw_response.warning, + } + if raw_response.model_extra: + response_payload.update(raw_response.model_extra) + + return response_model.model_validate(response_payload) async def send_request(self, request: _RequestModel) -> Any: return await self._send_request(request) @@ -2334,7 +2341,7 @@ def translate_pdf_text_to_file( extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> PdfRestFileBasedResponse: + ) -> TranslatePdfTextFileResponse: """Translate textual content and receive a file-based response.""" payload: dict[str, Any] = { @@ -2356,6 +2363,7 @@ def translate_pdf_text_to_file( extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, + response_model=TranslatePdfTextFileResponse, ) def extract_images( @@ -3323,7 +3331,7 @@ async def translate_pdf_text_to_file( extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> PdfRestFileBasedResponse: + ) -> TranslatePdfTextFileResponse: """Translate textual content and receive a file-based response.""" payload: dict[str, Any] = { @@ -3345,6 +3353,7 @@ async def translate_pdf_text_to_file( extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, + response_model=TranslatePdfTextFileResponse, ) async def extract_images( diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index eb9ee359..755bbaf7 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -8,6 +8,7 @@ PdfRestFileID, PdfRestInfoResponse, SummarizePdfTextResponse, + TranslatePdfTextFileResponse, TranslatePdfTextResponse, UpResponse, ) @@ -22,6 +23,7 @@ "PdfRestFileID", "PdfRestInfoResponse", "SummarizePdfTextResponse", + "TranslatePdfTextFileResponse", "TranslatePdfTextResponse", "UpResponse", ] diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index dd5c916b..aafe55f4 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -29,6 +29,7 @@ "PdfRestFileID", "PdfRestInfoResponse", "SummarizePdfTextResponse", + "TranslatePdfTextFileResponse", "TranslatePdfTextResponse", "UpResponse", ) @@ -360,6 +361,24 @@ class TranslatePdfTextResponse(BaseModel): model_config = ConfigDict(extra="allow") + source_languages: Annotated[ + list[str] | None, + Field( + alias="source_languages", + validation_alias=AliasChoices("source_languages", "sourceLanguages"), + description="Languages detected in the source content.", + default=None, + ), + ] = None + output_language: Annotated[ + str | None, + Field( + alias="output_language", + validation_alias=AliasChoices("output_language", "outputLanguage"), + description="Target language used for the translation.", + default=None, + ), + ] = None translated_text: Annotated[ str | None, Field( @@ -396,6 +415,31 @@ class TranslatePdfTextResponse(BaseModel): ] = None +class TranslatePdfTextFileResponse(PdfRestFileBasedResponse): + """File-based response returned by the translated-pdf-text tool.""" + + model_config = ConfigDict(extra="allow") + + source_languages: Annotated[ + list[str] | None, + Field( + alias="source_languages", + validation_alias=AliasChoices("source_languages", "sourceLanguages"), + description="Languages detected in the source content.", + default=None, + ), + ] = None + output_language: Annotated[ + str | None, + Field( + alias="output_language", + validation_alias=AliasChoices("output_language", "outputLanguage"), + description="Target language used for the translation.", + default=None, + ), + ] = None + + class ExtractTextResponse(BaseModel): """Response returned by the extracted-text tool.""" diff --git a/tests/live/test_live_translate_pdf_text.py b/tests/live/test_live_translate_pdf_text.py index c254fd5e..8c82039d 100644 --- a/tests/live/test_live_translate_pdf_text.py +++ b/tests/live/test_live_translate_pdf_text.py @@ -3,7 +3,10 @@ import pytest from pdfrest import PdfRestApiError, PdfRestClient -from pdfrest.models import PdfRestFileBasedResponse, TranslatePdfTextResponse +from pdfrest.models import ( + TranslatePdfTextFileResponse, + TranslatePdfTextResponse, +) from ..resources import get_test_resource_path @@ -26,6 +29,8 @@ def test_live_translate_pdf_text_success( assert isinstance(response, TranslatePdfTextResponse) assert response.translated_text + assert response.output_language == "fr" + assert response.source_languages assert response.input_id == uploaded.id @@ -63,6 +68,8 @@ def test_live_translate_pdf_text_file_success( output_format="plaintext", ) - assert isinstance(response, PdfRestFileBasedResponse) + assert isinstance(response, TranslatePdfTextFileResponse) assert response.output_files + assert response.output_language == "fr" + assert response.source_languages assert response.input_id == uploaded.id diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py index 47df9ba6..c26c4c0c 100644 --- a/tests/test_translate_pdf_text.py +++ b/tests/test_translate_pdf_text.py @@ -9,8 +9,8 @@ from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models import ( PdfRestFile, - PdfRestFileBasedResponse, PdfRestFileID, + TranslatePdfTextFileResponse, TranslatePdfTextResponse, ) from pdfrest.models._internal import TranslatePdfTextPayload @@ -86,6 +86,8 @@ def handler(request: httpx.Request) -> httpx.Response: json={ "translated_text": "Bonjour", "inputId": str(input_file.id), + "source_languages": ["en"], + "output_language": "fr", }, ) msg = f"Unexpected request {request.method} {request.url}" @@ -104,6 +106,8 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen == {"post": 1} assert isinstance(response, TranslatePdfTextResponse) assert response.translated_text == "Bonjour" + assert response.source_languages == ["en"] + assert response.output_language == "fr" assert response.input_id == input_file.id assert response.output_id is None assert response.output_url is None @@ -140,6 +144,8 @@ def handler(request: httpx.Request) -> httpx.Response: "outputUrl": f"https://api.pdfrest.com/resource/{output_id}?format=file", "outputId": output_id, "inputId": str(input_file.id), + "source_languages": ["en"], + "output_language": "es", }, ) if request.method == "GET" and request.url.path == f"/resource/{output_id}": @@ -166,8 +172,10 @@ def handler(request: httpx.Request) -> httpx.Response: timeout=0.3, ) - assert isinstance(response, PdfRestFileBasedResponse) + assert isinstance(response, TranslatePdfTextFileResponse) assert response.output_file.id == output_id + assert response.output_language == "es" + assert response.source_languages == ["en"] timeout_value = captured_timeout["value"] assert timeout_value is not None if isinstance(timeout_value, dict): @@ -201,6 +209,8 @@ def handler(request: httpx.Request) -> httpx.Response: json={ "translated_text": "Hallo", "inputId": str(input_file.id), + "source_languages": ["en"], + "output_language": "de", }, ) msg = f"Unexpected request {request.method} {request.url}" @@ -216,4 +226,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen == {"post": 1} assert isinstance(response, TranslatePdfTextResponse) assert response.translated_text == "Hallo" + assert response.source_languages == ["en"] + assert response.output_language == "de" assert response.input_id == input_file.id From ce5d12673f04d219003fe598e8cdfe65c131f614 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 6 Jan 2026 17:01:44 -0600 Subject: [PATCH 35/84] Revise and rename `extract_text` to `extract_pdf_text_to_file` - Fix `output_type` to "file" - Use `PdfRestFileBasedResponse` - Remove `ExtractTextResponse` Assisted-by: Codex --- src/pdfrest/client.py | 69 +++++----- ... => test_live_extract_pdf_text_to_file.py} | 16 +-- ...xt.py => test_extract_pdf_text_to_file.py} | 127 ++++++++++++------ 3 files changed, 126 insertions(+), 86 deletions(-) rename tests/live/{test_live_extract_text.py => test_live_extract_pdf_text_to_file.py} (75%) rename tests/{test_extract_text.py => test_extract_pdf_text_to_file.py} (56%) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index f2a09726..9a312d55 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -61,7 +61,6 @@ ) from .models import ( ConvertToMarkdownResponse, - ExtractTextResponse, PdfRestDeletionResponse, PdfRestErrorResponse, PdfRestFile, @@ -2395,7 +2394,7 @@ def extract_images( timeout=timeout, ) - def extract_text( + def extract_pdf_text_to_file( self, file: PdfRestFile | Sequence[PdfRestFile], *, @@ -2404,40 +2403,36 @@ def extract_text( preserve_line_breaks: Literal["off", "on"] = "off", word_style: Literal["off", "on"] = "off", word_coordinates: Literal["off", "on"] = "off", - output_type: Literal["json", "file"] = "json", output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> ExtractTextResponse: - """Extract text content from a PDF.""" + ) -> PdfRestFileBasedResponse: + """Extract text content from a PDF and return a file-based response.""" - payload: dict[str, Any] = {"files": file} + payload: dict[str, Any] = { + "files": file, + "full_text": full_text, + "preserve_line_breaks": preserve_line_breaks, + "word_style": word_style, + "word_coordinates": word_coordinates, + "output_type": "file", + } if pages is not None: payload["pages"] = pages - payload["full_text"] = full_text - payload["preserve_line_breaks"] = preserve_line_breaks - payload["word_style"] = word_style - payload["word_coordinates"] = word_coordinates - payload["output_type"] = output_type if output is not None: payload["output"] = output - validated_payload = ExtractTextPayload.model_validate(payload) - request = self.prepare_request( - "POST", - "/extracted-text", - json_body=validated_payload.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return self._post_file_operation( + endpoint="/extracted-text", + payload=payload, + payload_model=ExtractTextPayload, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = self._send_request(request) - return ExtractTextResponse.model_validate(raw_payload) def preview_redactions( self, @@ -3385,7 +3380,7 @@ async def extract_images( timeout=timeout, ) - async def extract_text( + async def extract_pdf_text_to_file( self, file: PdfRestFile | Sequence[PdfRestFile], *, @@ -3394,40 +3389,36 @@ async def extract_text( preserve_line_breaks: Literal["off", "on"] = "off", word_style: Literal["off", "on"] = "off", word_coordinates: Literal["off", "on"] = "off", - output_type: Literal["json", "file"] = "json", output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> ExtractTextResponse: - """Extract text content from a PDF.""" + ) -> PdfRestFileBasedResponse: + """Extract text content from a PDF and return a file-based response.""" - payload: dict[str, Any] = {"files": file} + payload: dict[str, Any] = { + "files": file, + "full_text": full_text, + "preserve_line_breaks": preserve_line_breaks, + "word_style": word_style, + "word_coordinates": word_coordinates, + "output_type": "file", + } if pages is not None: payload["pages"] = pages - payload["full_text"] = full_text - payload["preserve_line_breaks"] = preserve_line_breaks - payload["word_style"] = word_style - payload["word_coordinates"] = word_coordinates - payload["output_type"] = output_type if output is not None: payload["output"] = output - validated_payload = ExtractTextPayload.model_validate(payload) - request = self.prepare_request( - "POST", - "/extracted-text", - json_body=validated_payload.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return await self._post_file_operation( + endpoint="/extracted-text", + payload=payload, + payload_model=ExtractTextPayload, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = await self._send_request(request) - return ExtractTextResponse.model_validate(raw_payload) async def preview_redactions( self, diff --git a/tests/live/test_live_extract_text.py b/tests/live/test_live_extract_pdf_text_to_file.py similarity index 75% rename from tests/live/test_live_extract_text.py rename to tests/live/test_live_extract_pdf_text_to_file.py index bfbeb2cc..a96167f4 100644 --- a/tests/live/test_live_extract_text.py +++ b/tests/live/test_live_extract_pdf_text_to_file.py @@ -3,12 +3,12 @@ import pytest from pdfrest import PdfRestApiError, PdfRestClient -from pdfrest.models import ExtractTextResponse +from pdfrest.models import PdfRestFileBasedResponse from ..resources import get_test_resource_path -def test_live_extract_text_success( +def test_live_extract_pdf_text_to_file_success( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: @@ -18,21 +18,20 @@ def test_live_extract_text_success( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - response = client.extract_text( + response = client.extract_pdf_text_to_file( uploaded, - output_type="json", full_text="document", preserve_line_breaks="on", word_style="off", word_coordinates="off", ) - assert isinstance(response, ExtractTextResponse) - assert response.full_text + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files assert response.input_id == uploaded.id -def test_live_extract_text_invalid_pages( +def test_live_extract_pdf_text_to_file_invalid_pages( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: @@ -43,8 +42,7 @@ def test_live_extract_text_invalid_pages( ) as client: uploaded = client.files.create_from_paths([resource])[0] with pytest.raises(PdfRestApiError): - client.extract_text( + client.extract_pdf_text_to_file( uploaded, extra_body={"pages": "last-1"}, - output_type="json", ) diff --git a/tests/test_extract_text.py b/tests/test_extract_pdf_text_to_file.py similarity index 56% rename from tests/test_extract_text.py rename to tests/test_extract_pdf_text_to_file.py index 242be3ac..a2ad457c 100644 --- a/tests/test_extract_text.py +++ b/tests/test_extract_pdf_text_to_file.py @@ -7,13 +7,27 @@ from pydantic import ValidationError from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import ExtractTextResponse, PdfRestFile, PdfRestFileID +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID from pdfrest.models._internal import ExtractTextPayload from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file -def test_extract_text_payload_rejects_non_pdf() -> None: +def _make_text_file(file_id: str, name: str = "extracted.txt") -> PdfRestFile: + return PdfRestFile.model_validate( + { + "id": file_id, + "name": name, + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/plain", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + + +def test_extract_pdf_text_payload_rejects_non_pdf() -> None: file_id = str(PdfRestFileID.generate()) text_file = PdfRestFile.model_validate( { @@ -30,7 +44,7 @@ def test_extract_text_payload_rejects_non_pdf() -> None: ExtractTextPayload.model_validate({"files": [text_file]}) -def test_extract_text_payload_invalid_page_range() -> None: +def test_extract_pdf_text_payload_invalid_page_range() -> None: file_repr = make_pdf_file(PdfRestFileID.generate(1)) with pytest.raises( ValidationError, match="The start page must be less than or equal to the end" @@ -38,9 +52,10 @@ def test_extract_text_payload_invalid_page_range() -> None: ExtractTextPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) -def test_extract_text_json_success(monkeypatch: pytest.MonkeyPatch) -> None: +def test_extract_pdf_text_to_file_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 = ExtractTextPayload.model_validate( { "files": [input_file], @@ -50,11 +65,11 @@ def test_extract_text_json_success(monkeypatch: pytest.MonkeyPatch) -> None: "preserve_line_breaks": "off", "word_style": "off", "word_coordinates": "off", - "output_type": "json", + "output_type": "file", } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) - seen: dict[str, int] = {"post": 0} + seen: dict[str, int] = {"post": 0, "get": 0} def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/extracted-text": @@ -64,34 +79,41 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "fullText": "Example extracted text", - "inputId": str(input_file.id), + "inputId": [str(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=_make_text_file(output_id).model_dump(mode="json", by_alias=True), + ) 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.extract_text( + response = client.extract_pdf_text_to_file( input_file, pages=["1-3"], output="text", ) - assert seen == {"post": 1} - assert isinstance(response, ExtractTextResponse) - assert response.full_text == "Example extracted text" + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) assert response.input_id == input_file.id - assert response.output_id is None - assert response.output_url is None + assert len(response.output_files) == 1 + assert response.output_file.id == output_id -def test_extract_text_request_customization( +def test_extract_pdf_text_request_customization( 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 = ExtractTextPayload.model_validate( { "files": [input_file], @@ -100,33 +122,42 @@ def test_extract_text_request_customization( "preserve_line_breaks": "off", "word_style": "off", "word_coordinates": "off", - "output_type": "json", + "output_type": "file", } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) - 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 == "/extracted-text": assert request.url.params["trace"] == "true" assert request.headers["X-Debug"] == "sync" - captured_timeout["value"] = request.extensions.get("timeout") + captured_timeout["post"] = request.extensions.get("timeout") payload = json.loads(request.content.decode("utf-8")) assert payload == payload_dump | {"debug": True} return httpx.Response( 200, json={ - "outputUrl": f"https://api.pdfrest.com/resource/{output_id}?format=file", - "outputId": output_id, - "inputId": str(input_file.id), + "inputId": [str(input_file.id)], + "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" + captured_timeout["get"] = request.extensions.get("timeout") + return httpx.Response( + 200, + json=_make_text_file(output_id, "debug.txt").model_dump( + mode="json", by_alias=True + ), + ) 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.extract_text( + response = client.extract_pdf_text_to_file( input_file, output="file-output", extra_query={"trace": "true"}, @@ -135,25 +166,33 @@ def handler(request: httpx.Request) -> httpx.Response: timeout=0.35, ) - assert isinstance(response, ExtractTextResponse) - assert response.output_id == output_id - assert response.output_url - timeout_value = captured_timeout["value"] - assert timeout_value is not None - if isinstance(timeout_value, dict): + assert isinstance(response, PdfRestFileBasedResponse) + assert len(response.output_files) == 1 + post_timeout = captured_timeout["post"] + get_timeout = captured_timeout["get"] + assert post_timeout is not None + assert get_timeout is not None + if isinstance(post_timeout, dict): assert all( - component == pytest.approx(0.35) for component in timeout_value.values() + component == pytest.approx(0.35) for component in post_timeout.values() ) else: - assert timeout_value == pytest.approx(0.35) + assert post_timeout == pytest.approx(0.35) + if isinstance(get_timeout, dict): + assert all( + component == pytest.approx(0.35) for component in get_timeout.values() + ) + else: + assert get_timeout == pytest.approx(0.35) @pytest.mark.asyncio -async def test_async_extract_text_success( +async def test_async_extract_pdf_text_to_file_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 = ExtractTextPayload.model_validate( { "files": [input_file], @@ -161,11 +200,11 @@ async def test_async_extract_text_success( "preserve_line_breaks": "off", "word_style": "off", "word_coordinates": "off", - "output_type": "json", + "output_type": "file", } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) - seen: dict[str, int] = {"post": 0} + seen: dict[str, int] = {"post": 0, "get": 0} def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/extracted-text": @@ -174,16 +213,28 @@ def handler(request: httpx.Request) -> httpx.Response: assert payload == payload_dump return httpx.Response( 200, - json={"fullText": "Async text", "inputId": str(input_file.id)}, + json={ + "inputId": [str(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=_make_text_file(output_id, "async.txt").model_dump( + mode="json", by_alias=True + ), ) 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.extract_text(input_file) + response = await client.extract_pdf_text_to_file(input_file) - assert seen == {"post": 1} - assert isinstance(response, ExtractTextResponse) - assert response.full_text == "Async text" + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert len(response.output_files) == 1 assert response.input_id == input_file.id From 629047ddd9f8a6a2348e8f124a8ecc9f678b7b2a Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 7 Jan 2026 09:26:09 -0600 Subject: [PATCH 36/84] PDF to Markdown: Use `PdfRestFileBasedResponse` Assisted-by: Codex --- src/pdfrest/client.py | 42 +++---- tests/live/test_live_convert_to_markdown.py | 11 +- tests/test_convert_to_markdown.py | 118 ++++++++++++++------ 3 files changed, 102 insertions(+), 69 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 9a312d55..9c96c49b 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -60,7 +60,6 @@ translate_httpx_error, ) from .models import ( - ConvertToMarkdownResponse, PdfRestDeletionResponse, PdfRestErrorResponse, PdfRestFile, @@ -115,7 +114,6 @@ PdfXType, SummaryFormat, SummaryOutputFormat, - SummaryOutputType, TranslateOutputFormat, ) @@ -2222,19 +2220,18 @@ def convert_to_markdown( file: PdfRestFile | Sequence[PdfRestFile], *, pages: PdfPageSelection | None = None, - output_type: SummaryOutputType = "json", page_break_comments: Literal["on", "off"] | 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, - ) -> ConvertToMarkdownResponse: - """Convert a PDF to Markdown.""" + ) -> PdfRestFileBasedResponse: + """Convert a PDF to Markdown and return a file-based response.""" payload: dict[str, Any] = { "files": file, - "output_type": output_type, + "output_type": "file", } if pages is not None: payload["pages"] = pages @@ -2243,20 +2240,15 @@ def convert_to_markdown( if output is not None: payload["output"] = output - validated_payload = ConvertToMarkdownPayload.model_validate(payload) - request = self.prepare_request( - "POST", - "/markdown", - json_body=validated_payload.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return self._post_file_operation( + endpoint="/markdown", + payload=payload, + payload_model=ConvertToMarkdownPayload, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = self._send_request(request) - return ConvertToMarkdownResponse.model_validate(raw_payload) def ocr_pdf( self, @@ -3208,19 +3200,18 @@ async def convert_to_markdown( file: PdfRestFile | Sequence[PdfRestFile], *, pages: PdfPageSelection | None = None, - output_type: SummaryOutputType = "json", page_break_comments: Literal["on", "off"] | 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, - ) -> ConvertToMarkdownResponse: - """Convert a PDF to Markdown.""" + ) -> PdfRestFileBasedResponse: + """Convert a PDF to Markdown and return a file-based response.""" payload: dict[str, Any] = { "files": file, - "output_type": output_type, + "output_type": "file", } if pages is not None: payload["pages"] = pages @@ -3229,20 +3220,15 @@ async def convert_to_markdown( if output is not None: payload["output"] = output - validated_payload = ConvertToMarkdownPayload.model_validate(payload) - request = self.prepare_request( - "POST", - "/markdown", - json_body=validated_payload.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ), + return await self._post_file_operation( + endpoint="/markdown", + payload=payload, + payload_model=ConvertToMarkdownPayload, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, ) - raw_payload = await self._send_request(request) - return ConvertToMarkdownResponse.model_validate(raw_payload) async def ocr_pdf( self, diff --git a/tests/live/test_live_convert_to_markdown.py b/tests/live/test_live_convert_to_markdown.py index f86215af..e3390a67 100644 --- a/tests/live/test_live_convert_to_markdown.py +++ b/tests/live/test_live_convert_to_markdown.py @@ -3,7 +3,7 @@ import pytest from pdfrest import PdfRestApiError, PdfRestClient -from pdfrest.models import ConvertToMarkdownResponse +from pdfrest.models import PdfRestFileBasedResponse from ..resources import get_test_resource_path @@ -18,13 +18,10 @@ def test_live_convert_to_markdown_success( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - response = client.convert_to_markdown( - uploaded, - output_type="json", - ) + response = client.convert_to_markdown(uploaded) - assert isinstance(response, ConvertToMarkdownResponse) - assert response.markdown + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files assert response.input_id == uploaded.id diff --git a/tests/test_convert_to_markdown.py b/tests/test_convert_to_markdown.py index 2ca6d219..22876eb8 100644 --- a/tests/test_convert_to_markdown.py +++ b/tests/test_convert_to_markdown.py @@ -7,12 +7,30 @@ from pydantic import ValidationError from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import ConvertToMarkdownResponse, PdfRestFile, PdfRestFileID +from pdfrest.models import ( + PdfRestFile, + PdfRestFileBasedResponse, + PdfRestFileID, +) from pdfrest.models._internal import ConvertToMarkdownPayload from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file +def _make_markdown_file(file_id: str, name: str = "markdown.md") -> PdfRestFile: + return PdfRestFile.model_validate( + { + "id": file_id, + "name": name, + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/markdown", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + + def test_convert_to_markdown_payload_rejects_non_pdf() -> None: file_id = str(PdfRestFileID.generate()) text_file = PdfRestFile.model_validate( @@ -48,20 +66,21 @@ def test_convert_to_markdown_payload_invalid_page_break_comments() -> None: ) -def test_convert_to_markdown_json_success(monkeypatch: pytest.MonkeyPatch) -> None: +def test_convert_to_markdown_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 = ConvertToMarkdownPayload.model_validate( { "files": [input_file], "pages": ["1-3"], "output": "md", - "output_type": "json", + "output_type": "file", "page_break_comments": "on", } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) - seen: dict[str, int] = {"post": 0} + seen: dict[str, int] = {"post": 0, "get": 0} def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/markdown": @@ -72,10 +91,19 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "markdown": "# Title", - "inputId": str(input_file.id), + "inputId": [str(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=_make_markdown_file(output_id).model_dump( + mode="json", by_alias=True + ), + ) msg = f"Unexpected request {request.method} {request.url}" raise AssertionError(msg) @@ -85,16 +113,13 @@ def handler(request: httpx.Request) -> httpx.Response: input_file, pages=["1-3"], output="md", - output_type="json", page_break_comments="on", ) - assert seen == {"post": 1} - assert isinstance(response, ConvertToMarkdownResponse) - assert response.markdown == "# Title" + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) assert response.input_id == input_file.id - assert response.output_id is None - assert response.output_url is None + assert len(response.output_files) == 1 def test_convert_to_markdown_request_customization( @@ -116,7 +141,7 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/markdown": assert request.url.params["trace"] == "true" assert request.headers["X-Debug"] == "sync" - captured_timeout["value"] = request.extensions.get("timeout") + captured_timeout["post"] = request.extensions.get("timeout") payload = json.loads(request.content.decode("utf-8")) for key, value in payload_dump.items(): assert payload[key] == value @@ -124,11 +149,21 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "outputUrl": f"https://api.pdfrest.com/resource/{output_id}?format=file", - "outputId": output_id, - "inputId": str(input_file.id), + "inputId": [str(input_file.id)], + "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" + captured_timeout["get"] = request.extensions.get("timeout") + return httpx.Response( + 200, + json=_make_markdown_file(output_id, "debug.md").model_dump( + mode="json", by_alias=True + ), + ) msg = f"Unexpected request {request.method} {request.url}" raise AssertionError(msg) @@ -136,7 +171,6 @@ def handler(request: httpx.Request) -> httpx.Response: with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = client.convert_to_markdown( input_file, - output_type="file", extra_query={"trace": "true"}, extra_headers={"X-Debug": "sync"}, extra_body={"debug": True}, @@ -144,17 +178,24 @@ def handler(request: httpx.Request) -> httpx.Response: page_break_comments="off", ) - assert isinstance(response, ConvertToMarkdownResponse) - assert response.output_id == output_id - assert response.output_url - timeout_value = captured_timeout["value"] - assert timeout_value is not None - if isinstance(timeout_value, dict): + assert isinstance(response, PdfRestFileBasedResponse) + assert len(response.output_files) == 1 + post_timeout = captured_timeout["post"] + get_timeout = captured_timeout["get"] + assert post_timeout is not None + assert get_timeout is not None + if isinstance(post_timeout, dict): + assert all( + component == pytest.approx(0.4) for component in post_timeout.values() + ) + else: + assert post_timeout == pytest.approx(0.4) + if isinstance(get_timeout, dict): assert all( - component == pytest.approx(0.4) for component in timeout_value.values() + component == pytest.approx(0.4) for component in get_timeout.values() ) else: - assert timeout_value == pytest.approx(0.4) + assert get_timeout == pytest.approx(0.4) @pytest.mark.asyncio @@ -163,11 +204,12 @@ async def test_async_convert_to_markdown_success( ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(2)) + output_id = str(PdfRestFileID.generate()) payload_dump = ConvertToMarkdownPayload.model_validate( - {"files": [input_file], "output_type": "json", "page_break_comments": "off"} + {"files": [input_file], "output_type": "file", "page_break_comments": "off"} ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) - seen: dict[str, int] = {"post": 0} + seen: dict[str, int] = {"post": 0, "get": 0} def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/markdown": @@ -178,20 +220,28 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "markdown": "Async md", - "inputId": str(input_file.id), + "inputId": [str(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=_make_markdown_file(output_id, "async.md").model_dump( + mode="json", by_alias=True + ), + ) 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_markdown( - input_file, output_type="json", page_break_comments="off" + input_file, page_break_comments="off" ) - assert seen == {"post": 1} - assert isinstance(response, ConvertToMarkdownResponse) - assert response.markdown == "Async md" - assert response.input_id == input_file.id + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert len(response.output_files) == 1 From d87563750f986abbacd34039c0f65a22093d3b34 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 7 Jan 2026 09:43:36 -0600 Subject: [PATCH 37/84] Add missing async live tests Assisted-by: Codex --- tests/live/test_live_convert_to_markdown.py | 39 ++++++++++++++- tests/live/test_live_convert_to_pdfx.py | 45 ++++++++++++++++- tests/live/test_live_convert_to_word.py | 44 +++++++++++++++- tests/live/test_live_extract_images.py | 39 ++++++++++++++- .../test_live_extract_pdf_text_to_file.py | 46 ++++++++++++++++- tests/live/test_live_flatten_pdf_forms.py | 41 ++++++++++++++- tests/live/test_live_graphic_conversions.py | 43 +++++++++++++++- tests/live/test_live_linearize_pdf.py | 41 ++++++++++++++- tests/live/test_live_ocr_pdf.py | 39 ++++++++++++++- tests/live/test_live_pdf_redactions.py | 50 ++++++++++++++++++- tests/live/test_live_summarize_pdf_text.py | 42 +++++++++++++++- tests/live/test_live_translate_pdf_text.py | 44 +++++++++++++++- 12 files changed, 501 insertions(+), 12 deletions(-) diff --git a/tests/live/test_live_convert_to_markdown.py b/tests/live/test_live_convert_to_markdown.py index e3390a67..23aa0e1c 100644 --- a/tests/live/test_live_convert_to_markdown.py +++ b/tests/live/test_live_convert_to_markdown.py @@ -2,7 +2,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFileBasedResponse from ..resources import get_test_resource_path @@ -25,6 +25,25 @@ def test_live_convert_to_markdown_success( assert response.input_id == uploaded.id +@pytest.mark.asyncio +async def test_live_async_convert_to_markdown_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + response = await client.convert_to_markdown(uploaded, output="async-md") + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + assert response.output_file.name.startswith("async-md") + assert response.input_id == uploaded.id + + def test_live_convert_to_markdown_invalid_pages( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -40,3 +59,21 @@ def test_live_convert_to_markdown_invalid_pages( uploaded, extra_body={"pages": "last-1"}, ) + + +@pytest.mark.asyncio +async def test_live_async_convert_to_markdown_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + with pytest.raises(PdfRestApiError): + await client.convert_to_markdown( + uploaded, + extra_body={"pages": "last-1"}, + ) diff --git a/tests/live/test_live_convert_to_pdfx.py b/tests/live/test_live_convert_to_pdfx.py index a08088b0..c010798d 100644 --- a/tests/live/test_live_convert_to_pdfx.py +++ b/tests/live/test_live_convert_to_pdfx.py @@ -4,7 +4,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile from pdfrest.types import PdfXType @@ -50,6 +50,31 @@ def test_live_convert_to_pdfx_success( assert output_file.name.startswith("pdfx-live") +@pytest.mark.asyncio +@pytest.mark.parametrize("output_type", PDFX_TYPES, ids=list(PDFX_TYPES)) +async def test_live_async_convert_to_pdfx_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_pdfx: PdfRestFile, + output_type: PdfXType, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_to_pdfx( + uploaded_pdf_for_pdfx, + output_type=output_type, + output="async-pdfx", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async-pdfx") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_pdfx.id) + + @pytest.mark.parametrize( "invalid_output_type", [ @@ -76,3 +101,21 @@ def test_live_convert_to_pdfx_invalid_output_type( output_type="PDF/X-1a", extra_body={"output_type": invalid_output_type}, ) + + +@pytest.mark.asyncio +async def test_live_async_convert_to_pdfx_invalid_output_type( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_pdfx: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError): + await client.convert_to_pdfx( + uploaded_pdf_for_pdfx, + output_type="PDF/X-1a", + extra_body={"output_type": "PDF/X-0"}, + ) diff --git a/tests/live/test_live_convert_to_word.py b/tests/live/test_live_convert_to_word.py index c3c5822e..1318ffa6 100644 --- a/tests/live/test_live_convert_to_word.py +++ b/tests/live/test_live_convert_to_word.py @@ -2,7 +2,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile from ..resources import get_test_resource_path @@ -57,6 +57,31 @@ def test_live_convert_to_word_success( assert output_file.name.endswith(".docx") +@pytest.mark.asyncio +async def test_live_async_convert_to_word_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_word: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_to_word( + uploaded_pdf_for_word, + output="async-word", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async-word") + assert ( + output_file.type + == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + assert str(response.input_id) == str(uploaded_pdf_for_word.id) + + def test_live_convert_to_word_invalid_file_id( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -73,3 +98,20 @@ def test_live_convert_to_word_invalid_file_id( uploaded_pdf_for_word, extra_body={"id": "00000000-0000-0000-0000-000000000000"}, ) + + +@pytest.mark.asyncio +async def test_live_async_convert_to_word_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_word: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError): + await client.convert_to_word( + uploaded_pdf_for_word, + extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, + ) diff --git a/tests/live/test_live_extract_images.py b/tests/live/test_live_extract_images.py index faaff70d..bb096ff0 100644 --- a/tests/live/test_live_extract_images.py +++ b/tests/live/test_live_extract_images.py @@ -2,7 +2,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFileBasedResponse from ..resources import get_test_resource_path @@ -25,6 +25,25 @@ def test_live_extract_images_success( assert response.input_id == uploaded.id +@pytest.mark.asyncio +async def test_live_async_extract_images_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("duckhat.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + response = await client.extract_images(uploaded, output="async-images") + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + assert response.output_file.name.startswith("async-images") + assert response.input_id == uploaded.id + + def test_live_extract_images_invalid_pages( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -40,3 +59,21 @@ def test_live_extract_images_invalid_pages( uploaded, extra_body={"pages": "last-1"}, ) + + +@pytest.mark.asyncio +async def test_live_async_extract_images_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("duckhat.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + with pytest.raises(PdfRestApiError): + await client.extract_images( + uploaded, + extra_body={"pages": "last-1"}, + ) diff --git a/tests/live/test_live_extract_pdf_text_to_file.py b/tests/live/test_live_extract_pdf_text_to_file.py index a96167f4..3c79238d 100644 --- a/tests/live/test_live_extract_pdf_text_to_file.py +++ b/tests/live/test_live_extract_pdf_text_to_file.py @@ -2,7 +2,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFileBasedResponse from ..resources import get_test_resource_path @@ -31,6 +31,32 @@ def test_live_extract_pdf_text_to_file_success( assert response.input_id == uploaded.id +@pytest.mark.asyncio +async def test_live_async_extract_pdf_text_to_file_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + response = await client.extract_pdf_text_to_file( + uploaded, + full_text="document", + preserve_line_breaks="on", + word_style="off", + word_coordinates="off", + output="async-text", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + assert response.output_file.name.startswith("async-text") + assert response.input_id == uploaded.id + + def test_live_extract_pdf_text_to_file_invalid_pages( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -46,3 +72,21 @@ def test_live_extract_pdf_text_to_file_invalid_pages( uploaded, extra_body={"pages": "last-1"}, ) + + +@pytest.mark.asyncio +async def test_live_async_extract_pdf_text_to_file_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + with pytest.raises(PdfRestApiError): + await client.extract_pdf_text_to_file( + uploaded, + extra_body={"pages": "last-1"}, + ) diff --git a/tests/live/test_live_flatten_pdf_forms.py b/tests/live/test_live_flatten_pdf_forms.py index c6ad7fdb..eeed4c16 100644 --- a/tests/live/test_live_flatten_pdf_forms.py +++ b/tests/live/test_live_flatten_pdf_forms.py @@ -2,7 +2,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile from ..resources import get_test_resource_path @@ -54,6 +54,28 @@ def test_live_flatten_pdf_forms( assert output_file.name.endswith(".pdf") +@pytest.mark.asyncio +async def test_live_async_flatten_pdf_forms_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_with_forms: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.flatten_pdf_forms( + uploaded_pdf_with_forms, + output="async-flattened", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async-flattened") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_with_forms.id) + + def test_live_flatten_pdf_forms_invalid_file_id( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -70,3 +92,20 @@ def test_live_flatten_pdf_forms_invalid_file_id( uploaded_pdf_with_forms, extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, ) + + +@pytest.mark.asyncio +async def test_live_async_flatten_pdf_forms_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_with_forms: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError): + await client.flatten_pdf_forms( + uploaded_pdf_with_forms, + extra_body={"id": "00000000-0000-0000-0000-000000000000"}, + ) diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index 2b68edb3..1f072946 100644 --- a/tests/live/test_live_graphic_conversions.py +++ b/tests/live/test_live_graphic_conversions.py @@ -5,7 +5,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile from pdfrest.models._internal import ( BasePdfRestGraphicPayload, @@ -121,6 +121,28 @@ def uploaded_20_page_pdf( return client.files.create_from_paths([resource])[0] +@pytest.mark.asyncio +async def test_live_async_convert_to_png_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + response = await client.convert_to_png( + uploaded, + output_prefix="async-png", + resolution=150, + ) + + assert response.output_files + assert all(file_info.type == "image/png" for file_info in response.output_files) + assert str(response.input_id) == str(uploaded.id) + + @pytest.mark.parametrize( ("_endpoint_label", "spec", "color_model"), _valid_color_cases(), @@ -269,6 +291,25 @@ def test_live_graphic_invalid_smoothing( ) +@pytest.mark.asyncio +async def test_live_async_graphic_invalid_smoothing( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + with pytest.raises(PdfRestApiError): + await client.convert_to_png( + uploaded, + smoothing="none", + extra_body={"smoothing": "super-smooth"}, + ) + + @pytest.mark.parametrize( ("page_range", "expect_success"), [ diff --git a/tests/live/test_live_linearize_pdf.py b/tests/live/test_live_linearize_pdf.py index 59612691..09a671b4 100644 --- a/tests/live/test_live_linearize_pdf.py +++ b/tests/live/test_live_linearize_pdf.py @@ -2,7 +2,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile from ..resources import get_test_resource_path @@ -54,6 +54,28 @@ def test_live_linearize_pdf( assert output_file.name.endswith(".pdf") +@pytest.mark.asyncio +async def test_live_async_linearize_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_linearize: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.linearize_pdf( + uploaded_pdf_for_linearize, + output="async-linearized", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async-linearized") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_linearize.id) + + def test_live_linearize_pdf_invalid_file_id( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -70,3 +92,20 @@ def test_live_linearize_pdf_invalid_file_id( uploaded_pdf_for_linearize, extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, ) + + +@pytest.mark.asyncio +async def test_live_async_linearize_pdf_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_linearize: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError): + await client.linearize_pdf( + uploaded_pdf_for_linearize, + extra_body={"id": "00000000-0000-0000-0000-000000000000"}, + ) diff --git a/tests/live/test_live_ocr_pdf.py b/tests/live/test_live_ocr_pdf.py index 065a7022..96790f26 100644 --- a/tests/live/test_live_ocr_pdf.py +++ b/tests/live/test_live_ocr_pdf.py @@ -2,7 +2,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFileBasedResponse from ..resources import get_test_resource_path @@ -26,6 +26,25 @@ def test_live_ocr_pdf_success( assert response.input_id == uploaded.id +@pytest.mark.asyncio +async def test_live_async_ocr_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + response = await client.ocr_pdf(uploaded, output="async-ocr") + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + assert response.output_file.name.startswith("async-ocr") + assert response.input_id == uploaded.id + + def test_live_ocr_pdf_invalid_pages( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -41,3 +60,21 @@ def test_live_ocr_pdf_invalid_pages( uploaded, extra_body={"pages": "last-1"}, ) + + +@pytest.mark.asyncio +async def test_live_async_ocr_pdf_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + with pytest.raises(PdfRestApiError): + await client.ocr_pdf( + uploaded, + extra_body={"pages": "last-1"}, + ) diff --git a/tests/live/test_live_pdf_redactions.py b/tests/live/test_live_pdf_redactions.py index 796785a1..8e425daa 100644 --- a/tests/live/test_live_pdf_redactions.py +++ b/tests/live/test_live_pdf_redactions.py @@ -4,7 +4,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile from pdfrest.types import PdfRedactionInstruction, PdfRedactionPreset @@ -135,6 +135,36 @@ def test_live_redaction_preview_and_apply_multiple( assert final_file.type == "application/pdf" +@pytest.mark.asyncio +async def test_live_async_redaction_preview_and_apply( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_redaction: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + preview = await client.preview_redactions( + uploaded_pdf_for_redaction, + redactions=[{"type": "literal", "value": "quick brown fox"}], + output="async-redaction-preview", + ) + + preview_file = preview.output_files[0] + applied = await client.apply_redactions( + preview_file, + output="async-redaction-final", + ) + + assert preview.output_files + assert preview_file.name.endswith("async-redaction-preview.pdf") + assert applied.output_files + final_file = applied.output_files[0] + assert final_file.name.endswith("async-redaction-final.pdf") + assert final_file.type == "application/pdf" + + @pytest.mark.parametrize( "extra_body", [ @@ -167,3 +197,21 @@ def test_live_redactions_invalid_payloads( preview_file = preview.output_files[0] with pytest.raises(PdfRestApiError): client.apply_redactions(preview_file, extra_body=extra_body) + + +@pytest.mark.asyncio +async def test_live_async_redactions_invalid_payloads( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_redaction: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError): + await client.preview_redactions( + uploaded_pdf_for_redaction, + redactions=[{"type": "literal", "value": "placeholder"}], + extra_body={"rgb_color": "-1,-1,-1"}, + ) diff --git a/tests/live/test_live_summarize_pdf_text.py b/tests/live/test_live_summarize_pdf_text.py index 27920fd8..69f6252f 100644 --- a/tests/live/test_live_summarize_pdf_text.py +++ b/tests/live/test_live_summarize_pdf_text.py @@ -2,7 +2,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFileBasedResponse, SummarizePdfTextResponse from ..resources import get_test_resource_path @@ -51,6 +51,28 @@ def test_live_summarize_pdf_text_to_file_success( assert response.input_id == uploaded.id +@pytest.mark.asyncio +async def test_live_async_summarize_pdf_text_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + response = await client.summarize_pdf_text( + uploaded, + target_word_count=30, + summary_format="overview", + ) + + assert isinstance(response, SummarizePdfTextResponse) + assert response.summary + assert response.input_id == uploaded.id + + def test_live_summarize_pdf_text_invalid_format( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -66,3 +88,21 @@ def test_live_summarize_pdf_text_invalid_format( uploaded, extra_body={"summary_format": "invalid-style"}, ) + + +@pytest.mark.asyncio +async def test_live_async_summarize_pdf_text_invalid_format( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + with pytest.raises(PdfRestApiError, match="error"): + await client.summarize_pdf_text( + uploaded, + extra_body={"summary_format": "invalid-style"}, + ) diff --git a/tests/live/test_live_translate_pdf_text.py b/tests/live/test_live_translate_pdf_text.py index 8c82039d..1211b814 100644 --- a/tests/live/test_live_translate_pdf_text.py +++ b/tests/live/test_live_translate_pdf_text.py @@ -2,7 +2,7 @@ import pytest -from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import ( TranslatePdfTextFileResponse, TranslatePdfTextResponse, @@ -34,6 +34,29 @@ def test_live_translate_pdf_text_success( assert response.input_id == uploaded.id +@pytest.mark.asyncio +async def test_live_async_translate_pdf_text_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + response = await client.translate_pdf_text( + uploaded, + output_language="es", + output_format="plaintext", + ) + + assert isinstance(response, TranslatePdfTextResponse) + assert response.translated_text + assert response.output_language == "es" + assert response.input_id == uploaded.id + + def test_live_translate_pdf_text_invalid_output_format( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -52,6 +75,25 @@ def test_live_translate_pdf_text_invalid_output_format( ) +@pytest.mark.asyncio +async def test_live_async_translate_pdf_text_invalid_output_format( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + with pytest.raises(PdfRestApiError, match="error"): + await client.translate_pdf_text( + uploaded, + output_language="de", + extra_body={"output_format": "invalid-format"}, + ) + + def test_live_translate_pdf_text_file_success( pdfrest_api_key: str, pdfrest_live_base_url: str, From 57868d231f3ae45f786d9274da2cb1fa492d0e2f Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 7 Jan 2026 10:23:07 -0600 Subject: [PATCH 38/84] Add additional assertions to live tests for new tools - Now evaluates: - File names - MIME types - File sizes - Warnings in response - Input ID Assisted-by: Codex --- tests/live/test_live_convert_to_excel.py | 4 ++++ tests/live/test_live_convert_to_markdown.py | 11 +++++++++- tests/live/test_live_convert_to_powerpoint.py | 4 ++++ .../test_live_convert_xfa_to_acroforms.py | 4 ++++ tests/live/test_live_extract_images.py | 22 ++++++++++++++++--- .../test_live_extract_pdf_text_to_file.py | 11 +++++++++- tests/live/test_live_flatten_annotations.py | 4 ++++ .../live/test_live_flatten_transparencies.py | 4 ++++ tests/live/test_live_linearize_pdf.py | 4 ++++ tests/live/test_live_ocr_pdf.py | 9 +++++++- tests/live/test_live_rasterize_pdf.py | 4 ++++ tests/live/test_live_summarize_pdf_text.py | 6 ++++- tests/live/test_live_translate_pdf_text.py | 5 +++++ 13 files changed, 85 insertions(+), 7 deletions(-) diff --git a/tests/live/test_live_convert_to_excel.py b/tests/live/test_live_convert_to_excel.py index 26068b28..05c95699 100644 --- a/tests/live/test_live_convert_to_excel.py +++ b/tests/live/test_live_convert_to_excel.py @@ -50,6 +50,8 @@ def test_live_convert_to_excel_success( output_file.type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) + assert output_file.size > 0 + assert response.warning is None assert str(response.input_id) == str(uploaded_pdf_for_excel.id) if output_name is not None: assert output_file.name.startswith(output_name) @@ -76,6 +78,8 @@ async def test_live_async_convert_to_excel_success( output_file.type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) + assert output_file.size > 0 + assert response.warning is None assert str(response.input_id) == str(uploaded_pdf_for_excel.id) diff --git a/tests/live/test_live_convert_to_markdown.py b/tests/live/test_live_convert_to_markdown.py index 23aa0e1c..8219cc2f 100644 --- a/tests/live/test_live_convert_to_markdown.py +++ b/tests/live/test_live_convert_to_markdown.py @@ -22,6 +22,11 @@ def test_live_convert_to_markdown_success( assert isinstance(response, PdfRestFileBasedResponse) assert response.output_files + output_file = response.output_file + assert output_file.name.endswith(".md") + assert output_file.type == "text/markdown" + assert output_file.size > 0 + assert response.warning is None assert response.input_id == uploaded.id @@ -40,7 +45,11 @@ async def test_live_async_convert_to_markdown_success( assert isinstance(response, PdfRestFileBasedResponse) assert response.output_files - assert response.output_file.name.startswith("async-md") + output_file = response.output_file + assert output_file.name.startswith("async-md") + assert output_file.type == "text/markdown" + assert output_file.size > 0 + assert response.warning is None assert response.input_id == uploaded.id diff --git a/tests/live/test_live_convert_to_powerpoint.py b/tests/live/test_live_convert_to_powerpoint.py index f46da580..a7de4a00 100644 --- a/tests/live/test_live_convert_to_powerpoint.py +++ b/tests/live/test_live_convert_to_powerpoint.py @@ -50,6 +50,8 @@ def test_live_convert_to_powerpoint_success( output_file.type == "application/vnd.openxmlformats-officedocument.presentationml.presentation" ) + assert output_file.size > 0 + assert response.warning is None assert str(response.input_id) == str(uploaded_pdf_for_powerpoint.id) if output_name is not None: assert output_file.name.startswith(output_name) @@ -78,6 +80,8 @@ async def test_live_async_convert_to_powerpoint_success( output_file.type == "application/vnd.openxmlformats-officedocument.presentationml.presentation" ) + assert output_file.size > 0 + assert response.warning is None assert str(response.input_id) == str(uploaded_pdf_for_powerpoint.id) diff --git a/tests/live/test_live_convert_xfa_to_acroforms.py b/tests/live/test_live_convert_xfa_to_acroforms.py index 428fccb2..a3fe020a 100644 --- a/tests/live/test_live_convert_xfa_to_acroforms.py +++ b/tests/live/test_live_convert_xfa_to_acroforms.py @@ -47,6 +47,8 @@ def test_live_convert_xfa_to_acroforms_success( 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 str(response.input_id) == str(uploaded_pdf_for_acroforms.id) if output_name is not None: assert output_file.name.startswith(output_name) @@ -90,6 +92,8 @@ async def test_live_async_convert_xfa_to_acroforms_success( output_file = response.output_file assert output_file.name.startswith("async") assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None assert str(response.input_id) == str(uploaded_pdf_for_acroforms.id) diff --git a/tests/live/test_live_extract_images.py b/tests/live/test_live_extract_images.py index bb096ff0..211e8591 100644 --- a/tests/live/test_live_extract_images.py +++ b/tests/live/test_live_extract_images.py @@ -21,7 +21,15 @@ def test_live_extract_images_success( response = client.extract_images(uploaded) assert isinstance(response, PdfRestFileBasedResponse) - assert response.output_files + output_files = response.output_files + assert output_files + assert all(file.name for file in output_files) + assert all( + file.type and (file.type.startswith("image/") or file.type == "application/zip") + for file in output_files + ) + assert all(file.size > 0 for file in output_files) + assert response.warning is None assert response.input_id == uploaded.id @@ -39,8 +47,16 @@ async def test_live_async_extract_images_success( response = await client.extract_images(uploaded, output="async-images") assert isinstance(response, PdfRestFileBasedResponse) - assert response.output_files - assert response.output_file.name.startswith("async-images") + output_files = response.output_files + assert output_files + assert output_files[0].name.startswith("async-images") + assert all(file.name for file in output_files) + assert all( + file.type and (file.type.startswith("image/") or file.type == "application/zip") + for file in output_files + ) + assert all(file.size > 0 for file in output_files) + assert response.warning is None assert response.input_id == uploaded.id diff --git a/tests/live/test_live_extract_pdf_text_to_file.py b/tests/live/test_live_extract_pdf_text_to_file.py index 3c79238d..f9c10e74 100644 --- a/tests/live/test_live_extract_pdf_text_to_file.py +++ b/tests/live/test_live_extract_pdf_text_to_file.py @@ -28,6 +28,11 @@ def test_live_extract_pdf_text_to_file_success( assert isinstance(response, PdfRestFileBasedResponse) assert response.output_files + output_file = response.output_file + assert output_file.name.endswith(".txt") + assert output_file.type == "text/plain" + assert output_file.size > 0 + assert response.warning is None assert response.input_id == uploaded.id @@ -53,7 +58,11 @@ async def test_live_async_extract_pdf_text_to_file_success( assert isinstance(response, PdfRestFileBasedResponse) assert response.output_files - assert response.output_file.name.startswith("async-text") + output_file = response.output_file + assert output_file.name.startswith("async-text") + assert output_file.type == "text/plain" + assert output_file.size > 0 + assert response.warning is None assert response.input_id == uploaded.id diff --git a/tests/live/test_live_flatten_annotations.py b/tests/live/test_live_flatten_annotations.py index 9a669fe2..54f4f021 100644 --- a/tests/live/test_live_flatten_annotations.py +++ b/tests/live/test_live_flatten_annotations.py @@ -47,6 +47,8 @@ def test_live_flatten_annotations_success( 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 str(response.input_id) == str(uploaded_pdf_for_annotations.id) if output_name is not None: assert output_file.name.startswith(output_name) @@ -72,6 +74,8 @@ async def test_live_async_flatten_annotations_success( output_file = response.output_file assert output_file.name.startswith("async") assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None assert str(response.input_id) == str(uploaded_pdf_for_annotations.id) diff --git a/tests/live/test_live_flatten_transparencies.py b/tests/live/test_live_flatten_transparencies.py index f7a8bb49..c8a98e93 100644 --- a/tests/live/test_live_flatten_transparencies.py +++ b/tests/live/test_live_flatten_transparencies.py @@ -50,6 +50,8 @@ def test_live_flatten_transparencies_success( 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 str(response.input_id) == str(uploaded_pdf_for_transparencies.id) if output_name is not None: assert output_file.name.startswith(output_name) @@ -75,6 +77,8 @@ async def test_live_async_flatten_transparencies_success( output_file = response.output_file assert output_file.name.startswith("async") assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None assert str(response.input_id) == str(uploaded_pdf_for_transparencies.id) diff --git a/tests/live/test_live_linearize_pdf.py b/tests/live/test_live_linearize_pdf.py index 09a671b4..f0dc7359 100644 --- a/tests/live/test_live_linearize_pdf.py +++ b/tests/live/test_live_linearize_pdf.py @@ -47,6 +47,8 @@ def test_live_linearize_pdf( 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 str(response.input_id) == str(uploaded_pdf_for_linearize.id) if output_name is not None: assert output_file.name.startswith(output_name) @@ -73,6 +75,8 @@ async def test_live_async_linearize_pdf( output_file = response.output_file assert output_file.name.startswith("async-linearized") assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None assert str(response.input_id) == str(uploaded_pdf_for_linearize.id) diff --git a/tests/live/test_live_ocr_pdf.py b/tests/live/test_live_ocr_pdf.py index 96790f26..816bf697 100644 --- a/tests/live/test_live_ocr_pdf.py +++ b/tests/live/test_live_ocr_pdf.py @@ -22,7 +22,11 @@ def test_live_ocr_pdf_success( assert isinstance(response, PdfRestFileBasedResponse) assert response.output_files - assert response.output_file.id + output_file = response.output_file + assert output_file.name.endswith(".pdf") + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None assert response.input_id == uploaded.id @@ -42,6 +46,9 @@ async def test_live_async_ocr_pdf_success( assert isinstance(response, PdfRestFileBasedResponse) assert response.output_files assert response.output_file.name.startswith("async-ocr") + assert response.output_file.type == "application/pdf" + assert response.output_file.size > 0 + assert response.warning is None assert response.input_id == uploaded.id diff --git a/tests/live/test_live_rasterize_pdf.py b/tests/live/test_live_rasterize_pdf.py index 70f41c20..45e9402f 100644 --- a/tests/live/test_live_rasterize_pdf.py +++ b/tests/live/test_live_rasterize_pdf.py @@ -47,6 +47,8 @@ def test_live_rasterize_pdf_success( 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 str(response.input_id) == str(uploaded_pdf_for_rasterize.id) if output_name is not None: assert output_file.name.startswith(output_name) @@ -72,6 +74,8 @@ async def test_live_async_rasterize_pdf_success( output_file = response.output_file assert output_file.name.startswith("async") assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None assert str(response.input_id) == str(uploaded_pdf_for_rasterize.id) diff --git a/tests/live/test_live_summarize_pdf_text.py b/tests/live/test_live_summarize_pdf_text.py index 69f6252f..e846da8c 100644 --- a/tests/live/test_live_summarize_pdf_text.py +++ b/tests/live/test_live_summarize_pdf_text.py @@ -47,7 +47,11 @@ def test_live_summarize_pdf_text_to_file_success( assert isinstance(response, PdfRestFileBasedResponse) assert response.output_files - assert response.output_file.id + output_file = response.output_file + assert output_file.name.endswith(".md") + assert output_file.type == "text/markdown" + assert output_file.size > 0 + assert response.warning is None assert response.input_id == uploaded.id diff --git a/tests/live/test_live_translate_pdf_text.py b/tests/live/test_live_translate_pdf_text.py index 1211b814..eea4e7b8 100644 --- a/tests/live/test_live_translate_pdf_text.py +++ b/tests/live/test_live_translate_pdf_text.py @@ -112,6 +112,11 @@ def test_live_translate_pdf_text_file_success( assert isinstance(response, TranslatePdfTextFileResponse) assert response.output_files + output_file = response.output_file + assert output_file.name.endswith(".txt") + assert output_file.type == "text/plain" + assert output_file.size > 0 + assert response.warning is None assert response.output_language == "fr" assert response.source_languages assert response.input_id == uploaded.id From 73d94afc2c206cef0ca419244b62fabf6d46b61d Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 7 Jan 2026 12:05:04 -0600 Subject: [PATCH 39/84] Add match= expressions to live tests Assisted-by: Codex --- tests/live/test_live_compress_pdf.py | 4 ++-- tests/live/test_live_convert_to_excel.py | 4 ++-- tests/live/test_live_convert_to_markdown.py | 4 ++-- tests/live/test_live_convert_to_pdfx.py | 4 ++-- tests/live/test_live_convert_to_powerpoint.py | 4 ++-- tests/live/test_live_convert_to_word.py | 4 ++-- tests/live/test_live_convert_xfa_to_acroforms.py | 4 ++-- tests/live/test_live_delete.py | 4 ++-- tests/live/test_live_extract_images.py | 4 ++-- tests/live/test_live_extract_pdf_text_to_file.py | 4 ++-- tests/live/test_live_flatten_annotations.py | 4 ++-- tests/live/test_live_flatten_pdf_forms.py | 4 ++-- tests/live/test_live_flatten_transparencies.py | 4 ++-- tests/live/test_live_graphic_conversions.py | 12 ++++++------ tests/live/test_live_linearize_pdf.py | 4 ++-- tests/live/test_live_ocr_pdf.py | 4 ++-- tests/live/test_live_pdf_info.py | 2 +- tests/live/test_live_pdf_redactions.py | 6 +++--- tests/live/test_live_pdf_split_merge.py | 8 ++++---- tests/live/test_live_rasterize_pdf.py | 4 ++-- tests/live/test_live_summarize_pdf_text.py | 4 ++-- tests/live/test_live_translate_pdf_text.py | 4 ++-- 22 files changed, 50 insertions(+), 50 deletions(-) diff --git a/tests/live/test_live_compress_pdf.py b/tests/live/test_live_compress_pdf.py index 6ee8b365..0b3cdf66 100644 --- a/tests/live/test_live_compress_pdf.py +++ b/tests/live/test_live_compress_pdf.py @@ -158,7 +158,7 @@ def test_live_compress_pdf_invalid_level( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)compression"), ): client.compress_pdf( uploaded_pdf_for_compression, @@ -177,7 +177,7 @@ async def test_live_async_compress_pdf_invalid_level( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)compression"): await client.compress_pdf( uploaded_pdf_for_compression, compression_level="low", diff --git a/tests/live/test_live_convert_to_excel.py b/tests/live/test_live_convert_to_excel.py index 05c95699..f592aa40 100644 --- a/tests/live/test_live_convert_to_excel.py +++ b/tests/live/test_live_convert_to_excel.py @@ -93,7 +93,7 @@ def test_live_convert_to_excel_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"), ): client.convert_to_excel( uploaded_pdf_for_excel, @@ -111,7 +111,7 @@ async def test_live_async_convert_to_excel_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"): await client.convert_to_excel( uploaded_pdf_for_excel, extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, diff --git a/tests/live/test_live_convert_to_markdown.py b/tests/live/test_live_convert_to_markdown.py index 8219cc2f..760e1798 100644 --- a/tests/live/test_live_convert_to_markdown.py +++ b/tests/live/test_live_convert_to_markdown.py @@ -63,7 +63,7 @@ def test_live_convert_to_markdown_invalid_pages( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)page"): client.convert_to_markdown( uploaded, extra_body={"pages": "last-1"}, @@ -81,7 +81,7 @@ async def test_live_async_convert_to_markdown_invalid_pages( base_url=pdfrest_live_base_url, ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)page"): await client.convert_to_markdown( uploaded, extra_body={"pages": "last-1"}, diff --git a/tests/live/test_live_convert_to_pdfx.py b/tests/live/test_live_convert_to_pdfx.py index c010798d..df0e6695 100644 --- a/tests/live/test_live_convert_to_pdfx.py +++ b/tests/live/test_live_convert_to_pdfx.py @@ -94,7 +94,7 @@ def test_live_convert_to_pdfx_invalid_output_type( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)pdf.?x"), ): client.convert_to_pdfx( uploaded_pdf_for_pdfx, @@ -113,7 +113,7 @@ async def test_live_async_convert_to_pdfx_invalid_output_type( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)pdf.?x"): await client.convert_to_pdfx( uploaded_pdf_for_pdfx, output_type="PDF/X-1a", diff --git a/tests/live/test_live_convert_to_powerpoint.py b/tests/live/test_live_convert_to_powerpoint.py index a7de4a00..8a1209a2 100644 --- a/tests/live/test_live_convert_to_powerpoint.py +++ b/tests/live/test_live_convert_to_powerpoint.py @@ -95,7 +95,7 @@ def test_live_convert_to_powerpoint_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"), ): client.convert_to_powerpoint( uploaded_pdf_for_powerpoint, @@ -113,7 +113,7 @@ async def test_live_async_convert_to_powerpoint_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"): await client.convert_to_powerpoint( uploaded_pdf_for_powerpoint, extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, diff --git a/tests/live/test_live_convert_to_word.py b/tests/live/test_live_convert_to_word.py index 1318ffa6..3ec6a334 100644 --- a/tests/live/test_live_convert_to_word.py +++ b/tests/live/test_live_convert_to_word.py @@ -92,7 +92,7 @@ def test_live_convert_to_word_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"), ): client.convert_to_word( uploaded_pdf_for_word, @@ -110,7 +110,7 @@ async def test_live_async_convert_to_word_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"): await client.convert_to_word( uploaded_pdf_for_word, extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, diff --git a/tests/live/test_live_convert_xfa_to_acroforms.py b/tests/live/test_live_convert_xfa_to_acroforms.py index a3fe020a..8e882a3b 100644 --- a/tests/live/test_live_convert_xfa_to_acroforms.py +++ b/tests/live/test_live_convert_xfa_to_acroforms.py @@ -66,7 +66,7 @@ def test_live_convert_xfa_to_acroforms_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"), ): client.convert_xfa_to_acroforms( uploaded_pdf_for_acroforms, @@ -107,7 +107,7 @@ async def test_live_async_convert_xfa_to_acroforms_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"): await client.convert_xfa_to_acroforms( uploaded_pdf_for_acroforms, extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, diff --git a/tests/live/test_live_delete.py b/tests/live/test_live_delete.py index 75727fef..52bdf6fd 100644 --- a/tests/live/test_live_delete.py +++ b/tests/live/test_live_delete.py @@ -57,7 +57,7 @@ def test_live_delete_files_invalid_id( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - with pytest.raises(ValidationError): + with pytest.raises(ValidationError, match=r"(?i)ids?"): client.files.delete(uploaded, extra_body={"ids": token_urlsafe(16)}) @@ -72,7 +72,7 @@ async def test_live_async_delete_files_invalid_id( base_url=pdfrest_live_base_url, ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] - with pytest.raises(ValidationError): + with pytest.raises(ValidationError, match=r"(?i)ids?"): await client.files.delete(uploaded, extra_body={"ids": token_urlsafe(16)}) diff --git a/tests/live/test_live_extract_images.py b/tests/live/test_live_extract_images.py index 211e8591..3410622a 100644 --- a/tests/live/test_live_extract_images.py +++ b/tests/live/test_live_extract_images.py @@ -70,7 +70,7 @@ def test_live_extract_images_invalid_pages( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)page"): client.extract_images( uploaded, extra_body={"pages": "last-1"}, @@ -88,7 +88,7 @@ async def test_live_async_extract_images_invalid_pages( base_url=pdfrest_live_base_url, ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)page"): await client.extract_images( uploaded, extra_body={"pages": "last-1"}, diff --git a/tests/live/test_live_extract_pdf_text_to_file.py b/tests/live/test_live_extract_pdf_text_to_file.py index f9c10e74..75784540 100644 --- a/tests/live/test_live_extract_pdf_text_to_file.py +++ b/tests/live/test_live_extract_pdf_text_to_file.py @@ -76,7 +76,7 @@ def test_live_extract_pdf_text_to_file_invalid_pages( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)page"): client.extract_pdf_text_to_file( uploaded, extra_body={"pages": "last-1"}, @@ -94,7 +94,7 @@ async def test_live_async_extract_pdf_text_to_file_invalid_pages( base_url=pdfrest_live_base_url, ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)page"): await client.extract_pdf_text_to_file( uploaded, extra_body={"pages": "last-1"}, diff --git a/tests/live/test_live_flatten_annotations.py b/tests/live/test_live_flatten_annotations.py index 54f4f021..b97b08b0 100644 --- a/tests/live/test_live_flatten_annotations.py +++ b/tests/live/test_live_flatten_annotations.py @@ -89,7 +89,7 @@ def test_live_flatten_annotations_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"), ): client.flatten_annotations( uploaded_pdf_for_annotations, @@ -107,7 +107,7 @@ async def test_live_async_flatten_annotations_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"): await client.flatten_annotations( uploaded_pdf_for_annotations, extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, diff --git a/tests/live/test_live_flatten_pdf_forms.py b/tests/live/test_live_flatten_pdf_forms.py index eeed4c16..5bff7304 100644 --- a/tests/live/test_live_flatten_pdf_forms.py +++ b/tests/live/test_live_flatten_pdf_forms.py @@ -86,7 +86,7 @@ def test_live_flatten_pdf_forms_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"), ): client.flatten_pdf_forms( uploaded_pdf_with_forms, @@ -104,7 +104,7 @@ async def test_live_async_flatten_pdf_forms_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"): await client.flatten_pdf_forms( uploaded_pdf_with_forms, extra_body={"id": "00000000-0000-0000-0000-000000000000"}, diff --git a/tests/live/test_live_flatten_transparencies.py b/tests/live/test_live_flatten_transparencies.py index c8a98e93..7da1eb40 100644 --- a/tests/live/test_live_flatten_transparencies.py +++ b/tests/live/test_live_flatten_transparencies.py @@ -92,7 +92,7 @@ def test_live_flatten_transparencies_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"), ): client.flatten_transparencies( uploaded_pdf_for_transparencies, @@ -110,7 +110,7 @@ async def test_live_async_flatten_transparencies_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"): await client.flatten_transparencies( uploaded_pdf_for_transparencies, extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index 1f072946..3a09af9d 100644 --- a/tests/live/test_live_graphic_conversions.py +++ b/tests/live/test_live_graphic_conversions.py @@ -190,7 +190,7 @@ def test_live_graphic_invalid_color_model( uploaded = client.files.create_from_paths([resource])[0] client_method = getattr(client, spec.method_name) resolution = _resolution_bounds(payload_model)[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)color"): client_method( uploaded, resolution=resolution, @@ -235,7 +235,7 @@ def test_live_graphic_resolution_bounds( if should_raise: call_kwargs["extra_body"] = {"resolution": base_resolution + offset} - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)resolution"): client_method(uploaded, **call_kwargs) else: response = client_method(uploaded, **call_kwargs) @@ -283,7 +283,7 @@ def test_live_graphic_invalid_smoothing( ) as client: uploaded = client.files.create_from_paths([resource])[0] client_method = getattr(client, spec.method_name) - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)smooth"): client_method( uploaded, smoothing="none", @@ -302,7 +302,7 @@ async def test_live_async_graphic_invalid_smoothing( base_url=pdfrest_live_base_url, ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)smooth"): await client.convert_to_png( uploaded, smoothing="none", @@ -357,7 +357,7 @@ def test_live_png_page_range_variants( ) assert str(response.input_id) == str(uploaded_20_page_pdf.id) else: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)page"): client.convert_to_png( uploaded_20_page_pdf, output_prefix=f"live-range-{case_id}", @@ -389,7 +389,7 @@ def test_live_png_page_range_invalid_overrides( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)page"), ): client.convert_to_png( uploaded_20_page_pdf, diff --git a/tests/live/test_live_linearize_pdf.py b/tests/live/test_live_linearize_pdf.py index f0dc7359..523ea0d5 100644 --- a/tests/live/test_live_linearize_pdf.py +++ b/tests/live/test_live_linearize_pdf.py @@ -90,7 +90,7 @@ def test_live_linearize_pdf_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"), ): client.linearize_pdf( uploaded_pdf_for_linearize, @@ -108,7 +108,7 @@ async def test_live_async_linearize_pdf_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"): await client.linearize_pdf( uploaded_pdf_for_linearize, extra_body={"id": "00000000-0000-0000-0000-000000000000"}, diff --git a/tests/live/test_live_ocr_pdf.py b/tests/live/test_live_ocr_pdf.py index 816bf697..5109bad5 100644 --- a/tests/live/test_live_ocr_pdf.py +++ b/tests/live/test_live_ocr_pdf.py @@ -62,7 +62,7 @@ def test_live_ocr_pdf_invalid_pages( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)page"): client.ocr_pdf( uploaded, extra_body={"pages": "last-1"}, @@ -80,7 +80,7 @@ async def test_live_async_ocr_pdf_invalid_pages( base_url=pdfrest_live_base_url, ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)page"): await client.ocr_pdf( uploaded, extra_body={"pages": "last-1"}, diff --git a/tests/live/test_live_pdf_info.py b/tests/live/test_live_pdf_info.py index 977fe87d..7ec91828 100644 --- a/tests/live/test_live_pdf_info.py +++ b/tests/live/test_live_pdf_info.py @@ -111,7 +111,7 @@ def test_live_pdf_info_invalid_query( PdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)quer"), ): client.query_pdf_info( uploaded_pdf, diff --git a/tests/live/test_live_pdf_redactions.py b/tests/live/test_live_pdf_redactions.py index 8e425daa..5473e428 100644 --- a/tests/live/test_live_pdf_redactions.py +++ b/tests/live/test_live_pdf_redactions.py @@ -183,7 +183,7 @@ def test_live_redactions_invalid_payloads( base_url=pdfrest_live_base_url, ) as client: if "redactions" in extra_body: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)redaction"): client.preview_redactions( uploaded_pdf_for_redaction, redactions=[{"type": "literal", "value": "placeholder"}], @@ -195,7 +195,7 @@ def test_live_redactions_invalid_payloads( redactions=[{"type": "literal", "value": "placeholder"}], ) preview_file = preview.output_files[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)rgb"): client.apply_redactions(preview_file, extra_body=extra_body) @@ -209,7 +209,7 @@ async def test_live_async_redactions_invalid_payloads( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)rgb"): await client.preview_redactions( uploaded_pdf_for_redaction, redactions=[{"type": "literal", "value": "placeholder"}], diff --git a/tests/live/test_live_pdf_split_merge.py b/tests/live/test_live_pdf_split_merge.py index 979f7b1e..5a58912c 100644 --- a/tests/live/test_live_pdf_split_merge.py +++ b/tests/live/test_live_pdf_split_merge.py @@ -198,7 +198,7 @@ def test_live_split_pdf_invalid_pages( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)page"), ): client.split_pdf( split_source, @@ -270,7 +270,7 @@ def test_live_merge_pdfs_invalid_pages( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)page"), ): client.merge_pdfs( sources, @@ -373,7 +373,7 @@ def test_live_split_pdf_page_range_variants( output_pages = client.query_pdf_info(response.output_files[0]).page_count assert output_pages == len(expected_pages) else: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)page"): client.split_pdf( split_source, page_groups=[selection if not requires_override else "1"], @@ -446,7 +446,7 @@ def test_live_merge_pdf_page_range_variants( output_info = client.query_pdf_info(response.output_file) assert output_info.page_count == expected_total_pages else: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)page"): client.merge_pdfs( sources, output_prefix=f"live-merge-range-{case_id}", diff --git a/tests/live/test_live_rasterize_pdf.py b/tests/live/test_live_rasterize_pdf.py index 45e9402f..df7cb260 100644 --- a/tests/live/test_live_rasterize_pdf.py +++ b/tests/live/test_live_rasterize_pdf.py @@ -89,7 +89,7 @@ def test_live_rasterize_pdf_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"), ): client.rasterize_pdf( uploaded_pdf_for_rasterize, @@ -107,7 +107,7 @@ async def test_live_async_rasterize_pdf_invalid_file_id( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError): + with pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"): await client.rasterize_pdf( uploaded_pdf_for_rasterize, extra_body={"id": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, diff --git a/tests/live/test_live_summarize_pdf_text.py b/tests/live/test_live_summarize_pdf_text.py index e846da8c..be8fb802 100644 --- a/tests/live/test_live_summarize_pdf_text.py +++ b/tests/live/test_live_summarize_pdf_text.py @@ -87,7 +87,7 @@ def test_live_summarize_pdf_text_invalid_format( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - with pytest.raises(PdfRestApiError, match="error"): + with pytest.raises(PdfRestApiError, match=r"(?i)summary"): client.summarize_pdf_text( uploaded, extra_body={"summary_format": "invalid-style"}, @@ -105,7 +105,7 @@ async def test_live_async_summarize_pdf_text_invalid_format( base_url=pdfrest_live_base_url, ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] - with pytest.raises(PdfRestApiError, match="error"): + with pytest.raises(PdfRestApiError, match=r"(?i)summary"): await client.summarize_pdf_text( uploaded, extra_body={"summary_format": "invalid-style"}, diff --git a/tests/live/test_live_translate_pdf_text.py b/tests/live/test_live_translate_pdf_text.py index eea4e7b8..8824ff0d 100644 --- a/tests/live/test_live_translate_pdf_text.py +++ b/tests/live/test_live_translate_pdf_text.py @@ -67,7 +67,7 @@ def test_live_translate_pdf_text_invalid_output_format( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - with pytest.raises(PdfRestApiError, match="error"): + with pytest.raises(PdfRestApiError, match=r"(?i)output\s*format"): client.translate_pdf_text( uploaded, output_language="es", @@ -86,7 +86,7 @@ async def test_live_async_translate_pdf_text_invalid_output_format( base_url=pdfrest_live_base_url, ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] - with pytest.raises(PdfRestApiError, match="error"): + with pytest.raises(PdfRestApiError, match=r"(?i)output\s*format"): await client.translate_pdf_text( uploaded, output_language="de", From 34fc14e027626ea8445507016e6fef971fd1b780 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 7 Jan 2026 14:04:48 -0600 Subject: [PATCH 40/84] Fix Translate PDF test regex matches Assisted-by: Codex --- tests/live/test_live_translate_pdf_text.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/live/test_live_translate_pdf_text.py b/tests/live/test_live_translate_pdf_text.py index 8824ff0d..00701242 100644 --- a/tests/live/test_live_translate_pdf_text.py +++ b/tests/live/test_live_translate_pdf_text.py @@ -67,7 +67,10 @@ def test_live_translate_pdf_text_invalid_output_format( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - with pytest.raises(PdfRestApiError, match=r"(?i)output\s*format"): + with pytest.raises( + PdfRestApiError, + match=r"invalid-format is not a valid input for 'output_format'", + ): client.translate_pdf_text( uploaded, output_language="es", @@ -86,7 +89,10 @@ async def test_live_async_translate_pdf_text_invalid_output_format( base_url=pdfrest_live_base_url, ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] - with pytest.raises(PdfRestApiError, match=r"(?i)output\s*format"): + with pytest.raises( + PdfRestApiError, + match=r"invalid-format is not a valid input for 'output_format'", + ): await client.translate_pdf_text( uploaded, output_language="de", From 906a905654baa2320729f482502ca5c6ec58dacd Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 7 Jan 2026 14:48:49 -0600 Subject: [PATCH 41/84] Fix expected file format from Extract Text Assisted-by: Codex --- tests/live/test_live_extract_pdf_text_to_file.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/live/test_live_extract_pdf_text_to_file.py b/tests/live/test_live_extract_pdf_text_to_file.py index 75784540..d6e58652 100644 --- a/tests/live/test_live_extract_pdf_text_to_file.py +++ b/tests/live/test_live_extract_pdf_text_to_file.py @@ -29,8 +29,8 @@ def test_live_extract_pdf_text_to_file_success( assert isinstance(response, PdfRestFileBasedResponse) assert response.output_files output_file = response.output_file - assert output_file.name.endswith(".txt") - assert output_file.type == "text/plain" + assert output_file.name.endswith(".json") + assert output_file.type == "application/json" assert output_file.size > 0 assert response.warning is None assert response.input_id == uploaded.id @@ -60,7 +60,8 @@ async def test_live_async_extract_pdf_text_to_file_success( assert response.output_files output_file = response.output_file assert output_file.name.startswith("async-text") - assert output_file.type == "text/plain" + assert output_file.name.endswith(".json") + assert output_file.type == "application/json" assert output_file.size > 0 assert response.warning is None assert response.input_id == uploaded.id From 4050ab0f6b76345ac5fd276b7ddfcf887095ec44 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 7 Jan 2026 15:21:27 -0600 Subject: [PATCH 42/84] Convert to PNG live tests: Fix expected error Assisted-by: Codex --- tests/live/test_live_graphic_conversions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index 3a09af9d..a78f8d0f 100644 --- a/tests/live/test_live_graphic_conversions.py +++ b/tests/live/test_live_graphic_conversions.py @@ -389,7 +389,10 @@ def test_live_png_page_range_invalid_overrides( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError, match=r"(?i)page"), + pytest.raises( + PdfRestApiError, + match=r"There was an issue processing your file\. Validate all fields and try again\.", + ), ): client.convert_to_png( uploaded_20_page_pdf, From 76d5f8830f57bf8de5c4d4ea1e4fec7769523126 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 7 Jan 2026 15:38:11 -0600 Subject: [PATCH 43/84] Redact PDF live test: Fix expected error message Assisted-by: Codex --- tests/live/test_live_pdf_redactions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/live/test_live_pdf_redactions.py b/tests/live/test_live_pdf_redactions.py index 5473e428..3fda6d42 100644 --- a/tests/live/test_live_pdf_redactions.py +++ b/tests/live/test_live_pdf_redactions.py @@ -183,7 +183,13 @@ def test_live_redactions_invalid_payloads( base_url=pdfrest_live_base_url, ) as client: if "redactions" in extra_body: - with pytest.raises(PdfRestApiError, match=r"(?i)redaction"): + with pytest.raises( + PdfRestApiError, + match=( + r"The JSON data provided is not properly formatted\. Please check " + r"your syntax and try again\." + ), + ): client.preview_redactions( uploaded_pdf_for_redaction, redactions=[{"type": "literal", "value": "placeholder"}], From 34ed3f138b093708a927d94db88d54b4c5a46b6f Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 7 Jan 2026 16:39:26 -0600 Subject: [PATCH 44/84] Convert XFA: Allow `warning` in response Assisted-by: Codex --- .../test_live_convert_xfa_to_acroforms.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/live/test_live_convert_xfa_to_acroforms.py b/tests/live/test_live_convert_xfa_to_acroforms.py index 8e882a3b..dba38304 100644 --- a/tests/live/test_live_convert_xfa_to_acroforms.py +++ b/tests/live/test_live_convert_xfa_to_acroforms.py @@ -7,6 +7,10 @@ from ..resources import get_test_resource_path +WARNING_NO_XFA_FORMS = ( + "No XFA forms were detected in the input PDF. No output was produced." +) + @pytest.fixture(scope="module") def uploaded_pdf_for_acroforms( @@ -44,12 +48,16 @@ def test_live_convert_xfa_to_acroforms_success( ) as client: response = client.convert_xfa_to_acroforms(uploaded_pdf_for_acroforms, **kwargs) + assert str(response.input_id) == str(uploaded_pdf_for_acroforms.id) + if response.warning is not None: + assert response.warning == WARNING_NO_XFA_FORMS + assert response.output_files == [] + return + 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 str(response.input_id) == str(uploaded_pdf_for_acroforms.id) if output_name is not None: assert output_file.name.startswith(output_name) else: @@ -88,13 +96,17 @@ async def test_live_async_convert_xfa_to_acroforms_success( uploaded_pdf_for_acroforms, output="async" ) + assert str(response.input_id) == str(uploaded_pdf_for_acroforms.id) + if response.warning is not None: + assert response.warning == WARNING_NO_XFA_FORMS + assert response.output_files == [] + return + assert response.output_files output_file = response.output_file assert output_file.name.startswith("async") assert output_file.type == "application/pdf" assert output_file.size > 0 - assert response.warning is None - assert str(response.input_id) == str(uploaded_pdf_for_acroforms.id) @pytest.mark.asyncio From ff00e2271395b73bde98147c8ba175d641119670 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 7 Jan 2026 16:49:43 -0600 Subject: [PATCH 45/84] Add Convert to PDF/A (Archive PDF) and tests Assisted-by: Codex --- src/pdfrest/client.py | 62 +++++ src/pdfrest/models/_internal.py | 33 +++ src/pdfrest/types/__init__.py | 2 + src/pdfrest/types/public.py | 2 + tests/live/test_live_convert_to_pdfa.py | 147 +++++++++++ tests/test_convert_to_pdfa.py | 309 ++++++++++++++++++++++++ 6 files changed, 555 insertions(+) create mode 100644 tests/live/test_live_convert_to_pdfa.py create mode 100644 tests/test_convert_to_pdfa.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 9c96c49b..1415d73d 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -94,6 +94,7 @@ PdfRestRawFileResponse, PdfSplitPayload, PdfToExcelPayload, + PdfToPdfaPayload, PdfToPdfxPayload, PdfToPowerpointPayload, PdfToWordPayload, @@ -106,6 +107,7 @@ ) from .types import ( ALL_PDF_INFO_QUERIES, + PdfAType, PdfInfoQuery, PdfMergeInput, PdfPageSelection, @@ -2810,6 +2812,36 @@ def rasterize_pdf( timeout=timeout, ) + def convert_to_pdfa( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + output_type: PdfAType, + output: str | None = None, + rasterize_if_errors_encountered: Literal["on", "off"] | 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/A version.""" + + payload: dict[str, Any] = {"files": file, "output_type": output_type} + if output is not None: + payload["output"] = output + if rasterize_if_errors_encountered is not None: + payload["rasterize_if_errors_encountered"] = rasterize_if_errors_encountered + + return self._post_file_operation( + endpoint="/pdfa", + payload=payload, + payload_model=PdfToPdfaPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_to_pdfx( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -3832,6 +3864,36 @@ async def rasterize_pdf( timeout=timeout, ) + async def convert_to_pdfa( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + output_type: PdfAType, + output: str | None = None, + rasterize_if_errors_encountered: Literal["on", "off"] | 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/A version.""" + + payload: dict[str, Any] = {"files": file, "output_type": output_type} + if output is not None: + payload["output"] = output + if rasterize_if_errors_encountered is not None: + payload["rasterize_if_errors_encountered"] = rasterize_if_errors_encountered + + return await self._post_file_operation( + endpoint="/pdfa", + payload=payload, + payload_model=PdfToPdfaPayload, + 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 7475d18c..3245436a 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -22,6 +22,7 @@ from pdfrest.types.public import PdfRedactionPreset from ..types import ( + PdfAType, PdfInfoQuery, PdfXType, SummaryFormat, @@ -813,6 +814,38 @@ class PdfToPowerpointPayload(BaseModel): ] = None +class PdfToPdfaPayload(BaseModel): + """Adapt caller options into a pdfRest-ready PDF/A 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[PdfAType, Field(serialization_alias="output_type")] + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + rasterize_if_errors_encountered: Annotated[ + Literal["on", "off"] | None, + Field( + serialization_alias="rasterize_if_errors_encountered", + default=None, + ), + ] = None + + class PdfToPdfxPayload(BaseModel): """Adapt caller options into a pdfRest-ready PDF/X request payload.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index adf09638..94952a99 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -2,6 +2,7 @@ from .public import ( ALL_PDF_INFO_QUERIES, + PdfAType, PdfInfoQuery, PdfMergeInput, PdfMergeSource, @@ -19,6 +20,7 @@ __all__ = [ "ALL_PDF_INFO_QUERIES", + "PdfAType", "PdfInfoQuery", "PdfMergeInput", "PdfMergeSource", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 915968cf..df753f2b 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -14,6 +14,7 @@ __all__ = ( "ALL_PDF_INFO_QUERIES", + "PdfAType", "PdfInfoQuery", "PdfMergeInput", "PdfMergeSource", @@ -102,6 +103,7 @@ class PdfMergeSource(TypedDict, total=False): PdfMergeInput = PdfRestFile | PdfMergeSource | tuple[PdfRestFile, PdfPageSelection] +PdfAType = Literal["PDF/A-1b", "PDF/A-2b", "PDF/A-2u", "PDF/A-3b", "PDF/A-3u"] PdfXType = Literal["PDF/X-1a", "PDF/X-3", "PDF/X-4", "PDF/X-6"] SummaryFormat = Literal[ diff --git a/tests/live/test_live_convert_to_pdfa.py b/tests/live/test_live_convert_to_pdfa.py new file mode 100644 index 00000000..5d39d009 --- /dev/null +++ b/tests/live/test_live_convert_to_pdfa.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from typing import cast, get_args + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile +from pdfrest.types import PdfAType + +from ..resources import get_test_resource_path + +PDFA_TYPES: tuple[PdfAType, ...] = cast(tuple[PdfAType, ...], get_args(PdfAType)) +PDFA_TYPE_PARAMS = [ + pytest.param(output_type, id=output_type) for output_type in PDFA_TYPES +] + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_pdfa( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.mark.parametrize("output_type", PDFA_TYPE_PARAMS) +def test_live_convert_to_pdfa_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_pdfa: PdfRestFile, + output_type: PdfAType, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.convert_to_pdfa( + uploaded_pdf_for_pdfa, + output_type=output_type, + output="pdfa-live", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_pdfa.id) + assert output_file.name.startswith("pdfa-live") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("output_type", PDFA_TYPE_PARAMS) +async def test_live_async_convert_to_pdfa_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_pdfa: PdfRestFile, + output_type: PdfAType, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_to_pdfa( + uploaded_pdf_for_pdfa, + output_type=output_type, + output="async-pdfa", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async-pdfa") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_pdfa.id) + + +def test_live_convert_to_pdfa_with_rasterize_option( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_pdfa: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.convert_to_pdfa( + uploaded_pdf_for_pdfa, + output_type="PDF/A-2b", + rasterize_if_errors_encountered="on", + output="pdfa-rasterize", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("pdfa-rasterize") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_pdfa.id) + + +@pytest.mark.parametrize( + "invalid_output_type", + [ + pytest.param("PDF/A-0", id="pdfa-0"), + pytest.param("PDF/A-99", id="pdfa-99"), + pytest.param("pdf/a-2b", id="lowercase"), + ], +) +def test_live_convert_to_pdfa_invalid_output_type( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_pdfa: PdfRestFile, + invalid_output_type: str, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)pdf.?a"), + ): + client.convert_to_pdfa( + uploaded_pdf_for_pdfa, + output_type="PDF/A-1b", + extra_body={"output_type": invalid_output_type}, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_to_pdfa_invalid_output_type( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_pdfa: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)pdf.?a"): + await client.convert_to_pdfa( + uploaded_pdf_for_pdfa, + output_type="PDF/A-1b", + extra_body={"output_type": "PDF/A-0"}, + ) diff --git a/tests/test_convert_to_pdfa.py b/tests/test_convert_to_pdfa.py new file mode 100644 index 00000000..c678af17 --- /dev/null +++ b/tests/test_convert_to_pdfa.py @@ -0,0 +1,309 @@ +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 PdfToPdfaPayload +from pdfrest.types import PdfAType + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +@pytest.mark.parametrize( + "output_type", + [ + pytest.param("PDF/A-1b", id="pdfa-1b"), + pytest.param("PDF/A-2b", id="pdfa-2b"), + pytest.param("PDF/A-2u", id="pdfa-2u"), + pytest.param("PDF/A-3b", id="pdfa-3b"), + pytest.param("PDF/A-3u", id="pdfa-3u"), + ], +) +def test_convert_to_pdfa_success( + monkeypatch: pytest.MonkeyPatch, output_type: PdfAType +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + payload_dump = PdfToPdfaPayload.model_validate( + {"files": [input_file], "output_type": output_type, "output": "archive"} + ).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 == "/pdfa": + 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, "archive.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_pdfa( + input_file, + output_type=output_type, + output="archive", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "archive.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 +@pytest.mark.parametrize( + "output_type", + [ + pytest.param("PDF/A-1b", id="async-pdfa-1b"), + pytest.param("PDF/A-2b", id="async-pdfa-2b"), + pytest.param("PDF/A-2u", id="async-pdfa-2u"), + pytest.param("PDF/A-3b", id="async-pdfa-3b"), + pytest.param("PDF/A-3u", id="async-pdfa-3u"), + ], +) +async def test_async_convert_to_pdfa_success( + monkeypatch: pytest.MonkeyPatch, output_type: PdfAType +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + output_id = str(PdfRestFileID.generate()) + payload_dump = PdfToPdfaPayload.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 == "/pdfa": + 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_pdfa( + 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_pdfa_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + 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 == "/pdfa": + 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["output_type"] == "PDF/A-3b" + assert payload["rasterize_if_errors_encountered"] == "on" + assert payload["debug"] == "yes" + assert payload["id"] == str(input_file.id) + assert payload["output"] == "custom" + return httpx.Response( + 200, + json={"inputId": [input_file.id], "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.convert_to_pdfa( + input_file, + output_type="PDF/A-3b", + output="custom", + rasterize_if_errors_encountered="on", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": "yes"}, + timeout=0.33, + ) + + 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.33) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.33) + + +@pytest.mark.asyncio +async def test_async_convert_to_pdfa_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + 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 == "/pdfa": + 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["output_type"] == "PDF/A-2u" + assert payload["id"] == str(input_file.id) + assert payload["extra"] == {"note": "async"} + assert payload["rasterize_if_errors_encountered"] == "off" + return httpx.Response( + 200, + json={"inputId": [input_file.id], "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.convert_to_pdfa( + input_file, + output_type="PDF/A-2u", + rasterize_if_errors_encountered="off", + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"extra": {"note": "async"}}, + timeout=0.72, + ) + + 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.72) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.72) + + +def test_convert_to_pdfa_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=( + "Input should be 'PDF/A-1b', 'PDF/A-2b', 'PDF/A-2u', " + "'PDF/A-3b' or 'PDF/A-3u'" + ), + ), + ): + client.convert_to_pdfa(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_pdfa(png_file, output_type="PDF/A-2b") + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="PDF/A-1b"), + ): + client.convert_to_pdfa(pdf_file, output_type="PDF/A-4") # type: ignore[arg-type] + + 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_pdfa( + [pdf_file, make_pdf_file(PdfRestFileID.generate())], + output_type="PDF/A-2b", + ) From d5a8f131e9d8404c9de09d79d52a9e4e63818433 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 8 Jan 2026 10:29:52 -0600 Subject: [PATCH 46/84] Update src/pdfrest/models/public.py Omit mention of `output_type` as it is not relevant to the caller. Co-authored-by: Kevin A. Mitchell --- src/pdfrest/models/public.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index aafe55f4..aa57d9de 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -325,7 +325,7 @@ class SummarizePdfTextResponse(BaseModel): summary: Annotated[ str | None, Field( - description="Inline summary content when output_type is json.", + description="Summary content", default=None, ), ] = None From aa107cc7880ed448c77232010d0322efbeeda2ec Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 8 Jan 2026 10:31:29 -0600 Subject: [PATCH 47/84] Remove unused `ConvertToMarkdownResponse` class Assisted-by: Codex --- src/pdfrest/models/__init__.py | 2 -- src/pdfrest/models/public.py | 44 ---------------------------------- 2 files changed, 46 deletions(-) diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index 755bbaf7..c3242970 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -1,5 +1,4 @@ from .public import ( - ConvertToMarkdownResponse, ExtractTextResponse, PdfRestDeletionResponse, PdfRestErrorResponse, @@ -14,7 +13,6 @@ ) __all__ = [ - "ConvertToMarkdownResponse", "ExtractTextResponse", "PdfRestDeletionResponse", "PdfRestErrorResponse", diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index aa57d9de..b7de144f 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -20,7 +20,6 @@ from typing_extensions import override __all__ = ( - "ConvertToMarkdownResponse", "ExtractTextResponse", "PdfRestDeletionResponse", "PdfRestErrorResponse", @@ -485,49 +484,6 @@ class ExtractTextResponse(BaseModel): ] = None -class ConvertToMarkdownResponse(BaseModel): - """Response returned by the markdown conversion tool.""" - - model_config = ConfigDict(extra="allow") - - markdown: Annotated[ - str | None, - Field( - description="Inline markdown content when output_type is json.", - default=None, - ), - ] = None - input_id: Annotated[ - PdfRestFileID, - Field( - validation_alias=AliasChoices("input_id", "inputId"), - description="The id of the input file.", - ), - ] - output_url: Annotated[ - HttpUrl | None, - Field( - alias="outputUrl", - validation_alias=AliasChoices("output_url", "outputUrl"), - description="Download URL for file output.", - default=None, - ), - ] = None - output_id: Annotated[ - PdfRestFileID | None, - Field( - alias="outputId", - validation_alias=AliasChoices("output_id", "outputId"), - description="The id of the generated output when output_type is file.", - default=None, - ), - ] = None - warning: Annotated[ - str | None, - Field(description="A warning that was generated during markdown conversion."), - ] = None - - class PdfRestInfoResponse(BaseModel): """A response containing the output from the /info route.""" From 4f719a99134e253a46188b169bd1e1c74898327b Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 8 Jan 2026 15:15:36 -0600 Subject: [PATCH 48/84] Remove unused fields from Summarize PDF response Assisted-by: Codex --- src/pdfrest/models/public.py | 18 ------------------ tests/test_summarize_pdf_text.py | 2 -- 2 files changed, 20 deletions(-) diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index b7de144f..097ed276 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -335,24 +335,6 @@ class SummarizePdfTextResponse(BaseModel): description="The id of the input file.", ), ] - output_url: Annotated[ - HttpUrl | None, - Field( - alias="outputUrl", - validation_alias=AliasChoices("output_url", "outputUrl"), - description="Download URL for file output.", - default=None, - ), - ] = None - output_id: Annotated[ - PdfRestFileID | None, - Field( - alias="outputId", - validation_alias=AliasChoices("output_id", "outputId"), - description="The id of the generated output when output_type is file.", - default=None, - ), - ] = None class TranslatePdfTextResponse(BaseModel): diff --git a/tests/test_summarize_pdf_text.py b/tests/test_summarize_pdf_text.py index cbcf490b..cf2d7cb9 100644 --- a/tests/test_summarize_pdf_text.py +++ b/tests/test_summarize_pdf_text.py @@ -113,8 +113,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert isinstance(response, SummarizePdfTextResponse) assert response.summary == "Key points..." assert response.input_id == input_file.id - assert response.output_id is None - assert response.output_url is None def test_summarize_pdf_text_to_file_success( From 242a36c7dc98e14ae942f266c883715dbe146e17 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 8 Jan 2026 16:05:31 -0600 Subject: [PATCH 49/84] Translate PDF: Remove unused response fields Assisted-by: Codex --- src/pdfrest/models/public.py | 18 ------------------ tests/test_translate_pdf_text.py | 2 -- 2 files changed, 20 deletions(-) diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 097ed276..08c6a7e6 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -376,24 +376,6 @@ class TranslatePdfTextResponse(BaseModel): description="The id of the input file.", ), ] - output_url: Annotated[ - HttpUrl | None, - Field( - alias="outputUrl", - validation_alias=AliasChoices("output_url", "outputUrl"), - description="Download URL for file output.", - default=None, - ), - ] = None - output_id: Annotated[ - PdfRestFileID | None, - Field( - alias="outputId", - validation_alias=AliasChoices("output_id", "outputId"), - description="The id of the generated output when output_type is file.", - default=None, - ), - ] = None class TranslatePdfTextFileResponse(PdfRestFileBasedResponse): diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py index c26c4c0c..fc7eadcf 100644 --- a/tests/test_translate_pdf_text.py +++ b/tests/test_translate_pdf_text.py @@ -109,8 +109,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.source_languages == ["en"] assert response.output_language == "fr" assert response.input_id == input_file.id - assert response.output_id is None - assert response.output_url is None def test_translate_pdf_text_request_customization( From 38e209c122fe4eef6e69e1ad019e686fd5b4ee70 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 8 Jan 2026 16:23:31 -0600 Subject: [PATCH 50/84] Extract Text response: Remove unused fields Assisted-by: Codex --- src/pdfrest/models/public.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 08c6a7e6..8d1131fa 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -424,24 +424,6 @@ class ExtractTextResponse(BaseModel): description="The id of the input file.", ), ] - output_url: Annotated[ - HttpUrl | None, - Field( - alias="outputUrl", - validation_alias=AliasChoices("output_url", "outputUrl"), - description="Download URL for file output.", - default=None, - ), - ] = None - output_id: Annotated[ - PdfRestFileID | None, - Field( - alias="outputId", - validation_alias=AliasChoices("output_id", "outputId"), - description="The id of the generated output when output_type is file.", - default=None, - ), - ] = None warning: Annotated[ str | None, Field(description="A warning that was generated during text extraction."), From 81345e02e67d3ec8d6c6498f5b715ce5df428a07 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 9 Jan 2026 15:19:07 -0600 Subject: [PATCH 51/84] Remove unused `ExtractTextResponse` Assisted-by: Codex --- src/pdfrest/models/__init__.py | 2 -- src/pdfrest/models/public.py | 28 ---------------------------- 2 files changed, 30 deletions(-) diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index c3242970..ef10e565 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -1,5 +1,4 @@ from .public import ( - ExtractTextResponse, PdfRestDeletionResponse, PdfRestErrorResponse, PdfRestFile, @@ -13,7 +12,6 @@ ) __all__ = [ - "ExtractTextResponse", "PdfRestDeletionResponse", "PdfRestErrorResponse", "PdfRestFile", diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 8d1131fa..e4dc8a3a 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -20,7 +20,6 @@ from typing_extensions import override __all__ = ( - "ExtractTextResponse", "PdfRestDeletionResponse", "PdfRestErrorResponse", "PdfRestFile", @@ -403,33 +402,6 @@ class TranslatePdfTextFileResponse(PdfRestFileBasedResponse): ] = None -class ExtractTextResponse(BaseModel): - """Response returned by the extracted-text tool.""" - - model_config = ConfigDict(extra="allow") - - full_text: Annotated[ - str | None, - Field( - alias="fullText", - validation_alias=AliasChoices("full_text", "fullText"), - description="Inline extracted text when output_type is json.", - default=None, - ), - ] = None - input_id: Annotated[ - PdfRestFileID, - Field( - validation_alias=AliasChoices("input_id", "inputId"), - description="The id of the input file.", - ), - ] - warning: Annotated[ - str | None, - Field(description="A warning that was generated during text extraction."), - ] = None - - class PdfRestInfoResponse(BaseModel): """A response containing the output from the /info route.""" From 39d9836b749d59040063e68d1a6cf914af916131 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 9 Jan 2026 15:22:32 -0600 Subject: [PATCH 52/84] Remove "pdf" from Summarize method names --- src/pdfrest/client.py | 8 ++++---- tests/live/test_live_summarize_pdf_text.py | 20 ++++++++++---------- tests/test_summarize_pdf_text.py | 20 ++++++++++---------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 1415d73d..a198f353 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -2132,7 +2132,7 @@ def query_pdf_info( raw_payload = self._send_request(request) return PdfRestInfoResponse.model_validate(raw_payload) - def summarize_pdf_text( + def summarize_text( self, file: PdfRestFile | Sequence[PdfRestFile], *, @@ -2179,7 +2179,7 @@ def summarize_pdf_text( raw_payload = self._send_request(request) return SummarizePdfTextResponse.model_validate(raw_payload) - def summarize_pdf_text_to_file( + def summarize_text_to_file( self, file: PdfRestFile | Sequence[PdfRestFile], *, @@ -3142,7 +3142,7 @@ async def query_pdf_info( raw_payload = await self._send_request(request) return PdfRestInfoResponse.model_validate(raw_payload) - async def summarize_pdf_text( + async def summarize_text( self, file: PdfRestFile | Sequence[PdfRestFile], *, @@ -3189,7 +3189,7 @@ async def summarize_pdf_text( raw_payload = await self._send_request(request) return SummarizePdfTextResponse.model_validate(raw_payload) - async def summarize_pdf_text_to_file( + async def summarize_text_to_file( self, file: PdfRestFile | Sequence[PdfRestFile], *, diff --git a/tests/live/test_live_summarize_pdf_text.py b/tests/live/test_live_summarize_pdf_text.py index be8fb802..629c815a 100644 --- a/tests/live/test_live_summarize_pdf_text.py +++ b/tests/live/test_live_summarize_pdf_text.py @@ -8,7 +8,7 @@ from ..resources import get_test_resource_path -def test_live_summarize_pdf_text_success( +def test_live_summarize_text_success( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: @@ -18,7 +18,7 @@ def test_live_summarize_pdf_text_success( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - response = client.summarize_pdf_text( + response = client.summarize_text( uploaded, target_word_count=40, summary_format="overview", @@ -29,7 +29,7 @@ def test_live_summarize_pdf_text_success( assert response.input_id == uploaded.id -def test_live_summarize_pdf_text_to_file_success( +def test_live_summarize_text_to_file_success( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: @@ -39,7 +39,7 @@ def test_live_summarize_pdf_text_to_file_success( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - response = client.summarize_pdf_text_to_file( + response = client.summarize_text_to_file( uploaded, target_word_count=40, summary_format="overview", @@ -56,7 +56,7 @@ def test_live_summarize_pdf_text_to_file_success( @pytest.mark.asyncio -async def test_live_async_summarize_pdf_text_success( +async def test_live_async_summarize_text_success( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: @@ -66,7 +66,7 @@ async def test_live_async_summarize_pdf_text_success( base_url=pdfrest_live_base_url, ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] - response = await client.summarize_pdf_text( + response = await client.summarize_text( uploaded, target_word_count=30, summary_format="overview", @@ -77,7 +77,7 @@ async def test_live_async_summarize_pdf_text_success( assert response.input_id == uploaded.id -def test_live_summarize_pdf_text_invalid_format( +def test_live_summarize_text_invalid_format( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: @@ -88,14 +88,14 @@ def test_live_summarize_pdf_text_invalid_format( ) as client: uploaded = client.files.create_from_paths([resource])[0] with pytest.raises(PdfRestApiError, match=r"(?i)summary"): - client.summarize_pdf_text( + client.summarize_text( uploaded, extra_body={"summary_format": "invalid-style"}, ) @pytest.mark.asyncio -async def test_live_async_summarize_pdf_text_invalid_format( +async def test_live_async_summarize_text_invalid_format( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: @@ -106,7 +106,7 @@ async def test_live_async_summarize_pdf_text_invalid_format( ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] with pytest.raises(PdfRestApiError, match=r"(?i)summary"): - await client.summarize_pdf_text( + await client.summarize_text( uploaded, extra_body={"summary_format": "invalid-style"}, ) diff --git a/tests/test_summarize_pdf_text.py b/tests/test_summarize_pdf_text.py index cf2d7cb9..4263c488 100644 --- a/tests/test_summarize_pdf_text.py +++ b/tests/test_summarize_pdf_text.py @@ -66,7 +66,7 @@ def test_summarize_payload_invalid_page_range() -> None: SummarizePdfTextPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) -def test_summarize_pdf_text_json_success(monkeypatch: pytest.MonkeyPatch) -> None: +def test_summarize_text_json_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = _make_text_file(str(PdfRestFileID.generate(1))) payload_dump = SummarizePdfTextPayload.model_validate( @@ -100,7 +100,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.summarize_pdf_text( + response = client.summarize_text( input_file, target_word_count=120, summary_format="bullet_points", @@ -115,7 +115,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.input_id == input_file.id -def test_summarize_pdf_text_to_file_success( +def test_summarize_text_to_file_success( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -158,7 +158,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.summarize_pdf_text_to_file( + response = client.summarize_text_to_file( input_file, target_word_count=200, summary_format="bullet_points", @@ -174,7 +174,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.input_id == input_file.id -def test_summarize_pdf_text_to_file_request_customization( +def test_summarize_text_to_file_request_customization( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -223,7 +223,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.summarize_pdf_text_to_file( + response = client.summarize_text_to_file( input_file, extra_query={"trace": "true"}, extra_headers={"X-Debug": "sync"}, @@ -246,7 +246,7 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio -async def test_async_summarize_pdf_text_success( +async def test_async_summarize_text_success( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -275,7 +275,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.summarize_pdf_text(input_file) + response = await client.summarize_text(input_file) assert seen == {"post": 1} assert isinstance(response, SummarizePdfTextResponse) @@ -284,7 +284,7 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio -async def test_async_summarize_pdf_text_to_file_success( +async def test_async_summarize_text_to_file_success( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -322,7 +322,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.summarize_pdf_text_to_file(input_file) + response = await client.summarize_text_to_file(input_file) assert seen == {"post": 1, "get": 1} assert isinstance(response, PdfRestFileBasedResponse) From 24b91544147df5ce3a72e000f9e73c2dce6a410c Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 8 Jan 2026 22:49:10 -0600 Subject: [PATCH 53/84] models: Add `_bool_to_on_off` converter for boolean fields - Introduced `_bool_to_on_off` function to convert boolean values to "on"/"off". - Applied the new `BeforeValidator` to `preserve_line_breaks`, `word_style`, and `word_coordinates` fields to handle boolean inputs properly. - Ensured consistency in serialization and validation of relevant model fields. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 3245436a..8aaa0421 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -120,6 +120,12 @@ def _serialize_file_ids(value: list[PdfRestFile]) -> str: return ",".join(str(file.id) for file in value) +def _bool_to_on_off(value: Any) -> Any: + if isinstance(value, bool): + return "on" if value else "off" + return value + + def _serialize_page_ranges(value: list[str | int | tuple[str | int, ...]]) -> str: def join_tuple(value: str | int | tuple[str | int, ...]) -> str: if isinstance(value, tuple): @@ -364,9 +370,15 @@ class ExtractTextPayload(BaseModel): PlainSerializer(_serialize_page_ranges), ] = None full_text: Literal["off", "by_page", "document"] = "document" - preserve_line_breaks: Literal["off", "on"] = "off" - word_style: Literal["off", "on"] = "off" - word_coordinates: Literal["off", "on"] = "off" + preserve_line_breaks: Annotated[ + Literal["off", "on"], BeforeValidator(_bool_to_on_off) + ] = "off" + word_style: Annotated[Literal["off", "on"], BeforeValidator(_bool_to_on_off)] = ( + "off" + ) + word_coordinates: Annotated[ + Literal["off", "on"], BeforeValidator(_bool_to_on_off) + ] = "off" output_type: Literal["json", "file"] = "json" output: Annotated[ str | None, From 9ff3a86cc07a5f60e668bd4de7a0eea53030eee6 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Fri, 9 Jan 2026 15:54:17 -0600 Subject: [PATCH 54/84] Replace on/off in external interface with `bool` Assisted-by: Codex --- src/pdfrest/client.py | 20 ++++++++++---------- src/pdfrest/models/_internal.py | 2 ++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index a198f353..21655df4 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -2222,7 +2222,7 @@ def convert_to_markdown( file: PdfRestFile | Sequence[PdfRestFile], *, pages: PdfPageSelection | None = None, - page_break_comments: Literal["on", "off"] | None = None, + page_break_comments: bool | None = None, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -2394,9 +2394,9 @@ def extract_pdf_text_to_file( *, pages: PdfPageSelection | None = None, full_text: Literal["off", "by_page", "document"] = "document", - preserve_line_breaks: Literal["off", "on"] = "off", - word_style: Literal["off", "on"] = "off", - word_coordinates: Literal["off", "on"] = "off", + preserve_line_breaks: bool = False, + word_style: bool = False, + word_coordinates: bool = False, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -2818,7 +2818,7 @@ def convert_to_pdfa( *, output_type: PdfAType, output: str | None = None, - rasterize_if_errors_encountered: Literal["on", "off"] | None = None, + rasterize_if_errors_encountered: bool | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -3232,7 +3232,7 @@ async def convert_to_markdown( file: PdfRestFile | Sequence[PdfRestFile], *, pages: PdfPageSelection | None = None, - page_break_comments: Literal["on", "off"] | None = None, + page_break_comments: bool | None = None, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -3404,9 +3404,9 @@ async def extract_pdf_text_to_file( *, pages: PdfPageSelection | None = None, full_text: Literal["off", "by_page", "document"] = "document", - preserve_line_breaks: Literal["off", "on"] = "off", - word_style: Literal["off", "on"] = "off", - word_coordinates: Literal["off", "on"] = "off", + preserve_line_breaks: bool = False, + word_style: bool = False, + word_coordinates: bool = False, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -3870,7 +3870,7 @@ async def convert_to_pdfa( *, output_type: PdfAType, output: str | None = None, - rasterize_if_errors_encountered: Literal["on", "off"] | None = None, + rasterize_if_errors_encountered: bool | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 8aaa0421..59e576d6 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -418,6 +418,7 @@ class ConvertToMarkdownPayload(BaseModel): page_break_comments: Annotated[ Literal["on", "off"] | None, Field(serialization_alias="page_break_comments", default=None), + BeforeValidator(_bool_to_on_off), ] = None output: Annotated[ str | None, @@ -855,6 +856,7 @@ class PdfToPdfaPayload(BaseModel): serialization_alias="rasterize_if_errors_encountered", default=None, ), + BeforeValidator(_bool_to_on_off), ] = None From 9c99f33a1f1e702f0f9ecbf108105f8272fe90e2 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Mon, 12 Jan 2026 16:30:01 -0600 Subject: [PATCH 55/84] Set default values (from pdfRest) on optional client parameters Assisted-by: Codex --- src/pdfrest/client.py | 49 +++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 21655df4..8f8f95e2 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -2136,7 +2136,7 @@ def summarize_text( self, file: PdfRestFile | Sequence[PdfRestFile], *, - target_word_count: int | None = 400, + target_word_count: int = 400, summary_format: SummaryFormat = "overview", pages: PdfPageSelection | None = None, output_format: SummaryOutputFormat = "markdown", @@ -2183,7 +2183,7 @@ def summarize_text_to_file( self, file: PdfRestFile | Sequence[PdfRestFile], *, - target_word_count: int | None = 400, + target_word_count: int = 400, summary_format: SummaryFormat = "overview", pages: PdfPageSelection | None = None, output_format: SummaryOutputFormat = "markdown", @@ -2222,7 +2222,7 @@ def convert_to_markdown( file: PdfRestFile | Sequence[PdfRestFile], *, pages: PdfPageSelection | None = None, - page_break_comments: bool | None = None, + page_break_comments: bool = False, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -2234,11 +2234,10 @@ def convert_to_markdown( payload: dict[str, Any] = { "files": file, "output_type": "file", + "page_break_comments": page_break_comments, } if pages is not None: payload["pages"] = pages - if page_break_comments is not None: - payload["page_break_comments"] = page_break_comments if output is not None: payload["output"] = output @@ -2818,7 +2817,7 @@ def convert_to_pdfa( *, output_type: PdfAType, output: str | None = None, - rasterize_if_errors_encountered: bool | None = None, + rasterize_if_errors_encountered: bool = False, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -2826,12 +2825,13 @@ def convert_to_pdfa( ) -> PdfRestFileBasedResponse: """Convert a PDF to a specified PDF/A version.""" - payload: dict[str, Any] = {"files": file, "output_type": output_type} + payload: dict[str, Any] = { + "files": file, + "output_type": output_type, + "rasterize_if_errors_encountered": rasterize_if_errors_encountered, + } if output is not None: payload["output"] = output - if rasterize_if_errors_encountered is not None: - payload["rasterize_if_errors_encountered"] = rasterize_if_errors_encountered - return self._post_file_operation( endpoint="/pdfa", payload=payload, @@ -3000,7 +3000,7 @@ def convert_to_jpeg( smoothing: Literal["none", "all", "text", "line", "image"] | Sequence[Literal["none", "all", "text", "line", "image"]] | None = None, - jpeg_quality: int | None = None, + jpeg_quality: int = 75, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -3012,6 +3012,7 @@ def convert_to_jpeg( "files": files, "resolution": resolution, "color_model": color_model, + "jpeg_quality": jpeg_quality, } if output_prefix is not None: payload["output_prefix"] = output_prefix @@ -3019,8 +3020,6 @@ def convert_to_jpeg( payload["page_range"] = page_range if smoothing is not None: payload["smoothing"] = smoothing - if jpeg_quality is not None: - payload["jpeg_quality"] = jpeg_quality return self._convert_to_graphic( endpoint="/jpg", @@ -3146,7 +3145,7 @@ async def summarize_text( self, file: PdfRestFile | Sequence[PdfRestFile], *, - target_word_count: int | None = 400, + target_word_count: int = 400, summary_format: SummaryFormat = "overview", pages: PdfPageSelection | None = None, output_format: SummaryOutputFormat = "markdown", @@ -3193,7 +3192,7 @@ async def summarize_text_to_file( self, file: PdfRestFile | Sequence[PdfRestFile], *, - target_word_count: int | None = 400, + target_word_count: int = 400, summary_format: SummaryFormat = "overview", pages: PdfPageSelection | None = None, output_format: SummaryOutputFormat = "markdown", @@ -3232,7 +3231,7 @@ async def convert_to_markdown( file: PdfRestFile | Sequence[PdfRestFile], *, pages: PdfPageSelection | None = None, - page_break_comments: bool | None = None, + page_break_comments: bool = False, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -3244,11 +3243,10 @@ async def convert_to_markdown( payload: dict[str, Any] = { "files": file, "output_type": "file", + "page_break_comments": page_break_comments, } if pages is not None: payload["pages"] = pages - if page_break_comments is not None: - payload["page_break_comments"] = page_break_comments if output is not None: payload["output"] = output @@ -3870,7 +3868,7 @@ async def convert_to_pdfa( *, output_type: PdfAType, output: str | None = None, - rasterize_if_errors_encountered: bool | None = None, + rasterize_if_errors_encountered: bool = False, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -3878,11 +3876,13 @@ async def convert_to_pdfa( ) -> PdfRestFileBasedResponse: """Asynchronously convert a PDF to a specified PDF/A version.""" - payload: dict[str, Any] = {"files": file, "output_type": output_type} + payload: dict[str, Any] = { + "files": file, + "output_type": output_type, + "rasterize_if_errors_encountered": rasterize_if_errors_encountered, + } if output is not None: payload["output"] = output - if rasterize_if_errors_encountered is not None: - payload["rasterize_if_errors_encountered"] = rasterize_if_errors_encountered return await self._post_file_operation( endpoint="/pdfa", @@ -4052,7 +4052,7 @@ async def convert_to_jpeg( smoothing: Literal["none", "all", "text", "line", "image"] | Sequence[Literal["none", "all", "text", "line", "image"]] | None = None, - jpeg_quality: int | None = None, + jpeg_quality: int = 75, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -4064,6 +4064,7 @@ async def convert_to_jpeg( "files": files, "resolution": resolution, "color_model": color_model, + "jpeg_quality": jpeg_quality, } if output_prefix is not None: payload["output_prefix"] = output_prefix @@ -4071,8 +4072,6 @@ async def convert_to_jpeg( payload["page_range"] = page_range if smoothing is not None: payload["smoothing"] = smoothing - if jpeg_quality is not None: - payload["jpeg_quality"] = jpeg_quality return await self._convert_to_graphic( endpoint="/jpg", From 47953f54fe10d3715790c35e74ea5f2a6d2d51e5 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Mon, 12 Jan 2026 16:50:03 -0600 Subject: [PATCH 56/84] Create types for remaining Literal arguments in clients Assisted-by: Codex --- src/pdfrest/client.py | 81 +++++++++++++++-------------------- src/pdfrest/types/__init__.py | 18 ++++++++ src/pdfrest/types/public.py | 18 ++++++++ 3 files changed, 71 insertions(+), 46 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 8f8f95e2..593219cc 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -107,6 +107,13 @@ ) from .types import ( ALL_PDF_INFO_QUERIES, + BmpColorModel, + CompressionLevel, + ExtractTextGranularity, + FlattenQuality, + GifColorModel, + GraphicSmoothing, + JpegColorModel, PdfAType, PdfInfoQuery, PdfMergeInput, @@ -114,8 +121,10 @@ PdfRedactionInstruction, PdfRGBColor, PdfXType, + PngColorModel, SummaryFormat, SummaryOutputFormat, + TiffColorModel, TranslateOutputFormat, ) @@ -2392,7 +2401,7 @@ def extract_pdf_text_to_file( file: PdfRestFile | Sequence[PdfRestFile], *, pages: PdfPageSelection | None = None, - full_text: Literal["off", "by_page", "document"] = "document", + full_text: ExtractTextGranularity = "document", preserve_line_breaks: bool = False, word_style: bool = False, word_coordinates: bool = False, @@ -2677,7 +2686,7 @@ def compress_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], *, - compression_level: Literal["low", "medium", "high", "custom"], + compression_level: CompressionLevel, profile: PdfRestFile | Sequence[PdfRestFile] | None = None, output: str | None = None, extra_query: Query | None = None, @@ -2711,7 +2720,7 @@ def flatten_transparencies( file: PdfRestFile | Sequence[PdfRestFile], *, output: str | None = None, - quality: Literal["low", "medium", "high"] = "medium", + quality: FlattenQuality = "medium", extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -2876,10 +2885,8 @@ def convert_to_png( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, - color_model: Literal["rgb", "rgba", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + color_model: PngColorModel = "rgb", + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -2916,10 +2923,8 @@ def convert_to_bmp( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, - color_model: Literal["rgb", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + color_model: BmpColorModel = "rgb", + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -2956,10 +2961,8 @@ def convert_to_gif( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, - color_model: Literal["rgb", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + color_model: GifColorModel = "rgb", + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -2996,10 +2999,8 @@ def convert_to_jpeg( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, - color_model: Literal["rgb", "cmyk", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + color_model: JpegColorModel = "rgb", + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, jpeg_quality: int = 75, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -3038,10 +3039,8 @@ def convert_to_tiff( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, - color_model: Literal["rgb", "rgba", "cmyk", "lab", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + color_model: TiffColorModel = "rgb", + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -3401,7 +3400,7 @@ async def extract_pdf_text_to_file( file: PdfRestFile | Sequence[PdfRestFile], *, pages: PdfPageSelection | None = None, - full_text: Literal["off", "by_page", "document"] = "document", + full_text: ExtractTextGranularity = "document", preserve_line_breaks: bool = False, word_style: bool = False, word_coordinates: bool = False, @@ -3728,7 +3727,7 @@ async def compress_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], *, - compression_level: Literal["low", "medium", "high", "custom"], + compression_level: CompressionLevel, profile: PdfRestFile | Sequence[PdfRestFile] | None = None, output: str | None = None, extra_query: Query | None = None, @@ -3762,7 +3761,7 @@ async def flatten_transparencies( file: PdfRestFile | Sequence[PdfRestFile], *, output: str | None = None, - quality: Literal["low", "medium", "high"] = "medium", + quality: FlattenQuality = "medium", extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -3928,10 +3927,8 @@ async def convert_to_png( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, - color_model: Literal["rgb", "rgba", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + color_model: PngColorModel = "rgb", + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -3968,10 +3965,8 @@ async def convert_to_bmp( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, - color_model: Literal["rgb", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + color_model: BmpColorModel = "rgb", + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -4008,10 +4003,8 @@ async def convert_to_gif( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, - color_model: Literal["rgb", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + color_model: GifColorModel = "rgb", + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -4048,10 +4041,8 @@ async def convert_to_jpeg( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, - color_model: Literal["rgb", "cmyk", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + color_model: JpegColorModel = "rgb", + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, jpeg_quality: int = 75, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -4090,10 +4081,8 @@ async def convert_to_tiff( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, - color_model: Literal["rgb", "rgba", "cmyk", "lab", "gray"] = "rgb", - smoothing: Literal["none", "all", "text", "line", "image"] - | Sequence[Literal["none", "all", "text", "line", "image"]] - | None = None, + color_model: TiffColorModel = "rgb", + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 94952a99..b7c1ae7e 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -2,6 +2,13 @@ from .public import ( ALL_PDF_INFO_QUERIES, + BmpColorModel, + CompressionLevel, + ExtractTextGranularity, + FlattenQuality, + GifColorModel, + GraphicSmoothing, + JpegColorModel, PdfAType, PdfInfoQuery, PdfMergeInput, @@ -12,14 +19,23 @@ PdfRedactionType, PdfRGBColor, PdfXType, + PngColorModel, SummaryFormat, SummaryOutputFormat, SummaryOutputType, + TiffColorModel, TranslateOutputFormat, ) __all__ = [ "ALL_PDF_INFO_QUERIES", + "BmpColorModel", + "CompressionLevel", + "ExtractTextGranularity", + "FlattenQuality", + "GifColorModel", + "GraphicSmoothing", + "JpegColorModel", "PdfAType", "PdfInfoQuery", "PdfMergeInput", @@ -30,8 +46,10 @@ "PdfRedactionPreset", "PdfRedactionType", "PdfXType", + "PngColorModel", "SummaryFormat", "SummaryOutputFormat", "SummaryOutputType", + "TiffColorModel", "TranslateOutputFormat", ] diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index df753f2b..0d06c671 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -14,6 +14,13 @@ __all__ = ( "ALL_PDF_INFO_QUERIES", + "BmpColorModel", + "CompressionLevel", + "ExtractTextGranularity", + "FlattenQuality", + "GifColorModel", + "GraphicSmoothing", + "JpegColorModel", "PdfAType", "PdfInfoQuery", "PdfMergeInput", @@ -24,9 +31,11 @@ "PdfRedactionPreset", "PdfRedactionType", "PdfXType", + "PngColorModel", "SummaryFormat", "SummaryOutputFormat", "SummaryOutputType", + "TiffColorModel", "TranslateOutputFormat", ) @@ -105,6 +114,15 @@ class PdfMergeSource(TypedDict, total=False): PdfAType = Literal["PDF/A-1b", "PDF/A-2b", "PDF/A-2u", "PDF/A-3b", "PDF/A-3u"] PdfXType = Literal["PDF/X-1a", "PDF/X-3", "PDF/X-4", "PDF/X-6"] +ExtractTextGranularity = Literal["off", "by_page", "document"] +CompressionLevel = Literal["low", "medium", "high", "custom"] +FlattenQuality = Literal["low", "medium", "high"] +PngColorModel = Literal["rgb", "rgba", "gray"] +BmpColorModel = Literal["rgb", "gray"] +GifColorModel = Literal["rgb", "gray"] +JpegColorModel = Literal["rgb", "cmyk", "gray"] +TiffColorModel = Literal["rgb", "rgba", "cmyk", "lab", "gray"] +GraphicSmoothing = Literal["none", "all", "text", "line", "image"] SummaryFormat = Literal[ "overview", From cbc0ff8676dee393a1320c3eabbdc2c836cdef72 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Mon, 12 Jan 2026 16:53:16 -0600 Subject: [PATCH 57/84] OCR PDF: Fix misleading method descriptions OCR PDF does not extract text, but it makes subsequent text extraction possible. --- src/pdfrest/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 593219cc..8d51cd3c 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -2271,7 +2271,7 @@ def ocr_pdf( extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - """Perform OCR on a PDF to extract searchable text.""" + """Perform OCR on a PDF to make text searchable and extractable.""" payload: dict[str, Any] = {"files": file} if pages is not None: @@ -3270,7 +3270,7 @@ async def ocr_pdf( extra_body: Body | None = None, timeout: TimeoutTypes | None = None, ) -> PdfRestFileBasedResponse: - """Perform OCR on a PDF to extract searchable text.""" + """Perform OCR on a PDF to make text searchable and extractable.""" payload: dict[str, Any] = {"files": file} if pages is not None: From 2ab5abc8d774ee6e8ac834223f420b3dc8e6cc45 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Mon, 12 Jan 2026 17:34:04 -0600 Subject: [PATCH 58/84] OCR PDF: Add missing `languages` body parameter Assisted-by: Codex --- src/pdfrest/client.py | 7 ++++-- src/pdfrest/models/_internal.py | 13 +++++++++++ src/pdfrest/types/__init__.py | 4 ++++ src/pdfrest/types/public.py | 20 +++++++++++++++++ tests/live/test_live_ocr_pdf.py | 2 +- tests/test_ocr_pdf.py | 39 +++++++++++++++++++++++++++------ 6 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 8d51cd3c..bc640278 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -114,6 +114,7 @@ GifColorModel, GraphicSmoothing, JpegColorModel, + OcrLanguage, PdfAType, PdfInfoQuery, PdfMergeInput, @@ -2264,6 +2265,7 @@ def ocr_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], *, + languages: OcrLanguage | Sequence[OcrLanguage] = "English", pages: PdfPageSelection | None = None, output: str | None = None, extra_query: Query | None = None, @@ -2273,7 +2275,7 @@ def ocr_pdf( ) -> PdfRestFileBasedResponse: """Perform OCR on a PDF to make text searchable and extractable.""" - payload: dict[str, Any] = {"files": file} + payload: dict[str, Any] = {"files": file, "languages": languages} if pages is not None: payload["pages"] = pages if output is not None: @@ -3263,6 +3265,7 @@ async def ocr_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], *, + languages: OcrLanguage | Sequence[OcrLanguage] = "English", pages: PdfPageSelection | None = None, output: str | None = None, extra_query: Query | None = None, @@ -3272,7 +3275,7 @@ async def ocr_pdf( ) -> PdfRestFileBasedResponse: """Perform OCR on a PDF to make text searchable and extractable.""" - payload: dict[str, Any] = {"files": file} + payload: dict[str, Any] = {"files": file, "languages": languages} if pages is not None: payload["pages"] = pages if output is not None: diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 59e576d6..0ffbe762 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -22,6 +22,7 @@ from pdfrest.types.public import PdfRedactionPreset from ..types import ( + OcrLanguage, PdfAType, PdfInfoQuery, PdfXType, @@ -329,6 +330,18 @@ class OcrPdfPayload(BaseModel): ), PlainSerializer(_serialize_as_first_file_id), ] + languages: Annotated[ + list[OcrLanguage], + Field( + serialization_alias="languages", + validation_alias=AliasChoices("languages", "language"), + min_length=1, + default_factory=lambda: ["English"], + ), + BeforeValidator(_ensure_list), + BeforeValidator(_split_comma_list), + PlainSerializer(_serialize_as_comma_separated_string), + ] pages: Annotated[ list[AscendingPageRange] | None, Field(serialization_alias="pages", min_length=1, default=None), diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index b7c1ae7e..48f78b03 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -1,6 +1,7 @@ """Public import surface for shared pdfrest types.""" from .public import ( + ALL_OCR_LANGUAGES, ALL_PDF_INFO_QUERIES, BmpColorModel, CompressionLevel, @@ -9,6 +10,7 @@ GifColorModel, GraphicSmoothing, JpegColorModel, + OcrLanguage, PdfAType, PdfInfoQuery, PdfMergeInput, @@ -28,6 +30,7 @@ ) __all__ = [ + "ALL_OCR_LANGUAGES", "ALL_PDF_INFO_QUERIES", "BmpColorModel", "CompressionLevel", @@ -36,6 +39,7 @@ "GifColorModel", "GraphicSmoothing", "JpegColorModel", + "OcrLanguage", "PdfAType", "PdfInfoQuery", "PdfMergeInput", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 0d06c671..6472f2e7 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -13,6 +13,7 @@ PdfRestFile = Any __all__ = ( + "ALL_OCR_LANGUAGES", "ALL_PDF_INFO_QUERIES", "BmpColorModel", "CompressionLevel", @@ -21,6 +22,7 @@ "GifColorModel", "GraphicSmoothing", "JpegColorModel", + "OcrLanguage", "PdfAType", "PdfInfoQuery", "PdfMergeInput", @@ -140,3 +142,21 @@ class PdfMergeSource(TypedDict, total=False): SummaryOutputType = Literal["json", "file"] TranslateOutputFormat = Literal["plaintext", "markdown"] + +OcrLanguage = Literal[ + "ChineseSimplified", + "ChineseTraditional", + "Dutch", + "English", + "French", + "German", + "Italian", + "Japanese", + "Korean", + "Portuguese", + "Spanish", +] + +ALL_OCR_LANGUAGES: tuple[OcrLanguage, ...] = cast( + tuple[OcrLanguage, ...], get_args(OcrLanguage) +) diff --git a/tests/live/test_live_ocr_pdf.py b/tests/live/test_live_ocr_pdf.py index 5109bad5..5e9ede14 100644 --- a/tests/live/test_live_ocr_pdf.py +++ b/tests/live/test_live_ocr_pdf.py @@ -18,7 +18,7 @@ def test_live_ocr_pdf_success( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - response = client.ocr_pdf(uploaded) + response = client.ocr_pdf(uploaded, languages=["English", "German"]) assert isinstance(response, PdfRestFileBasedResponse) assert response.output_files diff --git a/tests/test_ocr_pdf.py b/tests/test_ocr_pdf.py index b5059e30..625f92f4 100644 --- a/tests/test_ocr_pdf.py +++ b/tests/test_ocr_pdf.py @@ -38,11 +38,36 @@ def test_ocr_payload_invalid_page_range() -> None: OcrPdfPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) +def test_ocr_payload_languages() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + payload = OcrPdfPayload.model_validate( + {"files": [file_repr], "languages": ["English", "German"]} + ) + assert payload.languages == ["English", "German"] + assert ( + payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + )["languages"] + == "English,German" + ) + + +def test_ocr_payload_invalid_language() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises(ValidationError, match="ChineseSimplified"): + OcrPdfPayload.model_validate({"files": [file_repr], "languages": ["Klingon"]}) + + def test_ocr_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) payload_dump = OcrPdfPayload.model_validate( - {"files": [input_file], "pages": ["1-3"], "output": "ocr"} + { + "files": [input_file], + "pages": ["1-3"], + "output": "ocr", + "languages": ["English"], + } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) output_id = str(PdfRestFileID.generate()) @@ -91,9 +116,9 @@ def test_ocr_pdf_request_customization( ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) - payload_dump = OcrPdfPayload.model_validate({"files": [input_file]}).model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ) + payload_dump = OcrPdfPayload.model_validate( + {"files": [input_file], "languages": ["English"]} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) output_id = str(PdfRestFileID.generate()) captured_timeout: dict[str, float | dict[str, float] | None] = {} @@ -152,9 +177,9 @@ async def test_async_ocr_pdf_success( ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(2)) - payload_dump = OcrPdfPayload.model_validate({"files": [input_file]}).model_dump( - mode="json", by_alias=True, exclude_none=True, exclude_unset=True - ) + payload_dump = OcrPdfPayload.model_validate( + {"files": [input_file], "languages": ["English"]} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) output_id = str(PdfRestFileID.generate()) seen: dict[str, int] = {"post": 0, "get": 0} From a090f18ed35eaf8086d061d9c2852e12a0c23295 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 13 Jan 2026 09:33:33 -0600 Subject: [PATCH 59/84] Translate PDF: Verify language code - Add `AfterValidator` on language string - Use new dependency `langcodes` to validate language codes Assisted-by: Codex --- pyproject.toml | 1 + src/pdfrest/models/_internal.py | 43 +++++++++++++++++++++++++- tests/test_translate_pdf_text.py | 53 ++++++++++++++++++++++++++++++++ uv.lock | 11 +++++++ 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 69d2a422..a28be645 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.10" dependencies = [ "exceptiongroup>=1.3.0", "httpx>=0.28.1", + "langcodes>=3.4.0", "pydantic>=2.12.0", ] diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 0ffbe762..1c654f0d 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -6,6 +6,7 @@ from pathlib import PurePath from typing import Annotated, Any, Generic, Literal, TypeVar +from langcodes import tag_is_valid from pydantic import ( AfterValidator, AliasChoices, @@ -175,6 +176,45 @@ def _int_to_string(value: Any) -> Any: return value +_OUTPUT_LANGUAGE_ERROR = ( + "The provided 'output_language' language tag is invalid. Format 'output_language' as " + "a valid 2-3 character ISO 639 language code (e.g., 'en', 'es', 'fra'), optionally " + "with a script, alphabetic region, or numeric region (e.g., 'zh-Hant', 'eng-US', " + "'es-419'). See documentation for recommended formats." +) + + +def _validate_output_language(value: str) -> str: + if not value: + raise ValueError(_OUTPUT_LANGUAGE_ERROR) + + trimmed = value.strip() + if not trimmed: + raise ValueError(_OUTPUT_LANGUAGE_ERROR) + + segments = trimmed.split("-") + if len(segments) > 2: + raise ValueError(_OUTPUT_LANGUAGE_ERROR) + + language = segments[0] + if not re.fullmatch(r"[A-Za-z]{2,3}", language): + raise ValueError(_OUTPUT_LANGUAGE_ERROR) + + if len(segments) == 2: + subtag = segments[1] + if not ( + re.fullmatch(r"[A-Za-z]{4}", subtag) + or re.fullmatch(r"[A-Za-z]{2}", subtag) + or re.fullmatch(r"[0-9]{3}", subtag) + ): + raise ValueError(_OUTPUT_LANGUAGE_ERROR) + + if not tag_is_valid(trimmed): + raise ValueError(_OUTPUT_LANGUAGE_ERROR) + + return trimmed + + class UploadURLs(BaseModel): url: Annotated[ list[HttpUrl] | HttpUrl, @@ -464,7 +504,8 @@ class TranslatePdfTextPayload(BaseModel): ] output_language: Annotated[ str, - Field(serialization_alias="output_language", min_length=1), + Field(serialization_alias="output_language"), + AfterValidator(_validate_output_language), ] pages: Annotated[ list[AscendingPageRange] | None, diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py index fc7eadcf..7cfe5c76 100644 --- a/tests/test_translate_pdf_text.py +++ b/tests/test_translate_pdf_text.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import httpx import pytest @@ -17,6 +18,13 @@ from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file +OUTPUT_LANGUAGE_ERROR = ( + "The provided 'output_language' language tag is invalid. Format 'output_language' " + "as a valid 2-3 character ISO 639 language code (e.g., 'en', 'es', 'fra'), " + "optionally with a script, alphabetic region, or numeric region (e.g., 'zh-Hant', " + "'eng-US', 'es-419'). See documentation for recommended formats." +) + def _make_markdown_file(file_id: str) -> PdfRestFile: return PdfRestFile.model_validate( @@ -54,6 +62,51 @@ def test_translate_payload_rejects_invalid_mime() -> None: ) +@pytest.mark.parametrize( + "output_language", + [ + pytest.param("en", id="language-2-letter"), + pytest.param("fra", id="language-3-letter"), + pytest.param("zh-Hant", id="script"), + pytest.param("eng-US", id="alpha-region"), + pytest.param("es-419", id="numeric-region"), + ], +) +def test_translate_payload_accepts_valid_output_language( + output_language: str, +) -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + payload = TranslatePdfTextPayload.model_validate( + {"files": [file_repr], "output_language": output_language} + ) + + assert payload.output_language == output_language + + +@pytest.mark.parametrize( + "output_language", + [ + pytest.param("", id="empty"), + pytest.param("e", id="too-short"), + pytest.param("english", id="not-a-code"), + pytest.param("eng-USA", id="long-subtag"), + pytest.param("en-1234", id="long-numeric-region"), + pytest.param("en-US-extra", id="too-many-subtags"), + ], +) +def test_translate_payload_rejects_invalid_output_language( + output_language: str, +) -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises( + ValidationError, + match=re.escape(OUTPUT_LANGUAGE_ERROR), + ): + TranslatePdfTextPayload.model_validate( + {"files": [file_repr], "output_language": output_language} + ) + + def test_translate_payload_requires_target_language() -> None: file_repr = make_pdf_file(PdfRestFileID.generate(1)) with pytest.raises(ValidationError): diff --git a/uv.lock b/uv.lock index ba0e7705..aa0a1f3c 100644 --- a/uv.lock +++ b/uv.lock @@ -439,6 +439,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "langcodes" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/f9edc5d72945019312f359e69ded9f82392a81d49c5051ed3209b100c0d2/langcodes-3.5.1.tar.gz", hash = "sha256:40bff315e01b01d11c2ae3928dd4f5cbd74dd38f9bd912c12b9a3606c143f731", size = 191084, upload-time = "2025-12-02T16:22:01.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/c1/d10b371bcba7abce05e2b33910e39c33cfa496a53f13640b7b8e10bb4d2b/langcodes-3.5.1-py3-none-any.whl", hash = "sha256:b6a9c25c603804e2d169165091d0cdb23934610524a21d226e4f463e8e958a72", size = 183050, upload-time = "2025-12-02T16:21:59.954Z" }, +] + [[package]] name = "license-expression" version = "30.4.4" @@ -601,6 +610,7 @@ source = { editable = "." } dependencies = [ { name = "exceptiongroup" }, { name = "httpx" }, + { name = "langcodes" }, { name = "pydantic" }, ] @@ -626,6 +636,7 @@ dev = [ requires-dist = [ { name = "exceptiongroup", specifier = ">=1.3.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "langcodes", specifier = ">=3.4.0" }, { name = "pydantic", specifier = ">=2.12.0" }, ] From d57d39dce70ecb6c6e28724adef79a04d9ad2f43 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 13 Jan 2026 10:51:37 -0600 Subject: [PATCH 60/84] Update JPEG test expectations regarding default values Assisted-by: Codex --- tests/test_convert_to_jpeg.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_convert_to_jpeg.py b/tests/test_convert_to_jpeg.py index 46e5f648..d0f5b047 100644 --- a/tests/test_convert_to_jpeg.py +++ b/tests/test_convert_to_jpeg.py @@ -83,7 +83,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert str(output_file.url).endswith(output_id) -def test_convert_to_jpeg_defaults_excluded(monkeypatch: pytest.MonkeyPatch) -> None: +def test_convert_to_jpeg_defaults_included(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) output_id = "8e9f0011-2222-4bcd-9f00-abcdefabcdef" @@ -98,7 +98,9 @@ def handler(request: httpx.Request) -> httpx.Response: assert_conversion_payload( payload, request_payload, allowed_extras={"jpeg_quality"} ) - assert "jpeg_quality" not in payload + assert payload["jpeg_quality"] == 75 + assert payload["resolution"] == 300 + assert payload["color_model"] == "rgb" return httpx.Response( 200, json={"inputId": [input_file.id], "outputId": [output_id]}, @@ -457,7 +459,7 @@ def test_convert_to_jpeg_sequence_arguments(monkeypatch: pytest.MonkeyPatch) -> "page_range": "1, 3", "smoothing": "text", } - ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + ).model_dump(mode="json", by_alias=True, exclude_none=True) seen: dict[str, int] = {"post": 0, "get": 0} From ce5e2decfcc426e9c92fa0e9c3186fdf622d0ed7 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 13 Jan 2026 10:55:08 -0600 Subject: [PATCH 61/84] Adjust PDF/A tests to expect `rasterize_if_errors_encountered` default Assisted-by: Codex --- tests/test_convert_to_pdfa.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/test_convert_to_pdfa.py b/tests/test_convert_to_pdfa.py index c678af17..dea42a35 100644 --- a/tests/test_convert_to_pdfa.py +++ b/tests/test_convert_to_pdfa.py @@ -36,8 +36,13 @@ def test_convert_to_pdfa_success( input_file = make_pdf_file(PdfRestFileID.generate(1)) output_id = str(PdfRestFileID.generate()) payload_dump = PdfToPdfaPayload.model_validate( - {"files": [input_file], "output_type": output_type, "output": "archive"} - ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + { + "files": [input_file], + "output_type": output_type, + "output": "archive", + "rasterize_if_errors_encountered": "off", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True) seen: dict[str, int] = {"post": 0, "get": 0} @@ -99,8 +104,12 @@ async def test_async_convert_to_pdfa_success( input_file = make_pdf_file(PdfRestFileID.generate(2)) output_id = str(PdfRestFileID.generate()) payload_dump = PdfToPdfaPayload.model_validate( - {"files": [input_file], "output_type": output_type} - ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + { + "files": [input_file], + "output_type": output_type, + "rasterize_if_errors_encountered": "off", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True) seen: dict[str, int] = {"post": 0, "get": 0} From 4ba03448562c53d85a2bbffef86d83fc2b81a8bf Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 14 Jan 2026 15:47:40 -0600 Subject: [PATCH 62/84] scripts: Add `check_test_parity.sh` to verify sync-async test coverage - Introduced a new Bash script to check test coverage parity for sync and async test cases. - Compares modified test files between two Git references and identifies missing sync/async counterparts and payload-style tests. - Generates a detailed test parity report, including total tests, sync and async counts, and counterpart gaps. - Ensures script cleanliness and removes temporary files after execution. Assisted-by: Codex --- scripts/check_test_parity.sh | 158 +++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100755 scripts/check_test_parity.sh diff --git a/scripts/check_test_parity.sh b/scripts/check_test_parity.sh new file mode 100755 index 00000000..6c1a787c --- /dev/null +++ b/scripts/check_test_parity.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +base_ref="${1:-upstream/main}" +head_ref="${2:-HEAD}" + +if ! git rev-parse --verify "$base_ref" > /dev/null 2>&1; then + echo "Base ref '$base_ref' not found." >&2 + exit 1 +fi + +if ! git rev-parse --verify "$head_ref" > /dev/null 2>&1; then + echo "Head ref '$head_ref' not found." >&2 + exit 1 +fi + +mapfile -t test_files < <( + git diff --name-only "$base_ref..$head_ref" -- tests | grep -E '\.py$' || true +) + +if [[ ${#test_files[@]} -eq 0 ]]; then + echo "No changed test files under tests/ for $base_ref..$head_ref." + exit 0 +fi + +tmp_output="$(mktemp)" +tmp_tests="$(mktemp)" +tmp_counts="$(mktemp)" +tmp_missing_sync="$(mktemp)" +tmp_missing_async="$(mktemp)" +tmp_payload="$(mktemp)" +trap 'rm -f "$tmp_output" "$tmp_tests" "$tmp_counts" "$tmp_missing_sync" "$tmp_missing_async" "$tmp_payload"' EXIT + +echo "Running pytest on changed tests:" +printf ' - %s\n' "${test_files[@]}" + +uv run pytest -vv -rA -n auto "${test_files[@]}" | tee "$tmp_output" + +awk ' +{ + line = $0; + sub(/^\[[^]]+\][[:space:]]+/, "", line); + if (line ~ /^(PASSED|FAILED|SKIPPED|XFAIL|XPASS|ERROR)[[:space:]]+tests\/.*::/) { + sub(/^(PASSED|FAILED|SKIPPED|XFAIL|XPASS|ERROR)[[:space:]]+/, "", line); + print line; + } else if (line ~ /^tests\/.*::.*[[:space:]]+(PASSED|FAILED|SKIPPED|XFAIL|XPASS|ERROR)$/) { + sub(/[[:space:]]+(PASSED|FAILED|SKIPPED|XFAIL|XPASS|ERROR)$/, "", line); + print line; + } +} +' "$tmp_output" > "$tmp_tests" + +if [[ ! -s "$tmp_tests" ]]; then + echo "No test node IDs detected in pytest output; try rerunning with -vv." >&2 + exit 1 +fi + +awk -v sync_file="$tmp_missing_sync" \ + -v async_file="$tmp_missing_async" \ + -v payload_file="$tmp_payload" \ + -v counts_file="$tmp_counts" ' +function is_async(nodeid) { + return (nodeid ~ /::test_.*async_/); +} +function normalize(nodeid) { + sub(/::test_live_async_/, "::test_live_", nodeid); + sub(/::test_async_/, "::test_", nodeid); + return nodeid; +} +{ + total++; + if ($0 ~ /::test_.*(payload|validation)/) { + payload_like[$0] = 1; + } + if (is_async($0)) { + async_count++; + norm = normalize($0); + async_norm[norm] = 1; + async_orig[norm] = $0; + } else { + sync_count++; + norm = normalize($0); + sync_norm[norm] = 1; + sync_orig[norm] = $0; + } +} +END { + missing_sync = 0; + missing_async = 0; + + for (n in async_norm) { + if (!(n in sync_norm)) { + missing_sync++; + print async_orig[n] >> sync_file; + } + } + for (n in sync_norm) { + if (!(n in async_norm)) { + missing_async++; + print sync_orig[n] >> async_file; + } + } + payload_count = 0; + for (t in payload_like) { + payload_count++; + print t >> payload_file; + } + + print "total=" total > counts_file; + print "sync_count=" sync_count >> counts_file; + print "async_count=" async_count >> counts_file; + print "missing_sync=" missing_sync >> counts_file; + print "missing_async=" missing_async >> counts_file; + print "payload_count=" payload_count >> counts_file; +} +' "$tmp_tests" + +total=0 +sync_count=0 +async_count=0 +missing_sync=0 +missing_async=0 +payload_count=0 +while IFS='=' read -r key value; do + case "$key" in + total) total="$value" ;; + sync_count) sync_count="$value" ;; + async_count) async_count="$value" ;; + missing_sync) missing_sync="$value" ;; + missing_async) missing_async="$value" ;; + payload_count) payload_count="$value" ;; + esac +done < "$tmp_counts" + +echo "" +echo "Test parity report" +echo "Total tests: $total" +echo "Sync tests: $sync_count" +echo "Async tests: $async_count" +echo "Missing sync counterparts: $missing_sync" +if [[ "$missing_sync" -gt 0 ]]; then + sort "$tmp_missing_sync" | while read -r line; do + echo " - $line" + done +fi +echo "Missing async counterparts: $missing_async" +if [[ "$missing_async" -gt 0 ]]; then + sort "$tmp_missing_async" | while read -r line; do + echo " - $line" + done +fi +echo "Payload/validation-style tests (name contains payload/validation): $payload_count" +if [[ "$payload_count" -gt 0 ]]; then + sort "$tmp_payload" | while read -r line; do + echo " - $line" + done +fi From a8a1551d1554e4b8d8161726c842ae8ecef878f6 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 14 Jan 2026 15:51:06 -0600 Subject: [PATCH 63/84] docs: Add `check_test_parity.sh` usage across documentation - Updated `AGENTS.md`, `README.md`, and `TESTING_GUIDELINES.md` with references and instructions for using the `scripts/check_test_parity.sh` script. - Emphasized the importance of maintaining sync/async parity in tests. - Included examples of the script's usage and default behavior for clarity. Assisted-by: Codex --- AGENTS.md | 3 +++ README.md | 6 ++++++ TESTING_GUIDELINES.md | 3 +++ 3 files changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f6e26cc3..1f55440a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,9 @@ - `uv run pre-commit run --all-files` — enforce formatting and lint rules before pushing. - `uv run pytest` — execute the suite with the active interpreter. +- `scripts/check_test_parity.sh` — run changed tests and report sync/async + parity gaps (accepts optional base/head refs, defaults to + `upstream/main..HEAD`). - `uv build` — produce wheels and sdists identical to the release workflow. - `uvx nox -s tests` — create matrix virtualenvs via nox and execute the pytest session. diff --git a/README.md b/README.md index 99ff6936..3a454d4f 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,9 @@ Run the test suite with: ```bash uv run pytest ``` + +Check sync/async parity for changed tests (defaults to `upstream/main..HEAD`): + +```bash +scripts/check_test_parity.sh +``` diff --git a/TESTING_GUIDELINES.md b/TESTING_GUIDELINES.md index c0852a26..f2603b20 100644 --- a/TESTING_GUIDELINES.md +++ b/TESTING_GUIDELINES.md @@ -13,6 +13,9 @@ iteration required. request customization, validation failures, file helpers, and live calls. Do not hide the transport behind a parameter; the test name itself should reveal which client is under test. +- **Check parity regularly.** Run `scripts/check_test_parity.sh` (defaults to + `upstream/main..HEAD`) to spot missing sync/async counterparts, keeping + parameterized test IDs aligned between transports. - **Exercise both sides of the contract.** Hermetic tests (via `httpx.MockTransport`) validate serialization and local validation. Live suites prove the server behaves the same way, including invalid literal From 87932e094563f6ee1aa6851d36c89697a7db5031 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 14 Jan 2026 16:18:46 -0600 Subject: [PATCH 64/84] Set image `smoothing` to `"none"` by default, per pdfRest Assisted-by: Codex --- src/pdfrest/client.py | 50 ++++++++++++++-------------------- tests/graphics_test_helpers.py | 4 ++- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index bc640278..0035227c 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -2888,7 +2888,7 @@ def convert_to_png( page_range: str | Sequence[str] | None = None, resolution: int = 300, color_model: PngColorModel = "rgb", - smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -2900,13 +2900,12 @@ def convert_to_png( "files": files, "resolution": resolution, "color_model": color_model, + "smoothing": smoothing, } if output_prefix is not None: payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing return self._convert_to_graphic( endpoint="/png", @@ -2926,7 +2925,7 @@ def convert_to_bmp( page_range: str | Sequence[str] | None = None, resolution: int = 300, color_model: BmpColorModel = "rgb", - smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -2938,13 +2937,12 @@ def convert_to_bmp( "files": files, "resolution": resolution, "color_model": color_model, + "smoothing": smoothing, } if output_prefix is not None: payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing return self._convert_to_graphic( endpoint="/bmp", @@ -2964,7 +2962,7 @@ def convert_to_gif( page_range: str | Sequence[str] | None = None, resolution: int = 300, color_model: GifColorModel = "rgb", - smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -2976,13 +2974,12 @@ def convert_to_gif( "files": files, "resolution": resolution, "color_model": color_model, + "smoothing": smoothing, } if output_prefix is not None: payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing return self._convert_to_graphic( endpoint="/gif", @@ -3002,7 +2999,7 @@ def convert_to_jpeg( page_range: str | Sequence[str] | None = None, resolution: int = 300, color_model: JpegColorModel = "rgb", - smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", jpeg_quality: int = 75, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -3015,14 +3012,13 @@ def convert_to_jpeg( "files": files, "resolution": resolution, "color_model": color_model, + "smoothing": smoothing, "jpeg_quality": jpeg_quality, } if output_prefix is not None: payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing return self._convert_to_graphic( endpoint="/jpg", @@ -3042,7 +3038,7 @@ def convert_to_tiff( page_range: str | Sequence[str] | None = None, resolution: int = 300, color_model: TiffColorModel = "rgb", - smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -3054,13 +3050,12 @@ def convert_to_tiff( "files": files, "resolution": resolution, "color_model": color_model, + "smoothing": smoothing, } if output_prefix is not None: payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing return self._convert_to_graphic( endpoint="/tif", @@ -3931,7 +3926,7 @@ async def convert_to_png( page_range: str | Sequence[str] | None = None, resolution: int = 300, color_model: PngColorModel = "rgb", - smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -3943,13 +3938,12 @@ async def convert_to_png( "files": files, "resolution": resolution, "color_model": color_model, + "smoothing": smoothing, } if output_prefix is not None: payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing return await self._convert_to_graphic( endpoint="/png", @@ -3969,7 +3963,7 @@ async def convert_to_bmp( page_range: str | Sequence[str] | None = None, resolution: int = 300, color_model: BmpColorModel = "rgb", - smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -3981,13 +3975,12 @@ async def convert_to_bmp( "files": files, "resolution": resolution, "color_model": color_model, + "smoothing": smoothing, } if output_prefix is not None: payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing return await self._convert_to_graphic( endpoint="/bmp", @@ -4007,7 +4000,7 @@ async def convert_to_gif( page_range: str | Sequence[str] | None = None, resolution: int = 300, color_model: GifColorModel = "rgb", - smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -4019,13 +4012,12 @@ async def convert_to_gif( "files": files, "resolution": resolution, "color_model": color_model, + "smoothing": smoothing, } if output_prefix is not None: payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing return await self._convert_to_graphic( endpoint="/gif", @@ -4045,7 +4037,7 @@ async def convert_to_jpeg( page_range: str | Sequence[str] | None = None, resolution: int = 300, color_model: JpegColorModel = "rgb", - smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", jpeg_quality: int = 75, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -4058,14 +4050,13 @@ async def convert_to_jpeg( "files": files, "resolution": resolution, "color_model": color_model, + "smoothing": smoothing, "jpeg_quality": jpeg_quality, } if output_prefix is not None: payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing return await self._convert_to_graphic( endpoint="/jpg", @@ -4085,7 +4076,7 @@ async def convert_to_tiff( page_range: str | Sequence[str] | None = None, resolution: int = 300, color_model: TiffColorModel = "rgb", - smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] | None = None, + smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -4097,13 +4088,12 @@ async def convert_to_tiff( "files": files, "resolution": resolution, "color_model": color_model, + "smoothing": smoothing, } if output_prefix is not None: payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range - if smoothing is not None: - payload["smoothing"] = smoothing return await self._convert_to_graphic( endpoint="/tif", diff --git a/tests/graphics_test_helpers.py b/tests/graphics_test_helpers.py index 8fff8bfa..b94d5fc3 100644 --- a/tests/graphics_test_helpers.py +++ b/tests/graphics_test_helpers.py @@ -49,7 +49,7 @@ def assert_conversion_payload( for key, value in expected.items(): assert payload[key] == value extra_keys = set(payload) - set(expected) - permitted = {"color_model", "resolution"} + permitted = {"color_model", "resolution", "smoothing"} if allowed_extras is not None: permitted.update(allowed_extras) assert extra_keys <= permitted @@ -57,3 +57,5 @@ def assert_conversion_payload( assert payload["resolution"] == 300 if "color_model" not in expected and "color_model" in payload: assert payload["color_model"] == "rgb" + if "smoothing" not in expected and "smoothing" in payload: + assert payload["smoothing"] == "none" From e003088c3c411dbf1c6ecd5c8505c96e1c125df6 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Wed, 14 Jan 2026 17:11:18 -0600 Subject: [PATCH 65/84] Modify test parity check to be runnable on Mac - Script used `mapfile` command, which is unavailable on Mac bash - Replace with `while` loop intended to fulfill the same role Assisted-by: Codex --- scripts/check_test_parity.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/check_test_parity.sh b/scripts/check_test_parity.sh index 6c1a787c..dd90ce9f 100755 --- a/scripts/check_test_parity.sh +++ b/scripts/check_test_parity.sh @@ -15,7 +15,12 @@ if ! git rev-parse --verify "$head_ref" > /dev/null 2>&1; then exit 1 fi -mapfile -t test_files < <( +test_files=() +while IFS= read -r file; do + if [[ -n "$file" ]]; then + test_files+=("$file") + fi +done < <( git diff --name-only "$base_ref..$head_ref" -- tests | grep -E '\.py$' || true ) From 432ccfaae37135a1c71222d132f44ad556a76329 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 11:09:59 -0600 Subject: [PATCH 66/84] Add missing unit tests Resolve discrepancies in unit tests between sync and async Assisted-by: Codex --- tests/test_convert_to_jpeg.py | 14 ++++++++--- tests/test_convert_to_pdfa.py | 10 ++++---- tests/test_summarize_pdf_text.py | 34 +++++++++++++++++++++++++++ tests/test_translate_pdf_text.py | 40 ++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/tests/test_convert_to_jpeg.py b/tests/test_convert_to_jpeg.py index d0f5b047..363e78ed 100644 --- a/tests/test_convert_to_jpeg.py +++ b/tests/test_convert_to_jpeg.py @@ -244,8 +244,16 @@ def handler(_: httpx.Request) -> httpx.Response: @pytest.mark.asyncio +@pytest.mark.parametrize( + "color_model", + [ + pytest.param("rgb", id="rgb"), + pytest.param("cmyk", id="cmyk"), + pytest.param("gray", id="gray"), + ], +) async def test_async_convert_to_jpeg_success( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, color_model: str ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) @@ -257,7 +265,7 @@ async def test_async_convert_to_jpeg_success( "output_prefix": "async-output", "page_range": "1-2", "resolution": 500, - "color_model": "gray", + "color_model": color_model, "jpeg_quality": 85, "smoothing": ["all"], } @@ -295,7 +303,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="async-output", page_range="1-2", resolution=500, - color_model="gray", + color_model=color_model, smoothing=["all"], jpeg_quality=85, ) diff --git a/tests/test_convert_to_pdfa.py b/tests/test_convert_to_pdfa.py index dea42a35..0c3af307 100644 --- a/tests/test_convert_to_pdfa.py +++ b/tests/test_convert_to_pdfa.py @@ -90,11 +90,11 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize( "output_type", [ - pytest.param("PDF/A-1b", id="async-pdfa-1b"), - pytest.param("PDF/A-2b", id="async-pdfa-2b"), - pytest.param("PDF/A-2u", id="async-pdfa-2u"), - pytest.param("PDF/A-3b", id="async-pdfa-3b"), - pytest.param("PDF/A-3u", id="async-pdfa-3u"), + pytest.param("PDF/A-1b", id="pdfa-1b"), + pytest.param("PDF/A-2b", id="pdfa-2b"), + pytest.param("PDF/A-2u", id="pdfa-2u"), + pytest.param("PDF/A-3b", id="pdfa-3b"), + pytest.param("PDF/A-3u", id="pdfa-3u"), ], ) async def test_async_convert_to_pdfa_success( diff --git a/tests/test_summarize_pdf_text.py b/tests/test_summarize_pdf_text.py index 4263c488..8cdea8f8 100644 --- a/tests/test_summarize_pdf_text.py +++ b/tests/test_summarize_pdf_text.py @@ -245,6 +245,40 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(0.25) +def test_summarize_text_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = SummarizePdfTextPayload.model_validate( + {"files": [input_file], "output_type": "json"} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/summarized-pdf-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + for key, value in payload_dump.items(): + assert payload[key] == value + return httpx.Response( + 200, + json={ + "summary": "Sync summary", + "inputId": str(input_file.id), + }, + ) + 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.summarize_text(input_file) + + assert seen == {"post": 1} + assert isinstance(response, SummarizePdfTextResponse) + assert response.summary == "Sync summary" + + @pytest.mark.asyncio async def test_async_summarize_text_success( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py index 7cfe5c76..fe755a16 100644 --- a/tests/test_translate_pdf_text.py +++ b/tests/test_translate_pdf_text.py @@ -237,6 +237,46 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(0.3) +def test_translate_pdf_text_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = TranslatePdfTextPayload.model_validate( + {"files": [input_file], "output_language": "de", "output_type": "json"} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/translated-pdf-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + for key, value in payload_dump.items(): + assert payload[key] == value + return httpx.Response( + 200, + json={ + "translated_text": "Hallo", + "inputId": str(input_file.id), + "source_languages": ["en"], + "output_language": "de", + }, + ) + 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.translate_pdf_text( + input_file, + output_language="de", + ) + + assert seen == {"post": 1} + assert isinstance(response, TranslatePdfTextResponse) + assert response.translated_text == "Hallo" + assert response.source_languages == ["en"] + + @pytest.mark.asyncio async def test_async_translate_pdf_text_success( monkeypatch: pytest.MonkeyPatch, From c3a1c1fefbf6a4c2ae91700173da44ce557b3d5b Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 11:21:28 -0600 Subject: [PATCH 67/84] Fill in missing live tests of conversion methods Assisted-by: Codex --- tests/live/test_live_convert_to_excel.py | 19 +++++++++-- tests/live/test_live_convert_to_pdfa.py | 11 ++++++- tests/live/test_live_convert_to_pdfx.py | 11 ++++++- tests/live/test_live_convert_to_powerpoint.py | 19 +++++++++-- tests/live/test_live_convert_to_word.py | 19 +++++++++-- .../test_live_convert_xfa_to_acroforms.py | 19 +++++++++-- tests/live/test_live_graphic_conversions.py | 33 +++++++++++++++++-- 7 files changed, 119 insertions(+), 12 deletions(-) diff --git a/tests/live/test_live_convert_to_excel.py b/tests/live/test_live_convert_to_excel.py index f592aa40..3816af34 100644 --- a/tests/live/test_live_convert_to_excel.py +++ b/tests/live/test_live_convert_to_excel.py @@ -60,20 +60,35 @@ def test_live_convert_to_excel_success( @pytest.mark.asyncio +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("async-excel", id="custom-output"), + ], +) async def test_live_async_convert_to_excel_success( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_excel: PdfRestFile, + output_name: str | None, ) -> None: + kwargs: dict[str, str] = {} + if output_name is not None: + kwargs["output"] = output_name + async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - response = await client.convert_to_excel(uploaded_pdf_for_excel, output="async") + response = await client.convert_to_excel(uploaded_pdf_for_excel, **kwargs) assert response.output_files output_file = response.output_file - assert output_file.name.startswith("async") + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".xlsx") assert ( output_file.type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" diff --git a/tests/live/test_live_convert_to_pdfa.py b/tests/live/test_live_convert_to_pdfa.py index 5d39d009..9cad65e4 100644 --- a/tests/live/test_live_convert_to_pdfa.py +++ b/tests/live/test_live_convert_to_pdfa.py @@ -130,10 +130,19 @@ def test_live_convert_to_pdfa_invalid_output_type( @pytest.mark.asyncio +@pytest.mark.parametrize( + "invalid_output_type", + [ + pytest.param("PDF/A-0", id="pdfa-0"), + pytest.param("PDF/A-99", id="pdfa-99"), + pytest.param("pdf/a-2b", id="lowercase"), + ], +) async def test_live_async_convert_to_pdfa_invalid_output_type( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_pdfa: PdfRestFile, + invalid_output_type: str, ) -> None: async with AsyncPdfRestClient( api_key=pdfrest_api_key, @@ -143,5 +152,5 @@ async def test_live_async_convert_to_pdfa_invalid_output_type( await client.convert_to_pdfa( uploaded_pdf_for_pdfa, output_type="PDF/A-1b", - extra_body={"output_type": "PDF/A-0"}, + extra_body={"output_type": invalid_output_type}, ) diff --git a/tests/live/test_live_convert_to_pdfx.py b/tests/live/test_live_convert_to_pdfx.py index df0e6695..2e02ee42 100644 --- a/tests/live/test_live_convert_to_pdfx.py +++ b/tests/live/test_live_convert_to_pdfx.py @@ -104,10 +104,19 @@ def test_live_convert_to_pdfx_invalid_output_type( @pytest.mark.asyncio +@pytest.mark.parametrize( + "invalid_output_type", + [ + pytest.param("PDF/X-0", id="pdfx-0"), + pytest.param("PDF/X-99", id="pdfx-99"), + pytest.param("pdf/x-4", id="lowercase"), + ], +) async def test_live_async_convert_to_pdfx_invalid_output_type( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_pdfx: PdfRestFile, + invalid_output_type: str, ) -> None: async with AsyncPdfRestClient( api_key=pdfrest_api_key, @@ -117,5 +126,5 @@ async def test_live_async_convert_to_pdfx_invalid_output_type( await client.convert_to_pdfx( uploaded_pdf_for_pdfx, output_type="PDF/X-1a", - extra_body={"output_type": "PDF/X-0"}, + extra_body={"output_type": invalid_output_type}, ) diff --git a/tests/live/test_live_convert_to_powerpoint.py b/tests/live/test_live_convert_to_powerpoint.py index 8a1209a2..248c04e7 100644 --- a/tests/live/test_live_convert_to_powerpoint.py +++ b/tests/live/test_live_convert_to_powerpoint.py @@ -60,22 +60,37 @@ def test_live_convert_to_powerpoint_success( @pytest.mark.asyncio +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("async-powerpoint", id="custom-output"), + ], +) async def test_live_async_convert_to_powerpoint_success( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_powerpoint: PdfRestFile, + output_name: str | None, ) -> None: + kwargs: dict[str, str] = {} + if output_name is not None: + kwargs["output"] = output_name + async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: response = await client.convert_to_powerpoint( - uploaded_pdf_for_powerpoint, output="async" + uploaded_pdf_for_powerpoint, **kwargs ) assert response.output_files output_file = response.output_file - assert output_file.name.startswith("async") + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pptx") assert ( output_file.type == "application/vnd.openxmlformats-officedocument.presentationml.presentation" diff --git a/tests/live/test_live_convert_to_word.py b/tests/live/test_live_convert_to_word.py index 3ec6a334..dcebe926 100644 --- a/tests/live/test_live_convert_to_word.py +++ b/tests/live/test_live_convert_to_word.py @@ -58,23 +58,38 @@ def test_live_convert_to_word_success( @pytest.mark.asyncio +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("async-word", id="custom-output"), + ], +) async def test_live_async_convert_to_word_success( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_word: PdfRestFile, + output_name: str | None, ) -> None: + kwargs: dict[str, str] = {} + if output_name is not None: + kwargs["output"] = output_name + async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: response = await client.convert_to_word( uploaded_pdf_for_word, - output="async-word", + **kwargs, ) assert response.output_files output_file = response.output_file - assert output_file.name.startswith("async-word") + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".docx") assert ( output_file.type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" diff --git a/tests/live/test_live_convert_xfa_to_acroforms.py b/tests/live/test_live_convert_xfa_to_acroforms.py index dba38304..889c970b 100644 --- a/tests/live/test_live_convert_xfa_to_acroforms.py +++ b/tests/live/test_live_convert_xfa_to_acroforms.py @@ -83,17 +83,29 @@ def test_live_convert_xfa_to_acroforms_invalid_file_id( @pytest.mark.asyncio +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("async-acroforms", id="custom-output"), + ], +) async def test_live_async_convert_xfa_to_acroforms_success( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_acroforms: PdfRestFile, + output_name: str | None, ) -> None: + kwargs: dict[str, str] = {} + if output_name is not None: + kwargs["output"] = output_name + async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: response = await client.convert_xfa_to_acroforms( - uploaded_pdf_for_acroforms, output="async" + uploaded_pdf_for_acroforms, **kwargs ) assert str(response.input_id) == str(uploaded_pdf_for_acroforms.id) @@ -104,7 +116,10 @@ async def test_live_async_convert_xfa_to_acroforms_success( assert response.output_files output_file = response.output_file - assert output_file.name.startswith("async") + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pdf") assert output_file.type == "application/pdf" assert output_file.size > 0 diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index a78f8d0f..51c1b47a 100644 --- a/tests/live/test_live_graphic_conversions.py +++ b/tests/live/test_live_graphic_conversions.py @@ -121,6 +121,27 @@ def uploaded_20_page_pdf( return client.files.create_from_paths([resource])[0] +def test_live_convert_to_png_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + response = client.convert_to_png( + uploaded, + output_prefix="live-png", + resolution=150, + ) + + assert response.output_files + assert all(file_info.type == "image/png" for file_info in response.output_files) + assert str(response.input_id) == str(uploaded.id) + + @pytest.mark.asyncio async def test_live_async_convert_to_png_success( pdfrest_api_key: str, @@ -292,9 +313,16 @@ def test_live_graphic_invalid_smoothing( @pytest.mark.asyncio +@pytest.mark.parametrize( + ("_endpoint_label", "spec", "invalid_smoothing"), + _invalid_smoothing_cases(), +) async def test_live_async_graphic_invalid_smoothing( pdfrest_api_key: str, pdfrest_live_base_url: str, + _endpoint_label: str, + spec: _GraphicEndpointSpec, + invalid_smoothing: Any, ) -> None: resource = get_test_resource_path("report.pdf") async with AsyncPdfRestClient( @@ -302,11 +330,12 @@ async def test_live_async_graphic_invalid_smoothing( base_url=pdfrest_live_base_url, ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] + client_method = getattr(client, spec.method_name) with pytest.raises(PdfRestApiError, match=r"(?i)smooth"): - await client.convert_to_png( + await client_method( uploaded, smoothing="none", - extra_body={"smoothing": "super-smooth"}, + extra_body={"smoothing": invalid_smoothing}, ) From ff5a49652222b07b57a0ae3607c96e53a01c5b99 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 13:25:29 -0600 Subject: [PATCH 68/84] Live flatten/linearize/rasterize: add async variants, missing sync tests Assisted-by: Codex --- tests/live/test_live_flatten_annotations.py | 19 ++- tests/live/test_live_flatten_pdf_forms.py | 21 +++- .../live/test_live_flatten_transparencies.py | 20 ++- tests/live/test_live_linearize_pdf.py | 19 ++- tests/live/test_live_pdf_info.py | 17 +++ tests/live/test_live_pdf_redactions.py | 119 ++++++++++++++++-- tests/live/test_live_pdf_split_merge.py | 2 +- tests/live/test_live_rasterize_pdf.py | 21 +++- 8 files changed, 215 insertions(+), 23 deletions(-) diff --git a/tests/live/test_live_flatten_annotations.py b/tests/live/test_live_flatten_annotations.py index b97b08b0..27cd0934 100644 --- a/tests/live/test_live_flatten_annotations.py +++ b/tests/live/test_live_flatten_annotations.py @@ -57,22 +57,37 @@ def test_live_flatten_annotations_success( @pytest.mark.asyncio +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("flatten-annotations", id="custom-output"), + ], +) async def test_live_async_flatten_annotations_success( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_annotations: PdfRestFile, + output_name: str | None, ) -> None: + kwargs: dict[str, str] = {} + if output_name is not None: + kwargs["output"] = output_name + async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: response = await client.flatten_annotations( - uploaded_pdf_for_annotations, output="async" + uploaded_pdf_for_annotations, **kwargs ) assert response.output_files output_file = response.output_file - assert output_file.name.startswith("async") + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pdf") assert output_file.type == "application/pdf" assert output_file.size > 0 assert response.warning is None diff --git a/tests/live/test_live_flatten_pdf_forms.py b/tests/live/test_live_flatten_pdf_forms.py index 5bff7304..2c6b939f 100644 --- a/tests/live/test_live_flatten_pdf_forms.py +++ b/tests/live/test_live_flatten_pdf_forms.py @@ -55,23 +55,38 @@ def test_live_flatten_pdf_forms( @pytest.mark.asyncio -async def test_live_async_flatten_pdf_forms_success( +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("flattened-live", id="custom-output"), + ], +) +async def test_live_async_flatten_pdf_forms( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_with_forms: PdfRestFile, + output_name: str | None, ) -> None: + kwargs: dict[str, str] = {} + if output_name is not None: + kwargs["output"] = output_name + async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: response = await client.flatten_pdf_forms( uploaded_pdf_with_forms, - output="async-flattened", + **kwargs, ) assert response.output_files output_file = response.output_file - assert output_file.name.startswith("async-flattened") + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pdf") assert output_file.type == "application/pdf" assert str(response.input_id) == str(uploaded_pdf_with_forms.id) diff --git a/tests/live/test_live_flatten_transparencies.py b/tests/live/test_live_flatten_transparencies.py index 7da1eb40..2438e68b 100644 --- a/tests/live/test_live_flatten_transparencies.py +++ b/tests/live/test_live_flatten_transparencies.py @@ -60,22 +60,38 @@ def test_live_flatten_transparencies_success( @pytest.mark.asyncio +@pytest.mark.parametrize( + ("output_name", "quality"), + [ + pytest.param(None, "medium", id="default-output"), + pytest.param("flatten-transparency", "high", id="custom-output-high"), + ], +) async def test_live_async_flatten_transparencies_success( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_transparencies: PdfRestFile, + output_name: str | None, + quality: str, ) -> None: + kwargs: dict[str, str] = {"quality": quality} + if output_name is not None: + kwargs["output"] = output_name + async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: response = await client.flatten_transparencies( - uploaded_pdf_for_transparencies, output="async", quality="low" + uploaded_pdf_for_transparencies, **kwargs ) assert response.output_files output_file = response.output_file - assert output_file.name.startswith("async") + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pdf") assert output_file.type == "application/pdf" assert output_file.size > 0 assert response.warning is None diff --git a/tests/live/test_live_linearize_pdf.py b/tests/live/test_live_linearize_pdf.py index 523ea0d5..1d43f9b2 100644 --- a/tests/live/test_live_linearize_pdf.py +++ b/tests/live/test_live_linearize_pdf.py @@ -57,23 +57,38 @@ def test_live_linearize_pdf( @pytest.mark.asyncio +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("linearized-live", id="custom-output"), + ], +) async def test_live_async_linearize_pdf( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_linearize: PdfRestFile, + output_name: str | None, ) -> None: + kwargs: dict[str, str] = {} + if output_name is not None: + kwargs["output"] = output_name + async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: response = await client.linearize_pdf( uploaded_pdf_for_linearize, - output="async-linearized", + **kwargs, ) assert response.output_files output_file = response.output_file - assert output_file.name.startswith("async-linearized") + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pdf") assert output_file.type == "application/pdf" assert output_file.size > 0 assert response.warning is None diff --git a/tests/live/test_live_pdf_info.py b/tests/live/test_live_pdf_info.py index 7ec91828..60e050e2 100644 --- a/tests/live/test_live_pdf_info.py +++ b/tests/live/test_live_pdf_info.py @@ -145,6 +145,23 @@ def test_live_pdf_info_multiple_queries( _assert_expected_value(item, getattr(response, item)) +def test_live_pdf_info_all_queries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + response = client.query_pdf_info(uploaded_pdf, queries=ALLOWED_QUERIES) + + assert isinstance(response, PdfRestInfoResponse) + assert str(response.input_id) == str(uploaded_pdf.id) + assert response.all_queries_processed is True + for query in ALLOWED_QUERIES: + _assert_expected_value(query, getattr(response, query)) + + @pytest.mark.asyncio async def test_live_pdf_info_async_all_queries( pdfrest_api_key: str, diff --git a/tests/live/test_live_pdf_redactions.py b/tests/live/test_live_pdf_redactions.py index 3fda6d42..027793db 100644 --- a/tests/live/test_live_pdf_redactions.py +++ b/tests/live/test_live_pdf_redactions.py @@ -136,10 +136,41 @@ def test_live_redaction_preview_and_apply_multiple( @pytest.mark.asyncio -async def test_live_async_redaction_preview_and_apply( +@pytest.mark.parametrize( + "instructions", + [ + pytest.param( + [ + { + "type": "literal", + "value": "The quick brown fox jumped over the lazy dog.", + }, + {"type": "regex", "value": r"\b\d{3}-\d{2}-\d{4}\b"}, + ], + id="literal-and-regex", + ), + pytest.param( + [ + {"type": "preset", "value": "email"}, + {"type": "preset", "value": "phone_number"}, + ], + id="preset-email-and-phone", + ), + pytest.param( + [ + {"type": "preset", "value": "credit_card"}, + {"type": "preset", "value": "bank_routing_number"}, + {"type": "preset", "value": "swift_bic_number"}, + ], + id="multiple-presets", + ), + ], +) +async def test_live_async_redaction_preview_and_apply_multiple( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_redaction: PdfRestFile, + instructions: list[PdfRedactionInstruction], ) -> None: async with AsyncPdfRestClient( api_key=pdfrest_api_key, @@ -147,21 +178,67 @@ async def test_live_async_redaction_preview_and_apply( ) as client: preview = await client.preview_redactions( uploaded_pdf_for_redaction, - redactions=[{"type": "literal", "value": "quick brown fox"}], - output="async-redaction-preview", + redactions=instructions, + output="redaction-preview-multi", ) + assert preview.output_files preview_file = preview.output_files[0] applied = await client.apply_redactions( preview_file, - output="async-redaction-final", + output="redaction-final-multi", + ) + + final_file = applied.output_files[0] + assert final_file.name.endswith("redaction-final-multi.pdf") + assert final_file.type == "application/pdf" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "instruction", + [ + pytest.param( + { + "type": "literal", + "value": "The quick brown fox jumped over the lazy dog.", + }, + id="literal", + ), + pytest.param({"type": "regex", "value": r"\b\d{3}-\d{2}-\d{4}\b"}, id="regex"), + *[ + pytest.param({"type": "preset", "value": preset}, id=f"preset-{preset}") + for preset in get_args(PdfRedactionPreset) + ], + ], +) +async def test_live_async_redaction_preview_and_apply_single( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_redaction: PdfRestFile, + instruction: PdfRedactionInstruction, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + preview = await client.preview_redactions( + uploaded_pdf_for_redaction, + redactions=[instruction], + output="redaction-preview", + ) + + preview_file = preview.output_files[0] + applied = await client.apply_redactions( + preview_file, + output="redaction-final", ) assert preview.output_files - assert preview_file.name.endswith("async-redaction-preview.pdf") + assert preview_file.name.endswith("redaction-preview.pdf") assert applied.output_files final_file = applied.output_files[0] - assert final_file.name.endswith("async-redaction-final.pdf") + assert final_file.name.endswith("redaction-final.pdf") assert final_file.type == "application/pdf" @@ -206,18 +283,42 @@ def test_live_redactions_invalid_payloads( @pytest.mark.asyncio +@pytest.mark.parametrize( + "extra_body", + [ + pytest.param({"redactions": "invalid"}, id="invalid-redactions"), + pytest.param({"rgb_color": "-1,-1,-1"}, id="invalid-rgb"), + ], +) async def test_live_async_redactions_invalid_payloads( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_redaction: PdfRestFile, + extra_body: dict[str, object], ) -> None: async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - with pytest.raises(PdfRestApiError, match=r"(?i)rgb"): - await client.preview_redactions( + if "redactions" in extra_body: + with pytest.raises( + PdfRestApiError, + match=( + r"The JSON data provided is not properly formatted\. Please check " + r"your syntax and try again\." + ), + ): + await client.preview_redactions( + uploaded_pdf_for_redaction, + redactions=[{"type": "literal", "value": "placeholder"}], + extra_body=extra_body, + ) + else: + preview = await client.preview_redactions( uploaded_pdf_for_redaction, redactions=[{"type": "literal", "value": "placeholder"}], - extra_body={"rgb_color": "-1,-1,-1"}, + extra_body=extra_body, ) + preview_file = preview.output_files[0] + with pytest.raises(PdfRestApiError, match=r"(?i)rgb"): + await client.apply_redactions(preview_file, extra_body=extra_body) diff --git a/tests/live/test_live_pdf_split_merge.py b/tests/live/test_live_pdf_split_merge.py index 5a58912c..f81c6834 100644 --- a/tests/live/test_live_pdf_split_merge.py +++ b/tests/live/test_live_pdf_split_merge.py @@ -280,7 +280,7 @@ def test_live_merge_pdfs_invalid_pages( @pytest.mark.asyncio -async def test_live_async_merge_pdfs( +async def test_live_async_merge_pdfs_success( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], diff --git a/tests/live/test_live_rasterize_pdf.py b/tests/live/test_live_rasterize_pdf.py index df7cb260..6ad9fd72 100644 --- a/tests/live/test_live_rasterize_pdf.py +++ b/tests/live/test_live_rasterize_pdf.py @@ -57,22 +57,35 @@ def test_live_rasterize_pdf_success( @pytest.mark.asyncio +@pytest.mark.parametrize( + "output_name", + [ + pytest.param(None, id="default-output"), + pytest.param("rasterized-live", id="custom-output"), + ], +) async def test_live_async_rasterize_pdf_success( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf_for_rasterize: PdfRestFile, + output_name: str | None, ) -> None: + kwargs: dict[str, str] = {} + if output_name is not None: + kwargs["output"] = output_name + async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - response = await client.rasterize_pdf( - uploaded_pdf_for_rasterize, output="async" - ) + response = await client.rasterize_pdf(uploaded_pdf_for_rasterize, **kwargs) assert response.output_files output_file = response.output_file - assert output_file.name.startswith("async") + if output_name is not None: + assert output_file.name.startswith(output_name) + else: + assert output_file.name.endswith(".pdf") assert output_file.type == "application/pdf" assert output_file.size > 0 assert response.warning is None From d4a9999205281ce497c2fc7e8a02f6cf9af6bd60 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 13:30:11 -0600 Subject: [PATCH 69/84] Resolve disparities in Query PDF live tests Assisted-by: Codex --- tests/live/test_live_pdf_info.py | 73 ++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/live/test_live_pdf_info.py b/tests/live/test_live_pdf_info.py index 60e050e2..74dcbd22 100644 --- a/tests/live/test_live_pdf_info.py +++ b/tests/live/test_live_pdf_info.py @@ -93,6 +93,27 @@ def test_live_pdf_info_queries( _assert_expected_value(query_name, value) +@pytest.mark.asyncio +@pytest.mark.parametrize("query_name", ALLOWED_QUERIES, ids=list(ALLOWED_QUERIES)) +async def test_live_pdf_info_async_queries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf: PdfRestFile, + query_name: PdfInfoQuery, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + response = await client.query_pdf_info(uploaded_pdf, queries=query_name) + + assert isinstance(response, PdfRestInfoResponse) + assert str(response.input_id) == str(uploaded_pdf.id) + assert response.all_queries_processed is True + + value = getattr(response, query_name) + _assert_expected_value(query_name, value) + + @pytest.mark.parametrize( "invalid_query", [ @@ -120,6 +141,32 @@ def test_live_pdf_info_invalid_query( ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "invalid_query", + [ + pytest.param("invalid_query", id="invalid-query"), + pytest.param("tagged,!!invalid!!", id="mixed-invalid"), + pytest.param("🚫", id="emoji"), + ], +) +async def test_live_pdf_info_async_invalid_query( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf: PdfRestFile, + invalid_query: 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)quer"): + await client.query_pdf_info( + uploaded_pdf, + queries="tagged", + extra_body={"queries": invalid_query}, + ) + + @pytest.mark.parametrize( "query_group", [ @@ -145,6 +192,32 @@ def test_live_pdf_info_multiple_queries( _assert_expected_value(item, getattr(response, item)) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "query_group", + [ + pytest.param(("tagged", "filename"), id="two-values"), + pytest.param(("page_count", "file_size", "pdf_version"), id="three-values"), + ], +) +async def test_live_pdf_info_async_multiple_queries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf: PdfRestFile, + query_group: tuple[PdfInfoQuery, ...], +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + response = await client.query_pdf_info(uploaded_pdf, queries=query_group) + + assert isinstance(response, PdfRestInfoResponse) + assert str(response.input_id) == str(uploaded_pdf.id) + assert response.all_queries_processed is True + for item in query_group: + _assert_expected_value(item, getattr(response, item)) + + def test_live_pdf_info_all_queries( pdfrest_api_key: str, pdfrest_live_base_url: str, From 8b5caaff4ab5d6bb20eb8dc68a4b817f44bfa9b7 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 13:47:16 -0600 Subject: [PATCH 70/84] Round out async test coverage in live graphic tests --- tests/live/test_live_graphic_conversions.py | 223 +++++++++++++++++++- 1 file changed, 221 insertions(+), 2 deletions(-) diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index 51c1b47a..56d2b610 100644 --- a/tests/live/test_live_graphic_conversions.py +++ b/tests/live/test_live_graphic_conversions.py @@ -191,6 +191,35 @@ def test_live_graphic_valid_color_models( assert response.output_files +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("_endpoint_label", "spec", "color_model"), + _valid_color_cases(), +) +async def test_live_async_graphic_valid_color_models( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + _endpoint_label: str, + spec: _GraphicEndpointSpec, + color_model: str, +) -> None: + resource = get_test_resource_path("report.pdf") + payload_model = spec.payload_model + resolution = _resolution_bounds(payload_model)[0] + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + client_method = getattr(client, spec.method_name) + response = await client_method( + uploaded, + color_model=color_model, + resolution=resolution, + ) + assert response.output_files + + @pytest.mark.parametrize( ("_endpoint_label", "spec", "invalid_color"), _invalid_color_cases(), @@ -219,6 +248,35 @@ def test_live_graphic_invalid_color_model( ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("_endpoint_label", "spec", "invalid_color"), + _invalid_color_cases(), +) +async def test_live_async_graphic_invalid_color_model( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + _endpoint_label: str, + spec: _GraphicEndpointSpec, + invalid_color: str, +) -> None: + payload_model = spec.payload_model + + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + client_method = getattr(client, spec.method_name) + resolution = _resolution_bounds(payload_model)[0] + with pytest.raises(PdfRestApiError, match=r"(?i)color"): + await client_method( + uploaded, + resolution=resolution, + extra_body={"color_model": invalid_color}, + ) + + @pytest.mark.parametrize( ("_endpoint_label", "spec"), PNG_PAYLOAD_ONLY.items(), @@ -263,6 +321,51 @@ def test_live_graphic_resolution_bounds( assert response.output_files +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("_endpoint_label", "spec"), + PNG_PAYLOAD_ONLY.items(), + ids=list(PNG_PAYLOAD_ONLY), +) +@pytest.mark.parametrize( + ("bound", "offset", "should_raise"), + [ + pytest.param("min", 0, False, id="min"), + pytest.param("max", 0, False, id="max"), + pytest.param("min", -1, True, id="below-min"), + pytest.param("max", 1, True, id="above-max"), + ], +) +async def test_live_async_graphic_resolution_bounds( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + _endpoint_label: str, + spec: _GraphicEndpointSpec, + bound: str, + offset: int, + should_raise: bool, +) -> None: + payload_model = spec.payload_model + min_res, max_res = _resolution_bounds(payload_model) + resource = get_test_resource_path("report.pdf") + + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + client_method = getattr(client, spec.method_name) + base_resolution = min_res if bound == "min" else max_res + call_kwargs: dict[str, Any] = {"resolution": base_resolution} + + if should_raise: + call_kwargs["extra_body"] = {"resolution": base_resolution + offset} + with pytest.raises(PdfRestApiError, match=r"(?i)resolution"): + await client_method(uploaded, **call_kwargs) + else: + response = await client_method(uploaded, **call_kwargs) + assert response.output_files + + @pytest.mark.parametrize( ("_endpoint_label", "spec", "smoothing_value"), _valid_smoothing_cases(), @@ -287,6 +390,31 @@ def test_live_graphic_valid_smoothing( assert response.output_files +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("_endpoint_label", "spec", "smoothing_value"), + _valid_smoothing_cases(), +) +async def test_live_async_graphic_valid_smoothing( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + _endpoint_label: str, + spec: _GraphicEndpointSpec, + smoothing_value: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + client_method = getattr(client, spec.method_name) + response = await client_method( + uploaded, + smoothing=smoothing_value, + ) + assert response.output_files + + @pytest.mark.parametrize( ("_endpoint_label", "spec", "invalid_smoothing"), _invalid_smoothing_cases(), @@ -326,8 +454,7 @@ async def test_live_async_graphic_invalid_smoothing( ) -> None: resource = get_test_resource_path("report.pdf") async with AsyncPdfRestClient( - api_key=pdfrest_api_key, - base_url=pdfrest_live_base_url, + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] client_method = getattr(client, spec.method_name) @@ -394,6 +521,62 @@ def test_live_png_page_range_variants( ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("page_range", "expect_success"), + [ + pytest.param("5", True, id="single"), + pytest.param("3-7", True, id="ascending-range"), + pytest.param("last", True, id="last"), + pytest.param("1-last", True, id="entire-document"), + pytest.param(["1", "3", "5-7"], True, id="list-mixed"), + ], +) +async def test_live_async_png_page_range_variants( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_20_page_pdf: PdfRestFile, + page_range: Any, + expect_success: bool, + request: pytest.FixtureRequest, +) -> None: + case_id = request.node.callspec.id + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + info = await client.query_pdf_info(uploaded_20_page_pdf) + + assert info.page_count == 20 + assert str(info.input_id) == str(uploaded_20_page_pdf.id) + assert info.filename is None or info.filename.endswith(".pdf") + + if expect_success: + response = await client.convert_to_png( + uploaded_20_page_pdf, + output_prefix=f"live-async-range-{case_id}", + page_range=page_range, + ) + + expected_pages = _expand_page_selection(page_range, total_pages=20) + assert len(response.output_files) == len(expected_pages) + assert any( + file_info.name.endswith(".png") for file_info in response.output_files + ) + assert all( + file_info.type == "image/png" and file_info.size > 0 + for file_info in response.output_files + ) + assert str(response.input_id) == str(uploaded_20_page_pdf.id) + else: + with pytest.raises(PdfRestApiError, match=r"(?i)page"): + await client.convert_to_png( + uploaded_20_page_pdf, + output_prefix=f"live-async-range-{case_id}", + extra_body={"page_range": page_range}, + ) + + @pytest.mark.parametrize( "page_override", [ @@ -431,6 +614,42 @@ def test_live_png_page_range_invalid_overrides( ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "page_override", + [ + pytest.param("0", id="zero"), + pytest.param("last-0", id="range-with-zero"), + pytest.param("7-3", id="descending-range"), + pytest.param("even", id="even"), + pytest.param("odd", id="odd"), + pytest.param("odd,even", id="odd-even"), + ], +) +async def test_live_async_png_page_range_invalid_overrides( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_20_page_pdf: PdfRestFile, + page_override: str, + request: pytest.FixtureRequest, +) -> None: + case_id = request.node.callspec.id + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises( + PdfRestApiError, + match=r"There was an issue processing your file\. Validate all fields and try again\.", + ): + await client.convert_to_png( + uploaded_20_page_pdf, + output_prefix=f"live-async-range-invalid-{case_id}", + page_range="1", + extra_body={"pages": page_override}, + ) + + def _expand_page_selection( selection: Any, *, From d5f602e79da1c1a88641b9e927f0656effeed4e6 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 13:54:35 -0600 Subject: [PATCH 71/84] Live split/merge/summarize/translate: add async variants, missing option tests Assisted-by: Codex --- tests/live/test_live_pdf_split_merge.py | 246 +++++++++++++++++++++ tests/live/test_live_summarize_pdf_text.py | 27 +++ tests/live/test_live_translate_pdf_text.py | 29 +++ 3 files changed, 302 insertions(+) diff --git a/tests/live/test_live_pdf_split_merge.py b/tests/live/test_live_pdf_split_merge.py index f81c6834..9ae630cb 100644 --- a/tests/live/test_live_pdf_split_merge.py +++ b/tests/live/test_live_pdf_split_merge.py @@ -151,6 +151,65 @@ def test_live_split_pdf_page_groups( assert str(response.input_id) == str(split_source.id) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("page_groups", "expected_count"), + [ + pytest.param(["1-5", "6-last"], 2, id="two-ranges"), + pytest.param([["1", "3", "5"], "2-4"], 2, id="alternating-selection"), + pytest.param(["even"], 1, id="even-only"), + pytest.param(["9-2"], 1, id="descending-single"), + pytest.param(["odd", "even"], 2, id="odd-and-even"), + ], +) +async def test_live_async_split_pdf_page_groups( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], + page_groups: list[PdfPageSelection], + expected_count: int, +) -> None: + split_source, _ = uploaded_live_pdfs + + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + total_pages = await _fetch_page_count_async(client, split_source) + + response = await client.split_pdf( + split_source, + page_groups=page_groups, + output_prefix="live-async-split", + ) + + assert len(response.output_files) == expected_count + + output_infos = [ + await client.query_pdf_info(output_file) + for output_file in response.output_files + ] + + assert all( + output_file.name.startswith("live-async-split") + and output_file.name.endswith(".pdf") + and output_file.type == "application/pdf" + and output_file.size > 0 + for output_file in response.output_files + ) + page_counts_optional = [info.page_count for info in output_infos] + assert all(count is not None for count in page_counts_optional) + expected_page_counts = [ + len(_expand_page_selection(group, total_pages=total_pages)) + for group in page_groups + ][: len(page_counts_optional)] + page_counts = [ + int(count) for count in page_counts_optional if count is not None + ] + assert page_counts == expected_page_counts + assert str(response.input_id) == str(split_source.id) + + def test_live_split_pdf_default_outputs( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -186,6 +245,43 @@ def test_live_split_pdf_default_outputs( assert str(response.input_id) == str(split_source.id) +@pytest.mark.asyncio +async def test_live_async_split_pdf_default_outputs( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], +) -> None: + split_source, _ = uploaded_live_pdfs + + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + total_pages = await _fetch_page_count_async(client, split_source) + + response = await client.split_pdf( + split_source, + output_prefix="live-async-split-default", + ) + + assert len(response.output_files) == total_pages + + output_infos = [ + await client.query_pdf_info(output_file) + for output_file in response.output_files + ] + assert all( + output_file.name.startswith("live-async-split-default") + and output_file.name.endswith(".pdf") + and output_file.type == "application/pdf" + and output_file.size > 0 + for output_file in response.output_files + ) + assert all(info.page_count == 1 for info in output_infos) + + assert str(response.input_id) == str(split_source.id) + + def test_live_split_pdf_invalid_pages( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -207,6 +303,26 @@ def test_live_split_pdf_invalid_pages( ) +@pytest.mark.asyncio +async def test_live_async_split_pdf_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], +) -> None: + split_source, _ = uploaded_live_pdfs + + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)page"): + await client.split_pdf( + split_source, + page_groups=["1-2"], + extra_body={"pages": ["0"]}, + ) + + def test_live_merge_pdfs_success( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -254,6 +370,30 @@ def test_live_merge_pdfs_success( assert output_info.page_count == expected_total_pages +@pytest.mark.asyncio +async def test_live_async_merge_pdfs_invalid_pages( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], +) -> None: + split_source, merge_partner = uploaded_live_pdfs + sources: list[PdfMergeInput] = [ + {"file": split_source, "pages": "even"}, + {"file": merge_partner, "pages": "1"}, + ] + + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)page"): + await client.merge_pdfs( + sources, + output_prefix="live-async-merge-invalid", + extra_body={"pages": ["even", "0"]}, + ) + + def test_live_merge_pdfs_invalid_pages( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -382,6 +522,52 @@ def test_live_split_pdf_page_range_variants( ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("selection", "expect_success", "requires_override"), SPLIT_RANGE_CASES +) +async def test_live_async_split_pdf_page_range_variants( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], + selection: PdfPageSelection, + expect_success: bool, + requires_override: bool, + request: pytest.FixtureRequest, +) -> None: + split_source, _ = uploaded_live_pdfs + case_id = request.node.callspec.id + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + total_pages = await _fetch_page_count_async(client, split_source) + override_body = None + if requires_override: + override_body = {"pages": [str(selection)]} + + if expect_success: + response = await client.split_pdf( + split_source, + page_groups=[selection if not requires_override else "1"], + output_prefix=f"live-async-split-range-{case_id}", + extra_body=override_body, + ) + expected_pages = _expand_page_selection(selection, total_pages=total_pages) + output_pages = ( + await client.query_pdf_info(response.output_files[0]) + ).page_count + assert output_pages == len(expected_pages) + else: + with pytest.raises(PdfRestApiError, match=r"(?i)page"): + await client.split_pdf( + split_source, + page_groups=[selection if not requires_override else "1"], + output_prefix=f"live-async-split-range-{case_id}", + extra_body=override_body, + ) + + MERGE_RANGE_CASES = [ pytest.param("3", True, False, id="single-str"), pytest.param(3, True, False, id="single-int"), @@ -452,3 +638,63 @@ def test_live_merge_pdf_page_range_variants( output_prefix=f"live-merge-range-{case_id}", extra_body=override_body, ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("selection", "expect_success", "requires_override"), MERGE_RANGE_CASES +) +async def test_live_async_merge_pdf_page_range_variants( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_live_pdfs: tuple[PdfRestFile, PdfRestFile], + selection: PdfPageSelection, + expect_success: bool, + requires_override: bool, + request: pytest.FixtureRequest, +) -> None: + split_source, merge_partner = uploaded_live_pdfs + case_id = request.node.callspec.id + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + source_page_counts = { + str(split_source.id): await _fetch_page_count_async(client, split_source), + str(merge_partner.id): await _fetch_page_count_async(client, merge_partner), + } + sources: list[PdfMergeInput] = [ + { + "file": split_source, + "pages": selection if not requires_override else "1", + }, + {"file": merge_partner, "pages": "1"}, + ] + override_body = {"pages": [str(selection), "1"]} if requires_override else None + + if expect_success: + response = await client.merge_pdfs( + sources, + output_prefix=f"live-async-merge-range-{case_id}", + extra_body=override_body, + ) + expected_total_pages = sum( + len( + _expand_page_selection( + chosen_selection, + total_pages=source_page_counts[str(file.id)], + ) + ) + for file, chosen_selection in ( + _extract_merge_entry(entry) for entry in sources + ) + ) + output_info = await client.query_pdf_info(response.output_file) + assert output_info.page_count == expected_total_pages + else: + with pytest.raises(PdfRestApiError, match=r"(?i)page"): + await client.merge_pdfs( + sources, + output_prefix=f"live-async-merge-range-{case_id}", + extra_body=override_body, + ) diff --git a/tests/live/test_live_summarize_pdf_text.py b/tests/live/test_live_summarize_pdf_text.py index 629c815a..4317c8d5 100644 --- a/tests/live/test_live_summarize_pdf_text.py +++ b/tests/live/test_live_summarize_pdf_text.py @@ -55,6 +55,33 @@ def test_live_summarize_text_to_file_success( assert response.input_id == uploaded.id +@pytest.mark.asyncio +async def test_live_async_summarize_text_to_file_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + response = await client.summarize_text_to_file( + uploaded, + target_word_count=30, + summary_format="overview", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files + output_file = response.output_file + assert output_file.name.endswith(".md") + assert output_file.type == "text/markdown" + assert output_file.size > 0 + assert response.warning is None + assert response.input_id == uploaded.id + + @pytest.mark.asyncio async def test_live_async_summarize_text_success( pdfrest_api_key: str, diff --git a/tests/live/test_live_translate_pdf_text.py b/tests/live/test_live_translate_pdf_text.py index 00701242..a2366ec8 100644 --- a/tests/live/test_live_translate_pdf_text.py +++ b/tests/live/test_live_translate_pdf_text.py @@ -126,3 +126,32 @@ def test_live_translate_pdf_text_file_success( assert response.output_language == "fr" assert response.source_languages assert response.input_id == uploaded.id + + +@pytest.mark.asyncio +async def test_live_async_translate_pdf_text_file_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + response = await client.translate_pdf_text_to_file( + uploaded, + output_language="de", + output_format="plaintext", + ) + + assert isinstance(response, TranslatePdfTextFileResponse) + assert response.output_files + output_file = response.output_file + assert output_file.name.endswith(".txt") + assert output_file.type == "text/plain" + assert output_file.size > 0 + assert response.warning is None + assert response.output_language == "de" + assert response.source_languages + assert response.input_id == uploaded.id From 1d9ecbb1725f64796cbb2123a88f8523e5b41422 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 14:57:37 -0600 Subject: [PATCH 72/84] Markdown/powerpoint: Add missing `async` tests Assisted-by: Codex --- tests/test_convert_to_markdown.py | 106 ++++++++++++++++++++++++++++ tests/test_convert_to_powerpoint.py | 27 +++++++ 2 files changed, 133 insertions(+) diff --git a/tests/test_convert_to_markdown.py b/tests/test_convert_to_markdown.py index 22876eb8..140a1c22 100644 --- a/tests/test_convert_to_markdown.py +++ b/tests/test_convert_to_markdown.py @@ -66,6 +66,44 @@ def test_convert_to_markdown_payload_invalid_page_break_comments() -> None: ) +@pytest.mark.asyncio +async def test_async_convert_to_markdown_payload_rejects_non_pdf() -> None: + file_id = str(PdfRestFileID.generate()) + text_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "notes.txt", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/plain", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + with pytest.raises(ValidationError, match="Must be a PDF file"): + ConvertToMarkdownPayload.model_validate({"files": [text_file]}) + + +@pytest.mark.asyncio +async def test_async_convert_to_markdown_payload_invalid_page_range() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises( + ValidationError, match="The start page must be less than or equal to the end" + ): + ConvertToMarkdownPayload.model_validate( + {"files": [file_repr], "pages": ["5-2"]} + ) + + +@pytest.mark.asyncio +async def test_async_convert_to_markdown_payload_invalid_page_break_comments() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises(ValidationError, match="Input should be 'on' or 'off'"): + ConvertToMarkdownPayload.model_validate( + {"files": [file_repr], "page_break_comments": "maybe"} + ) + + def test_convert_to_markdown_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) @@ -198,6 +236,74 @@ def handler(request: httpx.Request) -> httpx.Response: assert get_timeout == pytest.approx(0.4) +@pytest.mark.asyncio +async def test_async_convert_to_markdown_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = ConvertToMarkdownPayload.model_validate( + { + "files": [input_file], + "output_type": "file", + "page_break_comments": "off", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + 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 == "/markdown": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + for key, value in payload_dump.items(): + assert payload[key] == value + assert payload["debug"] is True + return httpx.Response( + 200, + json={ + "inputId": [str(input_file.id)], + "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"] == "async" + return httpx.Response( + 200, + json=_make_markdown_file(output_id, "debug-async.md").model_dump( + mode="json", by_alias=True + ), + ) + 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_markdown( + input_file, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": True}, + timeout=0.4, + page_break_comments="off", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert len(response.output_files) == 1 + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.4) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.4) + + @pytest.mark.asyncio async def test_async_convert_to_markdown_success( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_convert_to_powerpoint.py b/tests/test_convert_to_powerpoint.py index a8c1daa0..26653678 100644 --- a/tests/test_convert_to_powerpoint.py +++ b/tests/test_convert_to_powerpoint.py @@ -275,3 +275,30 @@ def test_convert_to_powerpoint_validation(monkeypatch: pytest.MonkeyPatch) -> No client.convert_to_powerpoint( [pdf_file, make_pdf_file(PdfRestFileID.generate())] ) + + +@pytest.mark.asyncio +async def test_async_convert_to_powerpoint_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)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.convert_to_powerpoint(png_file) + + with pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ): + await client.convert_to_powerpoint( + [pdf_file, make_pdf_file(PdfRestFileID.generate())] + ) From adc6aecfffbdbf97939aeeb0ce25286fe262e881 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 15:13:32 -0600 Subject: [PATCH 73/84] Extract images/text: Fix `async` test parity Assisted-by: Codex --- tests/test_extract_images.py | 89 +++++++++++++++++++++ tests/test_extract_pdf_text_to_file.py | 106 +++++++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/tests/test_extract_images.py b/tests/test_extract_images.py index 5dea441b..89ef81ce 100644 --- a/tests/test_extract_images.py +++ b/tests/test_extract_images.py @@ -52,6 +52,33 @@ def test_extract_images_payload_invalid_page_range() -> None: ExtractImagesPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) +@pytest.mark.asyncio +async def test_async_extract_images_payload_rejects_non_pdf() -> None: + file_id = str(PdfRestFileID.generate()) + text_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "notes.txt", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/plain", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + with pytest.raises(ValidationError, match="Must be a PDF file"): + ExtractImagesPayload.model_validate({"files": [text_file]}) + + +@pytest.mark.asyncio +async def test_async_extract_images_payload_invalid_page_range() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises( + ValidationError, match="The start page must be less than or equal to the end" + ): + ExtractImagesPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) + + def test_extract_images_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) @@ -164,6 +191,68 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(0.3) +@pytest.mark.asyncio +async def test_async_extract_images_request_customization( + 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 = ExtractImagesPayload.model_validate( + {"files": [input_file], "pages": ["1-last"]} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/extracted-images": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump | {"debug": True} + return httpx.Response( + 200, + json={ + "inputId": str(input_file.id), + "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"] == "async" + return httpx.Response( + 200, + json=_make_png_file(output_id, "debug-async.png").model_dump( + mode="json", by_alias=True + ), + ) + 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.extract_images( + input_file, + pages=["1-last"], + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": True}, + timeout=0.3, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert len(response.output_files) == 1 + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.3) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.3) + + @pytest.mark.asyncio async def test_async_extract_images_success( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_extract_pdf_text_to_file.py b/tests/test_extract_pdf_text_to_file.py index a2ad457c..fb875fc9 100644 --- a/tests/test_extract_pdf_text_to_file.py +++ b/tests/test_extract_pdf_text_to_file.py @@ -52,6 +52,33 @@ def test_extract_pdf_text_payload_invalid_page_range() -> None: ExtractTextPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) +@pytest.mark.asyncio +async def test_async_extract_pdf_text_payload_rejects_non_pdf() -> None: + file_id = str(PdfRestFileID.generate()) + text_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "notes.txt", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/plain", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + with pytest.raises(ValidationError, match="Must be a PDF file"): + ExtractTextPayload.model_validate({"files": [text_file]}) + + +@pytest.mark.asyncio +async def test_async_extract_pdf_text_payload_invalid_page_range() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises( + ValidationError, match="The start page must be less than or equal to the end" + ): + ExtractTextPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) + + def test_extract_pdf_text_to_file_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) @@ -186,6 +213,85 @@ def handler(request: httpx.Request) -> httpx.Response: assert get_timeout == pytest.approx(0.35) +@pytest.mark.asyncio +async def test_async_extract_pdf_text_request_customization( + 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 = ExtractTextPayload.model_validate( + { + "files": [input_file], + "output": "file-output", + "full_text": "document", + "preserve_line_breaks": "off", + "word_style": "off", + "word_coordinates": "off", + "output_type": "file", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/extracted-text": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async" + captured_timeout["post"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump | {"debug": True} + return httpx.Response( + 200, + json={ + "inputId": [str(input_file.id)], + "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"] == "async" + captured_timeout["get"] = request.extensions.get("timeout") + return httpx.Response( + 200, + json=_make_text_file(output_id, "debug-async.txt").model_dump( + mode="json", by_alias=True + ), + ) + 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.extract_pdf_text_to_file( + input_file, + output="file-output", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": True}, + timeout=0.35, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert len(response.output_files) == 1 + post_timeout = captured_timeout["post"] + get_timeout = captured_timeout["get"] + assert post_timeout is not None + assert get_timeout is not None + if isinstance(post_timeout, dict): + assert all( + component == pytest.approx(0.35) for component in post_timeout.values() + ) + else: + assert post_timeout == pytest.approx(0.35) + if isinstance(get_timeout, dict): + assert all( + component == pytest.approx(0.35) for component in get_timeout.values() + ) + else: + assert get_timeout == pytest.approx(0.35) + + @pytest.mark.asyncio async def test_async_extract_pdf_text_to_file_success( monkeypatch: pytest.MonkeyPatch, From a8ce0964caf6b88f403aeaae0ee2adfe51deca51 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 15:30:16 -0600 Subject: [PATCH 74/84] Flatten annots/transparencies/linearize/rasterize: Fix `async` test parity Assisted-by: Codex --- tests/test_flatten_annotations.py | 27 ++++++++++++++++++ tests/test_flatten_transparencies.py | 35 +++++++++++++++++++++++ tests/test_linearize_pdf.py | 42 ++++++++++++++++++++++++++++ tests/test_rasterize_pdf.py | 32 +++++++++++++++++++++ 4 files changed, 136 insertions(+) diff --git a/tests/test_flatten_annotations.py b/tests/test_flatten_annotations.py index d5407a3d..b34cf576 100644 --- a/tests/test_flatten_annotations.py +++ b/tests/test_flatten_annotations.py @@ -279,3 +279,30 @@ def test_flatten_annotations_validation(monkeypatch: pytest.MonkeyPatch) -> None ), ): client.flatten_annotations([pdf_file, make_pdf_file(PdfRestFileID.generate())]) + + +@pytest.mark.asyncio +async def test_async_flatten_annotations_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)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.flatten_annotations(png_file) + + with pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ): + await client.flatten_annotations( + [pdf_file, make_pdf_file(PdfRestFileID.generate())] + ) diff --git a/tests/test_flatten_transparencies.py b/tests/test_flatten_transparencies.py index 0035fd70..2f50ead1 100644 --- a/tests/test_flatten_transparencies.py +++ b/tests/test_flatten_transparencies.py @@ -295,3 +295,38 @@ def test_flatten_transparencies_validation(monkeypatch: pytest.MonkeyPatch) -> N ), ): client.flatten_transparencies(pdf_file, quality="ultra") # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_async_flatten_transparencies_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)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.flatten_transparencies(png_file) + + with pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ): + await client.flatten_transparencies( + [pdf_file, make_pdf_file(PdfRestFileID.generate())] + ) + + with pytest.raises( + ValidationError, match="Input should be 'low', 'medium' or 'high'" + ): + await client.flatten_transparencies( + pdf_file, + quality="ultra", # type: ignore[arg-type] + ) diff --git a/tests/test_linearize_pdf.py b/tests/test_linearize_pdf.py index 6b212437..68e25fbc 100644 --- a/tests/test_linearize_pdf.py +++ b/tests/test_linearize_pdf.py @@ -280,3 +280,45 @@ def test_linearize_pdf_validation( pytest.raises(ValidationError, match=match), ): client.linearize_pdf(files_argument) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("files", "match"), + [ + pytest.param( + "png", + "Must be a PDF file", + id="non-pdf-file", + ), + pytest.param( + "multiple", + "List should have at most 1 item after validation", + id="multiple-files", + ), + ], +) +async def test_async_linearize_pdf_validation( + monkeypatch: pytest.MonkeyPatch, + files: str, + match: str, +) -> 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)) + files_argument = ( + png_file + if files == "png" + else [pdf_file, make_pdf_file(PdfRestFileID.generate())] + ) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match=match): + await client.linearize_pdf(files_argument) diff --git a/tests/test_rasterize_pdf.py b/tests/test_rasterize_pdf.py index 707ab223..b477fe2c 100644 --- a/tests/test_rasterize_pdf.py +++ b/tests/test_rasterize_pdf.py @@ -263,3 +263,35 @@ def test_rasterize_pdf_validation(monkeypatch: pytest.MonkeyPatch) -> None: ), ): client.rasterize_pdf([pdf_file, make_pdf_file(PdfRestFileID.generate())]) + + +@pytest.mark.asyncio +async def test_async_rasterize_pdf_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)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.rasterize_pdf(png_file) + + with pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ): + await client.rasterize_pdf( + [pdf_file, make_pdf_file(PdfRestFileID.generate())] + ) + + with pytest.raises(ValidationError, match="Timeout must be greater than 0"): + await client.rasterize_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + output="output", + timeout=0, + ) From bd0bb8c29d36e03b9ed05234ff46c062920c39c4 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 15:39:37 -0600 Subject: [PATCH 75/84] OCR/summarize: Fix `async` test parity Assisted-by: Codex --- tests/test_ocr_pdf.py | 88 ++++++++++++++++++++++++++ tests/test_summarize_pdf_text.py | 105 +++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/tests/test_ocr_pdf.py b/tests/test_ocr_pdf.py index 625f92f4..03bf8d27 100644 --- a/tests/test_ocr_pdf.py +++ b/tests/test_ocr_pdf.py @@ -38,6 +38,33 @@ def test_ocr_payload_invalid_page_range() -> None: OcrPdfPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) +@pytest.mark.asyncio +async def test_async_ocr_payload_rejects_non_pdf() -> None: + file_id = str(PdfRestFileID.generate()) + text_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "notes.txt", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "text/plain", + "size": 64, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + with pytest.raises(ValidationError, match="Must be a PDF file"): + OcrPdfPayload.model_validate({"files": [text_file]}) + + +@pytest.mark.asyncio +async def test_async_ocr_payload_invalid_page_range() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises( + ValidationError, match="The start page must be less than or equal to the end" + ): + OcrPdfPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) + + def test_ocr_payload_languages() -> None: file_repr = make_pdf_file(PdfRestFileID.generate(1)) payload = OcrPdfPayload.model_validate( @@ -171,6 +198,67 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(0.4) +@pytest.mark.asyncio +async def test_async_ocr_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = OcrPdfPayload.model_validate( + {"files": [input_file], "languages": ["English"]} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + 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 == "/pdf-with-ocr-text": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump | {"debug": True} + return httpx.Response( + 200, + json={ + "outputId": output_id, + "inputId": str(input_file.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"] == "async" + return httpx.Response( + 200, + json=make_pdf_file(output_id, "custom-async-ocr.pdf").model_dump( + mode="json", by_alias=True + ), + ) + 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.ocr_pdf( + input_file, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": True}, + timeout=0.4, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.id == output_id + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.4) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.4) + + @pytest.mark.asyncio async def test_async_ocr_pdf_success( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_summarize_pdf_text.py b/tests/test_summarize_pdf_text.py index 8cdea8f8..692f70f9 100644 --- a/tests/test_summarize_pdf_text.py +++ b/tests/test_summarize_pdf_text.py @@ -66,6 +66,37 @@ def test_summarize_payload_invalid_page_range() -> None: SummarizePdfTextPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) +@pytest.mark.asyncio +async def test_async_summarize_payload_rejects_invalid_mime() -> None: + file_id = str(PdfRestFileID.generate()) + image_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "image.png", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "image/png", + "size": 10, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + + with pytest.raises( + ValidationError, match="Must be a PDF, Markdown, or plain text file" + ): + SummarizePdfTextPayload.model_validate({"files": [image_file]}) + + +@pytest.mark.asyncio +async def test_async_summarize_payload_invalid_page_range() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + + with pytest.raises( + ValidationError, match="The start page must be less than or equal to the end" + ): + SummarizePdfTextPayload.model_validate({"files": [file_repr], "pages": ["5-2"]}) + + def test_summarize_text_json_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = _make_text_file(str(PdfRestFileID.generate(1))) @@ -245,6 +276,80 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(0.25) +@pytest.mark.asyncio +async def test_async_summarize_text_to_file_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = SummarizePdfTextPayload.model_validate( + { + "files": [input_file], + "output_type": "file", + "output_format": "markdown", + "summary_format": "overview", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + output_id = str(PdfRestFileID.generate()) + + captured_timeout: dict[str, float | dict[str, float] | None] = {} + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/summarized-pdf-text": + seen["post"] += 1 + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + for key, value in payload_dump.items(): + assert payload[key] == value + assert payload["debug"] is True + return httpx.Response( + 200, + json={ + "outputId": output_id, + "inputId": str(input_file.id), + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-summary.txt", "text/plain" + ), + ) + 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.summarize_text_to_file( + input_file, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": True}, + timeout=0.25, + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.id == output_id + assert response.output_file.name == "async-summary.txt" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.25) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.25) + + def test_summarize_text_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(2)) From f204c7ae2ad9ae8adcad0ace8fc5b2d8a4de34b6 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 15:42:53 -0600 Subject: [PATCH 76/84] Translate: Fix `async` test parity Assisted-by: Codex --- tests/test_translate_pdf_text.py | 129 +++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py index fe755a16..3f13ffdb 100644 --- a/tests/test_translate_pdf_text.py +++ b/tests/test_translate_pdf_text.py @@ -62,6 +62,29 @@ def test_translate_payload_rejects_invalid_mime() -> None: ) +@pytest.mark.asyncio +async def test_async_translate_payload_rejects_invalid_mime() -> None: + file_id = str(PdfRestFileID.generate()) + image_file = PdfRestFile.model_validate( + { + "id": file_id, + "name": "image.png", + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": "image/png", + "size": 10, + "modified": "2024-01-01T00:00:00Z", + "scheduledDeletionTimeUtc": None, + } + ) + + with pytest.raises( + ValidationError, match="Must be a PDF, Markdown, or plain text file" + ): + TranslatePdfTextPayload.model_validate( + {"files": [image_file], "output_language": "fr"} + ) + + @pytest.mark.parametrize( "output_language", [ @@ -113,6 +136,38 @@ def test_translate_payload_requires_target_language() -> None: TranslatePdfTextPayload.model_validate({"files": [file_repr]}) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "output_language", + [ + pytest.param("", id="empty"), + pytest.param("e", id="too-short"), + pytest.param("english", id="not-a-code"), + pytest.param("eng-USA", id="long-subtag"), + pytest.param("en-1234", id="long-numeric-region"), + pytest.param("en-US-extra", id="too-many-subtags"), + ], +) +async def test_async_translate_payload_rejects_invalid_output_language( + output_language: str, +) -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises( + ValidationError, + match=re.escape(OUTPUT_LANGUAGE_ERROR), + ): + TranslatePdfTextPayload.model_validate( + {"files": [file_repr], "output_language": output_language} + ) + + +@pytest.mark.asyncio +async def test_async_translate_payload_requires_target_language() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises(ValidationError): + TranslatePdfTextPayload.model_validate({"files": [file_repr]}) + + def test_translate_pdf_text_json_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = _make_markdown_file(str(PdfRestFileID.generate(1))) @@ -237,6 +292,80 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(0.3) +@pytest.mark.asyncio +async def test_async_translate_pdf_text_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = TranslatePdfTextPayload.model_validate( + { + "files": [input_file], + "output_language": "es", + "output_type": "file", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + 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 == "/translated-pdf-text": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + for key, value in payload_dump.items(): + assert payload[key] == value + assert payload["debug"] is True + return httpx.Response( + 200, + json={ + "outputUrl": f"https://api.pdfrest.com/resource/{output_id}?format=file", + "outputId": output_id, + "inputId": str(input_file.id), + "source_languages": ["en"], + "output_language": "es", + }, + ) + 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"] == "async" + return httpx.Response( + 200, + json=_make_markdown_file(output_id).model_dump( + mode="json", by_alias=True + ), + ) + 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.translate_pdf_text_to_file( + input_file, + output_language="es", + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": True}, + timeout=0.3, + ) + + assert isinstance(response, TranslatePdfTextFileResponse) + assert response.output_file.id == output_id + assert response.output_language == "es" + assert response.source_languages == ["en"] + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.3) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.3) + + def test_translate_pdf_text_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(2)) From e8946dde0b608a5c04308023456a6a5b9cc8014a Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 16:12:44 -0600 Subject: [PATCH 77/84] Address test failures in Rasterize unit test and live PDF test Assisted-by: Codex --- tests/live/test_live_pdf_redactions.py | 1 - tests/test_rasterize_pdf.py | 7 ------- 2 files changed, 8 deletions(-) diff --git a/tests/live/test_live_pdf_redactions.py b/tests/live/test_live_pdf_redactions.py index 027793db..0b7aee90 100644 --- a/tests/live/test_live_pdf_redactions.py +++ b/tests/live/test_live_pdf_redactions.py @@ -317,7 +317,6 @@ async def test_live_async_redactions_invalid_payloads( preview = await client.preview_redactions( uploaded_pdf_for_redaction, redactions=[{"type": "literal", "value": "placeholder"}], - extra_body=extra_body, ) preview_file = preview.output_files[0] with pytest.raises(PdfRestApiError, match=r"(?i)rgb"): diff --git a/tests/test_rasterize_pdf.py b/tests/test_rasterize_pdf.py index b477fe2c..d5a189e1 100644 --- a/tests/test_rasterize_pdf.py +++ b/tests/test_rasterize_pdf.py @@ -288,10 +288,3 @@ async def test_async_rasterize_pdf_validation(monkeypatch: pytest.MonkeyPatch) - await client.rasterize_pdf( [pdf_file, make_pdf_file(PdfRestFileID.generate())] ) - - with pytest.raises(ValidationError, match="Timeout must be greater than 0"): - await client.rasterize_pdf( - make_pdf_file(PdfRestFileID.generate(1)), - output="output", - timeout=0, - ) From 559c82b0468d64296a13d7e45bf1c24ee5459881 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 16:44:30 -0600 Subject: [PATCH 78/84] Query PDF: Fix naming of `async` tests Assisted-by: Codex --- tests/live/test_live_pdf_info.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/live/test_live_pdf_info.py b/tests/live/test_live_pdf_info.py index 74dcbd22..7d361512 100644 --- a/tests/live/test_live_pdf_info.py +++ b/tests/live/test_live_pdf_info.py @@ -95,7 +95,7 @@ def test_live_pdf_info_queries( @pytest.mark.asyncio @pytest.mark.parametrize("query_name", ALLOWED_QUERIES, ids=list(ALLOWED_QUERIES)) -async def test_live_pdf_info_async_queries( +async def test_live_async_pdf_info_queries( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf: PdfRestFile, @@ -150,7 +150,7 @@ def test_live_pdf_info_invalid_query( pytest.param("🚫", id="emoji"), ], ) -async def test_live_pdf_info_async_invalid_query( +async def test_live_async_pdf_info_invalid_query( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf: PdfRestFile, @@ -200,7 +200,7 @@ def test_live_pdf_info_multiple_queries( pytest.param(("page_count", "file_size", "pdf_version"), id="three-values"), ], ) -async def test_live_pdf_info_async_multiple_queries( +async def test_live_async_pdf_info_multiple_queries( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf: PdfRestFile, @@ -236,7 +236,7 @@ def test_live_pdf_info_all_queries( @pytest.mark.asyncio -async def test_live_pdf_info_async_all_queries( +async def test_live_async_pdf_info_all_queries( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_pdf: PdfRestFile, From f6bb55440af09456f47875afa63b0df5eefb7406 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 15 Jan 2026 16:46:09 -0600 Subject: [PATCH 79/84] Address remaining smattering of synchronous/`async` parity in tests - Excel - JPEG - PDF/A - Convert forms - OCR - Summarize - Translate - PDF/A (live) Assisted-by: Codex --- tests/live/test_live_convert_to_pdfa.py | 24 ++ tests/test_convert_to_excel.py | 28 +++ tests/test_convert_to_jpeg.py | 296 ++++++++++++++++++++++++ tests/test_convert_to_pdfa.py | 49 ++++ tests/test_convert_xfa_to_acroforms.py | 28 +++ tests/test_ocr_pdf.py | 22 ++ tests/test_summarize_pdf_text.py | 52 +++++ tests/test_translate_pdf_text.py | 76 ++++++ 8 files changed, 575 insertions(+) diff --git a/tests/live/test_live_convert_to_pdfa.py b/tests/live/test_live_convert_to_pdfa.py index 9cad65e4..8b40221d 100644 --- a/tests/live/test_live_convert_to_pdfa.py +++ b/tests/live/test_live_convert_to_pdfa.py @@ -101,6 +101,30 @@ def test_live_convert_to_pdfa_with_rasterize_option( assert str(response.input_id) == str(uploaded_pdf_for_pdfa.id) +@pytest.mark.asyncio +async def test_live_async_convert_to_pdfa_with_rasterize_option( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_pdfa: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_to_pdfa( + uploaded_pdf_for_pdfa, + output_type="PDF/A-2b", + rasterize_if_errors_encountered="on", + output="async-pdfa-rasterize", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async-pdfa-rasterize") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_pdfa.id) + + @pytest.mark.parametrize( "invalid_output_type", [ diff --git a/tests/test_convert_to_excel.py b/tests/test_convert_to_excel.py index 42346aac..8debea4c 100644 --- a/tests/test_convert_to_excel.py +++ b/tests/test_convert_to_excel.py @@ -273,3 +273,31 @@ def test_convert_to_excel_validation(monkeypatch: pytest.MonkeyPatch) -> None: ), ): client.convert_to_excel([pdf_file, make_pdf_file(PdfRestFileID.generate())]) + + +@pytest.mark.asyncio +async def test_async_convert_to_excel_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)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.convert_to_excel(png_file) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ): + await client.convert_to_excel( + [pdf_file, make_pdf_file(PdfRestFileID.generate())] + ) diff --git a/tests/test_convert_to_jpeg.py b/tests/test_convert_to_jpeg.py index 363e78ed..4ebf5a4e 100644 --- a/tests/test_convert_to_jpeg.py +++ b/tests/test_convert_to_jpeg.py @@ -124,6 +124,50 @@ def handler(request: httpx.Request) -> httpx.Response: assert output_file.type == "image/jpeg" +@pytest.mark.asyncio +async def test_async_convert_to_jpeg_defaults_included( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "8e9f0011-2222-4bcd-9f00-abcdefabcdef" + + request_payload = JpegPdfRestPayload.model_validate( + {"files": input_file} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/jpg": + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload( + payload, request_payload, allowed_extras={"jpeg_quality"} + ) + assert payload["jpeg_quality"] == 75 + assert payload["resolution"] == 300 + assert payload["color_model"] == "rgb" + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "example-001.jpg", "image/jpeg" + ), + ) + 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_jpeg(input_file) + + output_file = response.output_files[0] + assert output_file.name == "example-001.jpg" + assert output_file.type == "image/jpeg" + + @pytest.mark.parametrize("resolution", [12, 2400]) def test_convert_to_jpeg_resolution_limits( monkeypatch: pytest.MonkeyPatch, resolution: int @@ -174,6 +218,57 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.output_files[0].name == f"example-resolution-{resolution}.jpg" +@pytest.mark.asyncio +@pytest.mark.parametrize("resolution", [12, 2400]) +async def test_async_convert_to_jpeg_resolution_limits( + monkeypatch: pytest.MonkeyPatch, resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + request_payload = JpegPdfRestPayload.model_validate( + { + "files": [input_file], + "resolution": resolution, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_defaults=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/jpg": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload( + payload, request_payload, allowed_extras={"jpeg_quality"} + ) + 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 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, f"example-resolution-{resolution}.jpg", "image/jpeg" + ), + ) + 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_jpeg( + input_file, + resolution=resolution, + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == f"example-resolution-{resolution}.jpg" + + @pytest.mark.parametrize("invalid_resolution", [11, 2401]) def test_convert_to_jpeg_resolution_out_of_bounds( monkeypatch: pytest.MonkeyPatch, invalid_resolution: int @@ -197,6 +292,28 @@ def handler(_: httpx.Request) -> httpx.Response: ) +@pytest.mark.asyncio +@pytest.mark.parametrize("invalid_resolution", [11, 2401]) +async def test_async_convert_to_jpeg_resolution_out_of_bounds( + monkeypatch: pytest.MonkeyPatch, invalid_resolution: int +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match=r"less than or equal to 2400|greater than or equal to 12", + ): + await client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + resolution=invalid_resolution, + ) + + @pytest.mark.parametrize( "invalid_color", [pytest.param("rgba", id="rgba"), pytest.param("lab", id="lab")], @@ -223,6 +340,31 @@ def handler(_: httpx.Request) -> httpx.Response: ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "invalid_color", + [pytest.param("rgba", id="rgba"), pytest.param("lab", id="lab")], +) +async def test_async_convert_to_jpeg_invalid_color_model( + monkeypatch: pytest.MonkeyPatch, invalid_color: str +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match=re.escape("Input should be 'rgb', 'cmyk' or 'gray'"), + ): + await client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + color_model=invalid_color, # type: ignore[arg-type] + ) + + def test_convert_to_jpeg_invalid_quality(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -243,6 +385,27 @@ def handler(_: httpx.Request) -> httpx.Response: ) +@pytest.mark.asyncio +async def test_async_convert_to_jpeg_invalid_quality( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match=re.escape("Input should be greater than or equal to 1"), + ): + await client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + jpeg_quality=0, + ) + + @pytest.mark.asyncio @pytest.mark.parametrize( "color_model", @@ -391,6 +554,27 @@ def handler(_: httpx.Request) -> httpx.Response: ) +@pytest.mark.asyncio +async def test_async_convert_to_jpeg_validation_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match="less than or equal to 2400", + ): + await client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + resolution=5000, + ) + + def test_convert_to_jpeg_invalid_smoothing_value( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -413,6 +597,27 @@ def handler(_: httpx.Request) -> httpx.Response: ) +@pytest.mark.asyncio +async def test_async_convert_to_jpeg_invalid_smoothing_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match=re.escape("Input should be 'none', 'all', 'text', 'line' or 'image'"), + ): + await client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + smoothing="invalid", # type: ignore[arg-type] + ) + + def test_convert_to_jpeg_multiple_files_rejected( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -434,6 +639,26 @@ def handler(_: httpx.Request) -> httpx.Response: client.convert_to_jpeg([first, second]) +@pytest.mark.asyncio +async def test_async_convert_to_jpeg_multiple_files_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + first = make_pdf_file(PdfRestFileID.generate(1)) + second = make_pdf_file(PdfRestFileID.generate(1)) + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match=re.escape("List should have at most 1 item after validation"), + ): + await client.convert_to_jpeg([first, second]) + + def test_convert_to_jpeg_empty_page_range_rejected( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -456,6 +681,27 @@ def handler(_: httpx.Request) -> httpx.Response: ) +@pytest.mark.asyncio +async def test_async_convert_to_jpeg_empty_page_range_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match=re.escape("List should have at least 1 item after validation"), + ): + await client.convert_to_jpeg( + make_pdf_file(PdfRestFileID.generate(1)), + page_range=[], + ) + + def test_convert_to_jpeg_sequence_arguments(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) @@ -503,6 +749,56 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.output_files[0].name == "example-001.jpg" +@pytest.mark.asyncio +async def test_async_convert_to_jpeg_sequence_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = "cdef0123-7777-4ab0-9123-aaaaaaaabbbb" + + request_payload = JpegPdfRestPayload.model_validate( + { + "files": [input_file], + "page_range": "1, 3", + "smoothing": "text", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True) + + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/jpg": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert_conversion_payload(payload, request_payload, allowed_extras=set()) + 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 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "example-async-001.jpg", "image/jpeg" + ), + ) + 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_jpeg( + [input_file], + page_range="1, 3", + smoothing="text", + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == "example-async-001.jpg" + + @pytest.mark.asyncio async def test_async_convert_to_jpeg_request_customization( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_convert_to_pdfa.py b/tests/test_convert_to_pdfa.py index 0c3af307..96af385b 100644 --- a/tests/test_convert_to_pdfa.py +++ b/tests/test_convert_to_pdfa.py @@ -316,3 +316,52 @@ def test_convert_to_pdfa_validation(monkeypatch: pytest.MonkeyPatch) -> None: [pdf_file, make_pdf_file(PdfRestFileID.generate())], output_type="PDF/A-2b", ) + + +@pytest.mark.asyncio +async def test_async_convert_to_pdfa_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)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match=( + "Input should be 'PDF/A-1b', 'PDF/A-2b', 'PDF/A-2u', " + "'PDF/A-3b' or 'PDF/A-3u'" + ), + ): + await client.convert_to_pdfa( + pdf_file, + output_type=None, # type: ignore[arg-type] + ) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.convert_to_pdfa(png_file, output_type="PDF/A-2b") + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="PDF/A-1b"): + await client.convert_to_pdfa( + pdf_file, + output_type="PDF/A-4", # type: ignore[arg-type] + ) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ): + await client.convert_to_pdfa( + [pdf_file, make_pdf_file(PdfRestFileID.generate())], + output_type="PDF/A-2b", + ) diff --git a/tests/test_convert_xfa_to_acroforms.py b/tests/test_convert_xfa_to_acroforms.py index 6080d22f..0d634cc7 100644 --- a/tests/test_convert_xfa_to_acroforms.py +++ b/tests/test_convert_xfa_to_acroforms.py @@ -269,3 +269,31 @@ def test_convert_xfa_to_acroforms_validation(monkeypatch: pytest.MonkeyPatch) -> client.convert_xfa_to_acroforms( [pdf_file, make_pdf_file(PdfRestFileID.generate())] ) + + +@pytest.mark.asyncio +async def test_async_convert_xfa_to_acroforms_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)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.convert_xfa_to_acroforms(png_file) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ): + await client.convert_xfa_to_acroforms( + [pdf_file, make_pdf_file(PdfRestFileID.generate())] + ) diff --git a/tests/test_ocr_pdf.py b/tests/test_ocr_pdf.py index 03bf8d27..fe55df9e 100644 --- a/tests/test_ocr_pdf.py +++ b/tests/test_ocr_pdf.py @@ -79,12 +79,34 @@ def test_ocr_payload_languages() -> None: ) +@pytest.mark.asyncio +async def test_async_ocr_payload_languages() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + payload = OcrPdfPayload.model_validate( + {"files": [file_repr], "languages": ["English", "German"]} + ) + assert payload.languages == ["English", "German"] + assert ( + payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + )["languages"] + == "English,German" + ) + + def test_ocr_payload_invalid_language() -> None: file_repr = make_pdf_file(PdfRestFileID.generate(1)) with pytest.raises(ValidationError, match="ChineseSimplified"): OcrPdfPayload.model_validate({"files": [file_repr], "languages": ["Klingon"]}) +@pytest.mark.asyncio +async def test_async_ocr_payload_invalid_language() -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + with pytest.raises(ValidationError, match="ChineseSimplified"): + OcrPdfPayload.model_validate({"files": [file_repr], "languages": ["Klingon"]}) + + def test_ocr_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate(1)) diff --git a/tests/test_summarize_pdf_text.py b/tests/test_summarize_pdf_text.py index 692f70f9..2f8c3ef9 100644 --- a/tests/test_summarize_pdf_text.py +++ b/tests/test_summarize_pdf_text.py @@ -146,6 +146,58 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.input_id == input_file.id +@pytest.mark.asyncio +async def test_async_summarize_text_json_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_text_file(str(PdfRestFileID.generate(1))) + payload_dump = SummarizePdfTextPayload.model_validate( + { + "files": [input_file], + "target_word_count": 120, + "summary_format": "bullet_points", + "pages": ["1-3"], + "output_format": "plaintext", + "output_type": "json", + "output": "summary", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/summarized-pdf-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "summary": "Async key points...", + "inputId": str(input_file.id), + }, + ) + 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.summarize_text( + input_file, + target_word_count=120, + summary_format="bullet_points", + pages=["1-3"], + output_format="plaintext", + output="summary", + ) + + assert seen == {"post": 1} + assert isinstance(response, SummarizePdfTextResponse) + assert response.summary == "Async key points..." + assert response.input_id == input_file.id + + def test_summarize_text_to_file_success( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_translate_pdf_text.py b/tests/test_translate_pdf_text.py index 3f13ffdb..1d244031 100644 --- a/tests/test_translate_pdf_text.py +++ b/tests/test_translate_pdf_text.py @@ -106,6 +106,28 @@ def test_translate_payload_accepts_valid_output_language( assert payload.output_language == output_language +@pytest.mark.asyncio +@pytest.mark.parametrize( + "output_language", + [ + pytest.param("en", id="language-2-letter"), + pytest.param("fra", id="language-3-letter"), + pytest.param("zh-Hant", id="script"), + pytest.param("eng-US", id="alpha-region"), + pytest.param("es-419", id="numeric-region"), + ], +) +async def test_async_translate_payload_accepts_valid_output_language( + output_language: str, +) -> None: + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + payload = TranslatePdfTextPayload.model_validate( + {"files": [file_repr], "output_language": output_language} + ) + + assert payload.output_language == output_language + + @pytest.mark.parametrize( "output_language", [ @@ -219,6 +241,60 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.input_id == input_file.id +@pytest.mark.asyncio +async def test_async_translate_pdf_text_json_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = _make_markdown_file(str(PdfRestFileID.generate(1))) + payload_dump = TranslatePdfTextPayload.model_validate( + { + "files": [input_file], + "output_language": "fr", + "pages": ["1-2"], + "output_format": "plaintext", + "output_type": "json", + "output": "translation", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/translated-pdf-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "translated_text": "Bonjour", + "inputId": str(input_file.id), + "source_languages": ["en"], + "output_language": "fr", + }, + ) + 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.translate_pdf_text( + input_file, + output_language="fr", + pages=["1-2"], + output_format="plaintext", + output="translation", + ) + + assert seen == {"post": 1} + assert isinstance(response, TranslatePdfTextResponse) + assert response.translated_text == "Bonjour" + assert response.source_languages == ["en"] + assert response.output_language == "fr" + assert response.input_id == input_file.id + + def test_translate_pdf_text_request_customization( monkeypatch: pytest.MonkeyPatch, ) -> None: From 6af6db5da2624738a65d4cac8c99f01b6d3e18fc Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 5 Feb 2026 10:41:30 -0600 Subject: [PATCH 80/84] Handle pytest progress suffix when parsing node IDs Assisted-by: Codex --- scripts/check_test_parity.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check_test_parity.sh b/scripts/check_test_parity.sh index dd90ce9f..90656302 100755 --- a/scripts/check_test_parity.sh +++ b/scripts/check_test_parity.sh @@ -46,6 +46,7 @@ awk ' { line = $0; sub(/^\[[^]]+\][[:space:]]+/, "", line); + sub(/[[:space:]]+\[[^]]+\]$/, "", line); if (line ~ /^(PASSED|FAILED|SKIPPED|XFAIL|XPASS|ERROR)[[:space:]]+tests\/.*::/) { sub(/^(PASSED|FAILED|SKIPPED|XFAIL|XPASS|ERROR)[[:space:]]+/, "", line); print line; From 3ea9dd770a5b53d0349e704650609f9a368e7973 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 5 Feb 2026 10:42:12 -0600 Subject: [PATCH 81/84] Filter deleted tests before invoking pytest Assisted-by: Codex --- scripts/check_test_parity.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check_test_parity.sh b/scripts/check_test_parity.sh index 90656302..0b8f13d3 100755 --- a/scripts/check_test_parity.sh +++ b/scripts/check_test_parity.sh @@ -21,7 +21,7 @@ while IFS= read -r file; do test_files+=("$file") fi done < <( - git diff --name-only "$base_ref..$head_ref" -- tests | grep -E '\.py$' || true + git diff --name-only --diff-filter=d "$base_ref..$head_ref" -- tests | grep -E '\.py$' || true ) if [[ ${#test_files[@]} -eq 0 ]]; then From 95ba01071bb0fd1acc82d5ca474902c3bfa3e013 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 5 Feb 2026 15:42:21 -0600 Subject: [PATCH 82/84] Live graphic tests: Add additional assertions on output - Added `_EXPECTED_FILE_FORMATS`, `_expected_file_format`, and `_assert_output_files` helper. - Applied `_assert_output_files` in: - PNG success (sync + async) - Valid color model tests (all formats, sync + async) - Resolution bounds (PNG) - Valid smoothing tests (all formats, sync + async) - PNG page-range variants (sync + async) Assisted-by: Codex --- tests/live/test_live_graphic_conversions.py | 98 ++++++++++++++++----- 1 file changed, 76 insertions(+), 22 deletions(-) diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index 56d2b610..9c13ae31 100644 --- a/tests/live/test_live_graphic_conversions.py +++ b/tests/live/test_live_graphic_conversions.py @@ -36,6 +36,14 @@ class _GraphicEndpointSpec(NamedTuple): "tiff": _GraphicEndpointSpec("convert_to_tiff", TiffPdfRestPayload), } +_EXPECTED_FILE_FORMATS: dict[str, tuple[str, str]] = { + "png": ("image/png", ".png"), + "bmp": ("image/bmp", ".bmp"), + "gif": ("image/gif", ".gif"), + "jpeg": ("image/jpeg", ".jpg"), + "tiff": ("image/tiff", ".tif"), +} + def _enumerate_color_models( payload_model: type[BasePdfRestGraphicPayload[Any]], @@ -108,6 +116,22 @@ def _invalid_smoothing_cases() -> list[Any]: return cases +def _expected_file_format(label: str) -> tuple[str, str]: + return _EXPECTED_FILE_FORMATS[label] + + +def _assert_output_files( + output_files: Sequence[PdfRestFile], + *, + expected_mime: str, + expected_suffix: str, +) -> None: + assert output_files + assert all(file_info.name.endswith(expected_suffix) for file_info in output_files) + assert all(file_info.type == expected_mime for file_info in output_files) + assert all(file_info.size > 0 for file_info in output_files) + + @pytest.fixture(scope="module") def uploaded_20_page_pdf( pdfrest_api_key: str, @@ -137,8 +161,11 @@ def test_live_convert_to_png_success( resolution=150, ) - assert response.output_files - assert all(file_info.type == "image/png" for file_info in response.output_files) + _assert_output_files( + response.output_files, + expected_mime="image/png", + expected_suffix=".png", + ) assert str(response.input_id) == str(uploaded.id) @@ -159,8 +186,11 @@ async def test_live_async_convert_to_png_success( resolution=150, ) - assert response.output_files - assert all(file_info.type == "image/png" for file_info in response.output_files) + _assert_output_files( + response.output_files, + expected_mime="image/png", + expected_suffix=".png", + ) assert str(response.input_id) == str(uploaded.id) @@ -183,12 +213,17 @@ def test_live_graphic_valid_color_models( ) as client: uploaded = client.files.create_from_paths([resource])[0] client_method = getattr(client, spec.method_name) + expected_mime, expected_suffix = _expected_file_format(_endpoint_label) response = client_method( uploaded, color_model=color_model, resolution=resolution, ) - assert response.output_files + _assert_output_files( + response.output_files, + expected_mime=expected_mime, + expected_suffix=expected_suffix, + ) @pytest.mark.asyncio @@ -212,12 +247,17 @@ async def test_live_async_graphic_valid_color_models( ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] client_method = getattr(client, spec.method_name) + expected_mime, expected_suffix = _expected_file_format(_endpoint_label) response = await client_method( uploaded, color_model=color_model, resolution=resolution, ) - assert response.output_files + _assert_output_files( + response.output_files, + expected_mime=expected_mime, + expected_suffix=expected_suffix, + ) @pytest.mark.parametrize( @@ -318,7 +358,11 @@ def test_live_graphic_resolution_bounds( client_method(uploaded, **call_kwargs) else: response = client_method(uploaded, **call_kwargs) - assert response.output_files + _assert_output_files( + response.output_files, + expected_mime="image/png", + expected_suffix=".png", + ) @pytest.mark.asyncio @@ -363,7 +407,11 @@ async def test_live_async_graphic_resolution_bounds( await client_method(uploaded, **call_kwargs) else: response = await client_method(uploaded, **call_kwargs) - assert response.output_files + _assert_output_files( + response.output_files, + expected_mime="image/png", + expected_suffix=".png", + ) @pytest.mark.parametrize( @@ -383,11 +431,16 @@ def test_live_graphic_valid_smoothing( ) as client: uploaded = client.files.create_from_paths([resource])[0] client_method = getattr(client, spec.method_name) + expected_mime, expected_suffix = _expected_file_format(_endpoint_label) response = client_method( uploaded, smoothing=smoothing_value, ) - assert response.output_files + _assert_output_files( + response.output_files, + expected_mime=expected_mime, + expected_suffix=expected_suffix, + ) @pytest.mark.asyncio @@ -408,11 +461,16 @@ async def test_live_async_graphic_valid_smoothing( ) as client: uploaded = (await client.files.create_from_paths([resource]))[0] client_method = getattr(client, spec.method_name) + expected_mime, expected_suffix = _expected_file_format(_endpoint_label) response = await client_method( uploaded, smoothing=smoothing_value, ) - assert response.output_files + _assert_output_files( + response.output_files, + expected_mime=expected_mime, + expected_suffix=expected_suffix, + ) @pytest.mark.parametrize( @@ -504,12 +562,10 @@ def test_live_png_page_range_variants( expected_pages = _expand_page_selection(page_range, total_pages=20) assert len(response.output_files) == len(expected_pages) - assert any( - file_info.name.endswith(".png") for file_info in response.output_files - ) - assert all( - file_info.type == "image/png" and file_info.size > 0 - for file_info in response.output_files + _assert_output_files( + response.output_files, + expected_mime="image/png", + expected_suffix=".png", ) assert str(response.input_id) == str(uploaded_20_page_pdf.id) else: @@ -560,12 +616,10 @@ async def test_live_async_png_page_range_variants( expected_pages = _expand_page_selection(page_range, total_pages=20) assert len(response.output_files) == len(expected_pages) - assert any( - file_info.name.endswith(".png") for file_info in response.output_files - ) - assert all( - file_info.type == "image/png" and file_info.size > 0 - for file_info in response.output_files + _assert_output_files( + response.output_files, + expected_mime="image/png", + expected_suffix=".png", ) assert str(response.input_id) == str(uploaded_20_page_pdf.id) else: From 23d3c8f64a36decda098c52b78fe01b057474cfd Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 5 Feb 2026 16:14:37 -0600 Subject: [PATCH 83/84] Convert forms live test: Use XFA input --- .../test_live_convert_xfa_to_acroforms.py | 18 +++--------------- tests/resources/xfa.pdf | Bin 0 -> 192301 bytes 2 files changed, 3 insertions(+), 15 deletions(-) create mode 100755 tests/resources/xfa.pdf diff --git a/tests/live/test_live_convert_xfa_to_acroforms.py b/tests/live/test_live_convert_xfa_to_acroforms.py index 889c970b..adc2e8ab 100644 --- a/tests/live/test_live_convert_xfa_to_acroforms.py +++ b/tests/live/test_live_convert_xfa_to_acroforms.py @@ -7,17 +7,13 @@ from ..resources import get_test_resource_path -WARNING_NO_XFA_FORMS = ( - "No XFA forms were detected in the input PDF. No output was produced." -) - @pytest.fixture(scope="module") def uploaded_pdf_for_acroforms( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> PdfRestFile: - resource = get_test_resource_path("report.pdf") + resource = get_test_resource_path("xfa.pdf") with PdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, @@ -49,11 +45,7 @@ def test_live_convert_xfa_to_acroforms_success( response = client.convert_xfa_to_acroforms(uploaded_pdf_for_acroforms, **kwargs) assert str(response.input_id) == str(uploaded_pdf_for_acroforms.id) - if response.warning is not None: - assert response.warning == WARNING_NO_XFA_FORMS - assert response.output_files == [] - return - + assert response.warning is None assert response.output_files output_file = response.output_file assert output_file.type == "application/pdf" @@ -109,11 +101,7 @@ async def test_live_async_convert_xfa_to_acroforms_success( ) assert str(response.input_id) == str(uploaded_pdf_for_acroforms.id) - if response.warning is not None: - assert response.warning == WARNING_NO_XFA_FORMS - assert response.output_files == [] - return - + assert response.warning is None assert response.output_files output_file = response.output_file if output_name is not None: diff --git a/tests/resources/xfa.pdf b/tests/resources/xfa.pdf new file mode 100755 index 0000000000000000000000000000000000000000..8a3ffe2cf5c874af8ea029c73751dd41bed97dc8 GIT binary patch literal 192301 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 z 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 literal 0 HcmV?d00001 From 3e7d369a57e725f46df6654f7acd49bac874e5b8 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 5 Feb 2026 16:15:47 -0600 Subject: [PATCH 84/84] OCR live test: Use rasterized input --- tests/live/test_live_ocr_pdf.py | 8 ++++---- tests/resources/report-image.pdf | Bin 0 -> 110084 bytes 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 tests/resources/report-image.pdf diff --git a/tests/live/test_live_ocr_pdf.py b/tests/live/test_live_ocr_pdf.py index 5e9ede14..89625a2e 100644 --- a/tests/live/test_live_ocr_pdf.py +++ b/tests/live/test_live_ocr_pdf.py @@ -12,7 +12,7 @@ def test_live_ocr_pdf_success( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: - resource = get_test_resource_path("report.pdf") + resource = get_test_resource_path("report-image.pdf") with PdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, @@ -35,7 +35,7 @@ async def test_live_async_ocr_pdf_success( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: - resource = get_test_resource_path("report.pdf") + resource = get_test_resource_path("report-image.pdf") async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, @@ -56,7 +56,7 @@ def test_live_ocr_pdf_invalid_pages( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: - resource = get_test_resource_path("report.pdf") + resource = get_test_resource_path("report-image.pdf") with PdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, @@ -74,7 +74,7 @@ async def test_live_async_ocr_pdf_invalid_pages( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> None: - resource = get_test_resource_path("report.pdf") + resource = get_test_resource_path("report-image.pdf") async with AsyncPdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, diff --git a/tests/resources/report-image.pdf b/tests/resources/report-image.pdf new file mode 100644 index 0000000000000000000000000000000000000000..47c42a29a985f43164aad75bff55ba275a42430b GIT binary patch literal 110084 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 v^ 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