diff --git a/docs/api-guide.md b/docs/api-guide.md index b954b90..b233d88 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -147,6 +147,7 @@ formats. profile. See the [custom job options example](https://github.com/pdfrest/pdfrest-python/blob/main/examples/convert_postscript_to_pdf/convert_postscript_with_job_options_example.py). - Out of PDF: + [convert_pdf_to_postscript][pdfrest.PdfRestClient.convert_pdf_to_postscript], [convert_to_word][pdfrest.PdfRestClient.convert_to_word], [convert_to_excel][pdfrest.PdfRestClient.convert_to_excel], [convert_to_powerpoint][pdfrest.PdfRestClient.convert_to_powerpoint], diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 8fefad5..5b55afd 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -153,6 +153,8 @@ to the API guide and API reference for exact method names and options. - Convert to Office/image/Markdown formats (for example Word, Excel, PowerPoint, PNG/JPEG/TIFF/GIF/BMP, Markdown) - Convert source assets to PDF (Office, HTML, URL, email, images, PostScript) +- Convert PDF pages to PostScript with language-level, page-range, scaling, + rotation, fitting, binary-output, and annotation controls - Apply an uploaded `.joboptions` Distiller profile when converting PostScript or EPS with `convert_postscript_to_pdf` - Convert to standards formats (`PDF/A`, `PDF/X`) diff --git a/examples/README.md b/examples/README.md index 63227ae..39bf5ee 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,6 +31,10 @@ supported interpreter matrix. - `examples/convert_to_image/convert_password_protected_pdf_to_png_example.py` – convert a password-protected PDF into PNG images by supplying its open password. +- `examples/convert_pdf_to_postscript/convert_pdf_to_postscript_example.py` – + synchronously convert selected PDF pages and verify the PostScript output. +- `examples/convert_pdf_to_postscript/async_convert_pdf_to_postscript_example.py` + – asynchronously convert a PDF page and verify the PostScript output. - `examples/delete/delete_example.py` – demonstrate file deletion (sync + async variants). - `examples/extract_text/extract_pdf_text_example.py` – run `extract_pdf_text` diff --git a/examples/convert_pdf_to_postscript/async_convert_pdf_to_postscript_example.py b/examples/convert_pdf_to_postscript/async_convert_pdf_to_postscript_example.py new file mode 100644 index 0000000..05aa2ca --- /dev/null +++ b/examples/convert_pdf_to_postscript/async_convert_pdf_to_postscript_example.py @@ -0,0 +1,79 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Asynchronously convert a PDF to PostScript and verify the result. + +This sample demonstrates how to: + +1. Upload the checked-in PDF resource with ``AsyncPdfRestClient``. +2. Convert its first page to level 2 PostScript. +3. Download the result and verify its PostScript header and page markers. +4. Print the generated file's metadata and download URL. + +Set ``PDFREST_API_KEY`` and optionally ``PDFREST_BASE_URL`` for a self-hosted +deployment, then run from the repository root with +``uv run examples/convert_pdf_to_postscript/async_convert_pdf_to_postscript_example.py``. +The required input PDF is included in the repository. +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import AsyncPdfRestClient + +RESOURCE_DIRECTORY = Path(__file__).resolve().parents[1] / "resources" +PDF_PATH = RESOURCE_DIRECTORY / "report.pdf" + + +def _language_level(postscript: bytes, expected_level: int) -> str: + """Return and verify the generated PostScript language-level marker.""" + expected = f"%%LanguageLevel: {expected_level}".encode() + if expected not in postscript.splitlines(): + msg = f"The generated file is not PostScript level {expected_level}." + raise RuntimeError(msg) + return expected.decode("ascii") + + +async def convert_pdf_to_postscript() -> None: + """Upload a PDF, convert its first page, and verify the PostScript.""" + load_dotenv() + base_url = os.getenv("PDFREST_BASE_URL") + + async with AsyncPdfRestClient(base_url=base_url) as client: + source = (await client.files.create_from_paths(PDF_PATH))[0] + response = await client.convert_pdf_to_postscript( + source, + ps_level=2, + page_range="1", + binary_output=False, + scale=0.75, + rotate=True, + shrink_to_fit=True, + print_annotations=False, + output="async-print-ready", + ) + postscript = await client.files.read_bytes(response.output_file) + + output = response.output_file + if not postscript.startswith(b"%!PS-Adobe"): + msg = "The generated file does not have a PostScript header." + raise RuntimeError(msg) + language_level = _language_level(postscript, 2) + + print(f"Created: {output.name}") + print(f"MIME type: {output.type}") + print(f"Size: {output.size} bytes") + print(f"Header: {postscript.splitlines()[0].decode('ascii')}") + print(f"Language level: {language_level}") + print(f"Pages: {postscript.count(b'%%Page:')}") + print(f"Download URL: {output.url}") + + +if __name__ == "__main__": # pragma: no cover - manual example + asyncio.run(convert_pdf_to_postscript()) diff --git a/examples/convert_pdf_to_postscript/convert_pdf_to_postscript_example.py b/examples/convert_pdf_to_postscript/convert_pdf_to_postscript_example.py new file mode 100644 index 0000000..bf6f787 --- /dev/null +++ b/examples/convert_pdf_to_postscript/convert_pdf_to_postscript_example.py @@ -0,0 +1,77 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Convert selected PDF pages to PostScript and verify the result. + +This sample demonstrates how to: + +1. Upload the checked-in PDF resource. +2. Convert selected pages to level 3 PostScript with print controls. +3. Download the result and verify its PostScript header and page markers. +4. Print the generated file's metadata and download URL. + +Set ``PDFREST_API_KEY`` and optionally ``PDFREST_BASE_URL`` for a self-hosted +deployment, then run from the repository root with +``uv run examples/convert_pdf_to_postscript/convert_pdf_to_postscript_example.py``. +The required input PDF is included in the repository. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import PdfRestClient + +RESOURCE_DIRECTORY = Path(__file__).resolve().parents[1] / "resources" +PDF_PATH = RESOURCE_DIRECTORY / "report.pdf" + + +def _language_level(postscript: bytes, expected_level: int) -> str: + """Return and verify the generated PostScript language-level marker.""" + expected = f"%%LanguageLevel: {expected_level}".encode() + if expected not in postscript.splitlines(): + msg = f"The generated file is not PostScript level {expected_level}." + raise RuntimeError(msg) + return expected.decode("ascii") + + +def convert_pdf_to_postscript() -> None: + """Upload a PDF, convert selected pages, and verify the PostScript.""" + load_dotenv() + base_url = os.getenv("PDFREST_BASE_URL") + + with PdfRestClient(base_url=base_url) as client: + source = client.files.create_from_paths(PDF_PATH)[0] + response = client.convert_pdf_to_postscript( + source, + ps_level=3, + page_range="1-last", + binary_output=False, + scale=1, + shrink_to_fit=True, + print_annotations=False, + output="print-ready", + ) + postscript = client.files.read_bytes(response.output_file) + + output = response.output_file + if not postscript.startswith(b"%!PS-Adobe"): + msg = "The generated file does not have a PostScript header." + raise RuntimeError(msg) + language_level = _language_level(postscript, 3) + + print(f"Created: {output.name}") + print(f"MIME type: {output.type}") + print(f"Size: {output.size} bytes") + print(f"Header: {postscript.splitlines()[0].decode('ascii')}") + print(f"Language level: {language_level}") + print(f"Pages: {postscript.count(b'%%Page:')}") + print(f"Download URL: {output.url}") + + +if __name__ == "__main__": # pragma: no cover - manual example + convert_pdf_to_postscript() diff --git a/pyproject.toml b/pyproject.toml index 627845f..c4e489a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pdfrest" -version = "1.3.0" +version = "1.4.0" description = "Python client library for interacting with the pdfRest API" readme = { file = "README.md", content-type = "text/markdown" } authors = [ diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 9c8a8b1..7849669 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -83,6 +83,7 @@ ConvertJsonToPdfPayload, ConvertMarkdownToPdfPayload, ConvertOfficeToPdfPayload, + ConvertPdfToPostscriptPayload, ConvertPlainTextToPdfPayload, ConvertPostscriptToPdfPayload, ConvertToMarkdownPayload, @@ -178,6 +179,7 @@ PdfTextColor, PdfXType, PngColorModel, + PostScriptLevel, SummaryFormat, SummaryOutputFormat, TiffColorModel, @@ -3840,6 +3842,78 @@ def convert_to_powerpoint( timeout=timeout, ) + def convert_pdf_to_postscript( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + ps_level: PostScriptLevel = 3, + page_range: str = "all", + binary_output: bool = True, + scale: float = 1, + rotate: bool = False, + shrink_to_fit: bool = False, + print_annotations: bool = True, + 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 PostScript level 2 or level 3. + + Produces print-oriented PostScript with page selection, scaling, + rotation, binary output, fitting, and annotation controls. + + Args: + file: Uploaded PDF as a `PdfRestFile` object. + ps_level: PostScript language level for the generated file. + page_range: Pages to convert, such as `all`, `1`, `1,3-5`, or + `14-last`. + binary_output: Allows binary data in the PostScript stream when + supported. + scale: Page-content scale multiplier; must be greater than zero. + rotate: Enables page rotation in the generated PostScript. + shrink_to_fit: Shrinks page content when needed to fit the output + page. + print_annotations: Includes printable PDF annotations in the + output. + output: Output filename prefix used by pdfRest when creating the + PostScript file. + extra_query: Additional query parameters merged into the request. + extra_headers: Additional HTTP headers merged into the request. + extra_body: Additional request body fields merged into the payload. + timeout: Request timeout override for this call. + + Returns: + Validated file-based response returned by pdfRest. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = { + "files": file, + "ps_level": ps_level, + "page_range": page_range, + "binary_output": binary_output, + "scale": scale, + "rotate": rotate, + "shrink_to_fit": shrink_to_fit, + "print_annotations": print_annotations, + } + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/postscript", + payload=payload, + payload_model=ConvertPdfToPostscriptPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def convert_xfa_to_acroforms( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -7304,6 +7378,78 @@ async def convert_to_powerpoint( timeout=timeout, ) + async def convert_pdf_to_postscript( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + ps_level: PostScriptLevel = 3, + page_range: str = "all", + binary_output: bool = True, + scale: float = 1, + rotate: bool = False, + shrink_to_fit: bool = False, + print_annotations: bool = True, + 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 PostScript level 2 or level 3. + + This is the asynchronous variant of + [PdfRestClient.convert_pdf_to_postscript][pdfrest.PdfRestClient.convert_pdf_to_postscript]. + + Args: + file: Uploaded PDF as a `PdfRestFile` object. + ps_level: PostScript language level for the generated file. + page_range: Pages to convert, such as `all`, `1`, `1,3-5`, or + `14-last`. + binary_output: Allows binary data in the PostScript stream when + supported. + scale: Page-content scale multiplier; must be greater than zero. + rotate: Enables page rotation in the generated PostScript. + shrink_to_fit: Shrinks page content when needed to fit the output + page. + print_annotations: Includes printable PDF annotations in the + output. + output: Output filename prefix used by pdfRest when creating the + PostScript file. + extra_query: Additional query parameters merged into the request. + extra_headers: Additional HTTP headers merged into the request. + extra_body: Additional request body fields merged into the payload. + timeout: Request timeout override for this call. + + Returns: + Validated file-based response returned by pdfRest. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = { + "files": file, + "ps_level": ps_level, + "page_range": page_range, + "binary_output": binary_output, + "scale": scale, + "rotate": rotate, + "shrink_to_fit": shrink_to_fit, + "print_annotations": print_annotations, + } + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/postscript", + payload=payload, + payload_model=ConvertPdfToPostscriptPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def convert_xfa_to_acroforms( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index e103854..1432caa 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -47,6 +47,7 @@ PdfStructuredTextPageSize, PdfStructuredTextTextAlignment, PdfXType, + PostScriptLevel, SummaryFormat, SummaryOutputFormat, SummaryOutputType, @@ -464,6 +465,38 @@ def _ascending_page_range( AscendingPageRange = PageNumber | Literal["last"] | _AscendingPageRangeTuple +_POSTSCRIPT_PAGE_BOUNDARY = r"(?:last|[1-9]\d*)" +_POSTSCRIPT_PAGE_RANGE_PATTERN = re.compile( + rf"^\s*(?:all|{_POSTSCRIPT_PAGE_BOUNDARY}(?:\s*-\s*{_POSTSCRIPT_PAGE_BOUNDARY})?)" + rf"(?:\s*,\s*(?:{_POSTSCRIPT_PAGE_BOUNDARY}(?:\s*-\s*{_POSTSCRIPT_PAGE_BOUNDARY})?))*\s*$", + flags=re.IGNORECASE, +) + + +def _validate_postscript_page_range(value: str) -> str: + if not _POSTSCRIPT_PAGE_RANGE_PATTERN.fullmatch(value): + msg = "An invalid page range was specified." + raise ValueError(msg) + + normalized = value.strip() + if normalized.casefold() == "all": + return normalized + + for part in normalized.split(","): + start, separator, end = part.partition("-") + if not separator: + continue + start = start.strip() + end = end.strip() + if start.casefold() == "last" and end.casefold() != "last": + msg = "An invalid page range was specified." + raise ValueError(msg) + if end.casefold() != "last" and int(start) > int(end): + msg = "An invalid page range was specified." + raise ValueError(msg) + return normalized + + class PdfInfoPayload(BaseModel): """Adapt caller options into a pdfRest-ready pdf-info request payload.""" @@ -1855,6 +1888,46 @@ class PdfToPowerpointPayload(BaseModel): ] = None +class ConvertPdfToPostscriptPayload(BaseModel): + """Adapt caller options into a pdfRest-ready PDF-to-PostScript 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), + ] + ps_level: Annotated[PostScriptLevel, Field(serialization_alias="ps_level")] = 3 + page_range: Annotated[ + str, + Field(serialization_alias="page_range"), + AfterValidator(_validate_postscript_page_range), + ] = "all" + binary_output: Annotated[bool, Field(serialization_alias="binary_output")] = True + scale: Annotated[ + float, + Field(serialization_alias="scale", gt=0, allow_inf_nan=False), + ] = 1 + rotate: Annotated[bool, Field(serialization_alias="rotate")] = False + shrink_to_fit: Annotated[bool, Field(serialization_alias="shrink_to_fit")] = False + print_annotations: Annotated[ + bool, Field(serialization_alias="print_annotations") + ] = True + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + class PdfToPdfaPayload(BaseModel): """Adapt caller options into a pdfRest-ready PDF/A request payload.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 6e73600..48b9ffa 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -64,6 +64,7 @@ PdfTextColor, PdfXType, PngColorModel, + PostScriptLevel, SummaryFormat, SummaryOutputFormat, SummaryOutputType, @@ -137,6 +138,7 @@ "PdfTextColor", "PdfXType", "PngColorModel", + "PostScriptLevel", "SummaryFormat", "SummaryOutputFormat", "SummaryOutputType", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 9033087..e74e433 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -76,6 +76,7 @@ "PdfTextColor", "PdfXType", "PngColorModel", + "PostScriptLevel", "SummaryFormat", "SummaryOutputFormat", "SummaryOutputType", @@ -850,6 +851,12 @@ class PdfPemCredentials(TypedDict): PdfAType = Literal["PDF/A-1b", "PDF/A-2b", "PDF/A-2u", "PDF/A-3b", "PDF/A-3u"] #: PDF/X conformance targets accepted by ``convert_to_pdfx``. PdfXType = Literal["PDF/X-1a", "PDF/X-3", "PDF/X-4", "PDF/X-6"] +PostScriptLevel: TypeAlias = Literal[2, 3] +"""PostScript language level used by ``convert_pdf_to_postscript``. + +Level 2 supports older PostScript interpreters, while level 3 enables newer +language features. +""" #: Granularity modes for extracted full text payloads. ExtractTextGranularity = Literal["off", "by_page", "document"] #: Compression levels accepted by ``compress_pdf``. diff --git a/tests/live/test_live_convert_pdf_to_postscript.py b/tests/live/test_live_convert_pdf_to_postscript.py new file mode 100644 index 0000000..a8a8089 --- /dev/null +++ b/tests/live/test_live_convert_pdf_to_postscript.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +from typing import TypedDict, cast, get_args + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile +from pdfrest.models._internal import ConvertPdfToPostscriptPayload +from pdfrest.types import PostScriptLevel + +from ..resources import get_test_resource_path + +POSTSCRIPT_LEVELS = cast( + tuple[PostScriptLevel, ...], + get_args(ConvertPdfToPostscriptPayload.model_fields["ps_level"].annotation), +) +SCALE_SCHEMA = ConvertPdfToPostscriptPayload.model_json_schema()["properties"]["scale"] +SCALE_EXCLUSIVE_MINIMUM = cast(float, SCALE_SCHEMA["exclusiveMinimum"]) +SCALE_NEAR_BOUNDARY = SCALE_EXCLUSIVE_MINIMUM + 0.0001 + + +class PostscriptLiveOptions(TypedDict, total=False): + binary_output: bool + scale: float + rotate: bool + shrink_to_fit: bool + print_annotations: bool + output: str + + +POSTSCRIPT_OPTION_CASES = [ + pytest.param( + PostscriptLiveOptions(binary_output=False), + "report_pdfrest_postscript", + b"%%DocumentData: Clean7Bit", + id="text-safe-output", + ), + pytest.param( + PostscriptLiveOptions(scale=SCALE_NEAR_BOUNDARY), + "report_pdfrest_postscript", + b"%%BoundingBox: 305 395 307 397", + id="scale-near-exclusive-minimum", + ), + pytest.param( + PostscriptLiveOptions(rotate=True), + "report_pdfrest_postscript", + None, + id="rotation-enabled", + ), + pytest.param( + PostscriptLiveOptions(shrink_to_fit=True), + "report_pdfrest_postscript", + None, + id="shrink-to-fit-enabled", + ), + pytest.param( + PostscriptLiveOptions(print_annotations=False), + "report_pdfrest_postscript", + None, + id="annotations-disabled", + ), + pytest.param( + PostscriptLiveOptions(output="live-postscript-custom"), + "live-postscript-custom", + None, + id="custom-output", + ), +] + + +def _postscript_level_id(ps_level: PostScriptLevel) -> str: + return f"level-{ps_level}" + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_postscript( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("20-pages.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.fixture(scope="module") +def uploaded_single_page_pdf_for_postscript( + 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] + + +def _assert_postscript_response( + output: PdfRestFile, + output_bytes: bytes, + output_prefix: str, + ps_level: PostScriptLevel, + expected_pages: int, +) -> None: + assert output.name.startswith(output_prefix) + assert output.name.endswith(".ps") + assert output.type == "application/postscript" + assert output.size == len(output_bytes) + assert output.size > 0 + assert f"/resource/{output.id}" in str(output.url) + assert output_bytes.startswith(b"%!PS-Adobe") + assert f"%%LanguageLevel: {ps_level}".encode() in output_bytes.splitlines() + assert output_bytes.count(b"%%Page:") == expected_pages + + +@pytest.mark.parametrize( + "ps_level", + POSTSCRIPT_LEVELS, + ids=_postscript_level_id, +) +def test_live_convert_pdf_to_postscript_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_single_page_pdf_for_postscript: PdfRestFile, + ps_level: PostScriptLevel, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.convert_pdf_to_postscript( + uploaded_single_page_pdf_for_postscript, + ps_level=ps_level, + ) + output_bytes = client.files.read_bytes(response.output_file) + + assert str(response.input_id) == str(uploaded_single_page_pdf_for_postscript.id) + assert response.warning is None + _assert_postscript_response( + response.output_file, + output_bytes, + "report_pdfrest_postscript", + ps_level, + 1, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "ps_level", + POSTSCRIPT_LEVELS, + ids=_postscript_level_id, +) +async def test_live_async_convert_pdf_to_postscript_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_single_page_pdf_for_postscript: PdfRestFile, + ps_level: PostScriptLevel, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_pdf_to_postscript( + uploaded_single_page_pdf_for_postscript, + ps_level=ps_level, + ) + output_bytes = await client.files.read_bytes(response.output_file) + + assert str(response.input_id) == str(uploaded_single_page_pdf_for_postscript.id) + assert response.warning is None + _assert_postscript_response( + response.output_file, + output_bytes, + "report_pdfrest_postscript", + ps_level, + 1, + ) + + +def test_live_convert_pdf_to_postscript_page_range( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_postscript: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.convert_pdf_to_postscript( + uploaded_pdf_for_postscript, + page_range="2-3", + ) + output_bytes = client.files.read_bytes(response.output_file) + + assert str(response.input_id) == str(uploaded_pdf_for_postscript.id) + assert response.warning is None + _assert_postscript_response( + response.output_file, + output_bytes, + "20-pages_pdfrest_postscript", + 3, + 2, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_pdf_to_postscript_page_range( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_postscript: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_pdf_to_postscript( + uploaded_pdf_for_postscript, + page_range="4-5", + ) + output_bytes = await client.files.read_bytes(response.output_file) + + assert str(response.input_id) == str(uploaded_pdf_for_postscript.id) + assert response.warning is None + _assert_postscript_response( + response.output_file, + output_bytes, + "20-pages_pdfrest_postscript", + 3, + 2, + ) + + +@pytest.mark.parametrize( + ("options", "output_prefix", "expected_marker"), + POSTSCRIPT_OPTION_CASES, +) +def test_live_convert_pdf_to_postscript_options( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_single_page_pdf_for_postscript: PdfRestFile, + options: PostscriptLiveOptions, + output_prefix: str, + expected_marker: bytes | None, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.convert_pdf_to_postscript( + uploaded_single_page_pdf_for_postscript, + **options, + ) + output_bytes = client.files.read_bytes(response.output_file) + + assert str(response.input_id) == str(uploaded_single_page_pdf_for_postscript.id) + assert response.warning is None + _assert_postscript_response( + response.output_file, + output_bytes, + output_prefix, + 3, + 1, + ) + if expected_marker is not None: + assert expected_marker in output_bytes.splitlines() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("options", "output_prefix", "expected_marker"), + POSTSCRIPT_OPTION_CASES, +) +async def test_live_async_convert_pdf_to_postscript_options( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_single_page_pdf_for_postscript: PdfRestFile, + options: PostscriptLiveOptions, + output_prefix: str, + expected_marker: bytes | None, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_pdf_to_postscript( + uploaded_single_page_pdf_for_postscript, + **options, + ) + output_bytes = await client.files.read_bytes(response.output_file) + + assert str(response.input_id) == str(uploaded_single_page_pdf_for_postscript.id) + assert response.warning is None + _assert_postscript_response( + response.output_file, + output_bytes, + output_prefix, + 3, + 1, + ) + if expected_marker is not None: + assert expected_marker in output_bytes.splitlines() + + +def test_live_convert_pdf_to_postscript_rejects_invalid_level( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_postscript: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match="ps_level must be 2 or 3"), + ): + client.convert_pdf_to_postscript( + uploaded_pdf_for_postscript, + extra_body={"ps_level": 4}, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_pdf_to_postscript_rejects_invalid_level( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_postscript: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match="ps_level must be 2 or 3"): + await client.convert_pdf_to_postscript( + uploaded_pdf_for_postscript, + extra_body={"ps_level": 4}, + ) + + +def test_live_convert_pdf_to_postscript_rejects_invalid_page_range( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_postscript: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match="invalid page range"), + ): + client.convert_pdf_to_postscript( + uploaded_pdf_for_postscript, + extra_body={"page_range": "1,,3"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_pdf_to_postscript_rejects_invalid_page_range( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_postscript: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match="invalid page range"): + await client.convert_pdf_to_postscript( + uploaded_pdf_for_postscript, + extra_body={"page_range": "1,,3"}, + ) + + +def test_live_convert_pdf_to_postscript_rejects_invalid_scale( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_postscript: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match="scale must be greater than zero"), + ): + client.convert_pdf_to_postscript( + uploaded_pdf_for_postscript, + extra_body={"scale": SCALE_EXCLUSIVE_MINIMUM}, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_pdf_to_postscript_rejects_invalid_scale( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_postscript: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises( + PdfRestApiError, + match="scale must be greater than zero", + ): + await client.convert_pdf_to_postscript( + uploaded_pdf_for_postscript, + extra_body={"scale": SCALE_EXCLUSIVE_MINIMUM}, + ) diff --git a/tests/test_convert_pdf_to_postscript.py b/tests/test_convert_pdf_to_postscript.py new file mode 100644 index 0000000..a575709 --- /dev/null +++ b/tests/test_convert_pdf_to_postscript.py @@ -0,0 +1,657 @@ +from __future__ import annotations + +import json +import math +from typing import Literal, cast, get_args + +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 ConvertPdfToPostscriptPayload +from pdfrest.types import PostScriptLevel + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + +POSTSCRIPT_LEVELS = cast( + tuple[PostScriptLevel, ...], + get_args(ConvertPdfToPostscriptPayload.model_fields["ps_level"].annotation), +) + + +def _postscript_level_id(ps_level: PostScriptLevel) -> str: + return f"level-{ps_level}" + + +def _make_non_pdf_file() -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload( + PdfRestFileID.generate(), + "example.png", + "image/png", + ) + ) + + +def _assert_timeout( + value: float | dict[str, float] | None, + expected: float, +) -> None: + assert value is not None + if isinstance(value, dict): + assert value + assert all(component == pytest.approx(expected) for component in value.values()) + else: + assert value == pytest.approx(expected) + + +@pytest.mark.parametrize( + "ps_level", + POSTSCRIPT_LEVELS, + ids=_postscript_level_id, +) +def test_convert_pdf_to_postscript_payload_serialization( + ps_level: PostScriptLevel, +) -> None: + source = make_pdf_file(PdfRestFileID.generate(1)) + + payload = ConvertPdfToPostscriptPayload.model_validate( + { + "file": source, + "ps_level": ps_level, + "page_range": "1, 3-5, 12, 14-last", + "binary_output": False, + "scale": 1.25, + "rotate": True, + "shrink_to_fit": True, + "print_annotations": False, + "output": "print-ready", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + assert payload == { + "id": str(source.id), + "ps_level": ps_level, + "page_range": "1, 3-5, 12, 14-last", + "binary_output": False, + "scale": 1.25, + "rotate": True, + "shrink_to_fit": True, + "print_annotations": False, + "output": "print-ready", + } + + +@pytest.mark.parametrize( + "page_range", + [ + pytest.param("all", id="all"), + pytest.param("ALL", id="all-case-insensitive"), + pytest.param("1", id="single-page"), + pytest.param("last", id="last-page"), + pytest.param("1-3", id="ascending-range"), + pytest.param("6-last", id="range-to-last"), + pytest.param("1, 3-5, 12, 14-last", id="mixed-selection"), + pytest.param("all,1", id="all-with-selection"), + ], +) +def test_convert_pdf_to_postscript_page_range_accepts_supported_syntax( + page_range: str, +) -> None: + source = make_pdf_file(PdfRestFileID.generate(2)) + + payload = ConvertPdfToPostscriptPayload.model_validate( + {"file": source, "page_range": page_range} + ) + + assert payload.page_range == page_range.strip() + + +@pytest.mark.parametrize( + "page_range", + [ + pytest.param("", id="empty"), + pytest.param("0", id="zero"), + pytest.param("1-", id="missing-end"), + pytest.param("1,,3", id="empty-selection"), + pytest.param("last-2", id="last-as-start"), + pytest.param("3-1", id="descending"), + pytest.param("even", id="unsupported-selector"), + pytest.param("1--3", id="double-hyphen"), + ], +) +def test_convert_pdf_to_postscript_page_range_rejects_unsupported_syntax( + page_range: str, +) -> None: + source = make_pdf_file(PdfRestFileID.generate()) + + with pytest.raises( + ValidationError, + match="An invalid page range was specified", + ): + ConvertPdfToPostscriptPayload.model_validate( + {"file": source, "page_range": page_range} + ) + + +@pytest.mark.parametrize( + "scale", + [ + pytest.param(0.0001, id="positive-near-zero"), + pytest.param(1, id="identity"), + pytest.param(1.25, id="enlarge"), + ], +) +def test_convert_pdf_to_postscript_scale_accepts_positive_values(scale: float) -> None: + source = make_pdf_file(PdfRestFileID.generate()) + + payload = ConvertPdfToPostscriptPayload.model_validate( + {"file": source, "scale": scale} + ) + + assert payload.scale == pytest.approx(scale) + + +@pytest.mark.parametrize( + ("scale", "error_match"), + [ + pytest.param(0, "greater than 0", id="zero"), + pytest.param(-0.0001, "greater than 0", id="negative"), + pytest.param(math.inf, "finite number", id="positive-infinity"), + pytest.param(-math.inf, "finite number", id="negative-infinity"), + pytest.param(math.nan, "finite number", id="not-a-number"), + ], +) +def test_convert_pdf_to_postscript_scale_rejects_non_positive_or_non_finite_values( + scale: float, + error_match: str, +) -> None: + source = make_pdf_file(PdfRestFileID.generate()) + + with pytest.raises(ValidationError, match=error_match): + ConvertPdfToPostscriptPayload.model_validate({"file": source, "scale": scale}) + + +def test_convert_pdf_to_postscript_default_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + seen = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/postscript": + seen["post"] += 1 + assert json.loads(request.content) == { + "id": str(source.id), + "ps_level": 3, + "page_range": "all", + "binary_output": True, + "scale": 1, + "rotate": False, + "shrink_to_fit": False, + "print_annotations": True, + } + return httpx.Response( + 200, + json={"inputId": [source.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_pdfrest_postscript.ps", + "application/postscript", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + with PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + response = client.convert_pdf_to_postscript(source) + + assert seen == {"post": 1, "get": 1} + assert response.output_file.name == "example_pdfrest_postscript.ps" + + +@pytest.mark.asyncio +async def test_async_convert_pdf_to_postscript_default_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + seen = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/postscript": + seen["post"] += 1 + assert json.loads(request.content) == { + "id": str(source.id), + "ps_level": 3, + "page_range": "all", + "binary_output": True, + "scale": 1, + "rotate": False, + "shrink_to_fit": False, + "print_annotations": True, + } + return httpx.Response( + 200, + json={"inputId": [source.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_pdfrest_postscript.ps", + "application/postscript", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + response = await client.convert_pdf_to_postscript(source) + + assert seen == {"post": 1, "get": 1} + assert response.output_file.name == "example_pdfrest_postscript.ps" + + +@pytest.mark.parametrize( + "ps_level", + POSTSCRIPT_LEVELS, + ids=_postscript_level_id, +) +def test_convert_pdf_to_postscript_success( + monkeypatch: pytest.MonkeyPatch, + ps_level: PostScriptLevel, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + expected_payload = ConvertPdfToPostscriptPayload.model_validate( + { + "file": source, + "ps_level": ps_level, + "page_range": "1,3-5", + "binary_output": False, + "scale": 1.25, + "rotate": True, + "shrink_to_fit": True, + "print_annotations": False, + "output": f"level-{ps_level}", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + seen = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/postscript": + seen["post"] += 1 + assert json.loads(request.content) == expected_payload + return httpx.Response( + 200, + json={"inputId": [source.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, + f"level-{ps_level}.ps", + "application/postscript", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + with PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + response = client.convert_pdf_to_postscript( + source, + ps_level=ps_level, + page_range="1,3-5", + binary_output=False, + scale=1.25, + rotate=True, + shrink_to_fit=True, + print_annotations=False, + output=f"level-{ps_level}", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == f"level-{ps_level}.ps" + assert response.output_file.type == "application/postscript" + assert response.output_file.size == 256 + assert str(response.output_file.url).endswith(output_id) + assert str(response.input_id) == str(source.id) + assert response.warning is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "ps_level", + POSTSCRIPT_LEVELS, + ids=_postscript_level_id, +) +async def test_async_convert_pdf_to_postscript_success( + monkeypatch: pytest.MonkeyPatch, + ps_level: PostScriptLevel, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + expected_payload = ConvertPdfToPostscriptPayload.model_validate( + { + "file": source, + "ps_level": ps_level, + "page_range": "2-last", + "binary_output": False, + "scale": 0.75, + "rotate": True, + "shrink_to_fit": True, + "print_annotations": False, + "output": f"async-level-{ps_level}", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + seen = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/postscript": + seen["post"] += 1 + assert json.loads(request.content) == expected_payload + return httpx.Response( + 200, + json={"inputId": [source.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"async-level-{ps_level}.ps", + "application/postscript", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + response = await client.convert_pdf_to_postscript( + source, + ps_level=ps_level, + page_range="2-last", + binary_output=False, + scale=0.75, + rotate=True, + shrink_to_fit=True, + print_annotations=False, + output=f"async-level-{ps_level}", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == f"async-level-{ps_level}.ps" + assert response.output_file.type == "application/postscript" + assert response.output_file.size == 256 + assert str(response.output_file.url).endswith(output_id) + assert str(response.input_id) == str(source.id) + assert response.warning is None + + +def test_convert_pdf_to_postscript_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured_timeout[request.method.lower()] = request.extensions.get("timeout") + assert request.url.params["trace"] == "sync" + assert request.headers["X-Debug"] == "sync" + if request.method == "POST" and request.url.path == "/postscript": + payload = json.loads(request.content) + assert payload == { + "id": str(source.id), + "ps_level": 3, + "page_range": "all", + "binary_output": True, + "scale": 1, + "rotate": False, + "shrink_to_fit": False, + "print_annotations": True, + "output": "custom", + "debug": True, + } + return httpx.Response( + 200, + json={"inputId": [source.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, + "custom.ps", + "application/postscript", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + with PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + response = client.convert_pdf_to_postscript( + source, + output="custom", + extra_query={"trace": "sync"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.35, + ) + + assert response.output_file.name == "custom.ps" + _assert_timeout(captured_timeout["post"], 0.35) + _assert_timeout(captured_timeout["get"], 0.35) + + +@pytest.mark.asyncio +async def test_async_convert_pdf_to_postscript_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured_timeout[request.method.lower()] = request.extensions.get("timeout") + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async" + if request.method == "POST" and request.url.path == "/postscript": + payload = json.loads(request.content) + assert payload == { + "id": str(source.id), + "ps_level": 3, + "page_range": "all", + "binary_output": True, + "scale": 1, + "rotate": False, + "shrink_to_fit": False, + "print_annotations": True, + "output": "async-custom", + "metadata": {"source": "async"}, + } + return httpx.Response( + 200, + json={"inputId": [source.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, + "async-custom.ps", + "application/postscript", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + response = await client.convert_pdf_to_postscript( + source, + output="async-custom", + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"metadata": {"source": "async"}}, + timeout=0.65, + ) + + assert response.output_file.name == "async-custom.ps" + _assert_timeout(captured_timeout["post"], 0.65) + _assert_timeout(captured_timeout["get"], 0.65) + + +ValidationCase = Literal[ + "mime", + "cardinality", + "ps-level", + "page-range", + "scale", + "output", +] + + +def _invoke_sync_validation_case( + client: PdfRestClient, + case: ValidationCase, +) -> None: + source = make_pdf_file(PdfRestFileID.generate()) + if case == "mime": + client.convert_pdf_to_postscript(_make_non_pdf_file()) + elif case == "cardinality": + client.convert_pdf_to_postscript( + [source, make_pdf_file(PdfRestFileID.generate())] + ) + elif case == "ps-level": + client.convert_pdf_to_postscript(source, ps_level=4) # type: ignore[arg-type] + elif case == "page-range": + client.convert_pdf_to_postscript(source, page_range="3-1") + elif case == "scale": + client.convert_pdf_to_postscript(source, scale=0) + else: + client.convert_pdf_to_postscript(source, output="metadata.json") + + +async def _invoke_async_validation_case( + client: AsyncPdfRestClient, + case: ValidationCase, +) -> None: + source = make_pdf_file(PdfRestFileID.generate()) + if case == "mime": + await client.convert_pdf_to_postscript(_make_non_pdf_file()) + elif case == "cardinality": + await client.convert_pdf_to_postscript( + [source, make_pdf_file(PdfRestFileID.generate())] + ) + elif case == "ps-level": + await client.convert_pdf_to_postscript( + source, + ps_level=4, # type: ignore[arg-type] + ) + elif case == "page-range": + await client.convert_pdf_to_postscript(source, page_range="3-1") + elif case == "scale": + await client.convert_pdf_to_postscript(source, scale=0) + else: + await client.convert_pdf_to_postscript(source, output="metadata.json") + + +VALIDATION_CASES = [ + pytest.param("mime", "Must be a PDF file", id="mime"), + pytest.param("cardinality", "at most 1 item", id="cardinality"), + pytest.param("ps-level", "Input should be 2 or 3", id="ps-level"), + pytest.param( + "page-range", + "An invalid page range was specified", + id="page-range", + ), + pytest.param("scale", "greater than 0", id="scale"), + pytest.param("output", "reserved name", id="output"), +] + + +@pytest.mark.parametrize(("case", "error_match"), VALIDATION_CASES) +def test_convert_pdf_to_postscript_rejects_invalid_input_before_transport( + monkeypatch: pytest.MonkeyPatch, + case: ValidationCase, + error_match: 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.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client, + pytest.raises(ValidationError, match=error_match), + ): + _invoke_sync_validation_case(client, case) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("case", "error_match"), VALIDATION_CASES) +async def test_async_convert_pdf_to_postscript_rejects_invalid_input_before_transport( + monkeypatch: pytest.MonkeyPatch, + case: ValidationCase, + error_match: 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.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + with pytest.raises(ValidationError, match=error_match): + await _invoke_async_validation_case(client, case) diff --git a/uv.lock b/uv.lock index 167d818..a085a9f 100644 --- a/uv.lock +++ b/uv.lock @@ -964,7 +964,7 @@ wheels = [ [[package]] name = "pdfrest" -version = "1.3.0" +version = "1.4.0" source = { editable = "." } dependencies = [ { name = "exceptiongroup" },