diff --git a/.agents/skills/pr-review-auditor/SKILL.md b/.agents/skills/pr-review-auditor/SKILL.md index 543004db..015f4355 100644 --- a/.agents/skills/pr-review-auditor/SKILL.md +++ b/.agents/skills/pr-review-auditor/SKILL.md @@ -59,6 +59,10 @@ policy precedence: `*GUIDELINES*.md` before `AGENTS.md`. 4. Perform review in severity order. - Correctness/behavioral regressions. +- Upload-path compatibility: supported examples and live fixtures must use + normal MIME detection rather than forcing metadata to bypass a narrow + validator. When valid uploads can report multiple MIME types, require + allowlist coverage through both sync and async client REST paths. - CI and workflow regressions. - Test gaps or weak assertions. - Secondary maintainability/style concerns. diff --git a/.agents/skills/pr-review-auditor/references/review-checklist.md b/.agents/skills/pr-review-auditor/references/review-checklist.md index 358e1e43..bb6f06a8 100644 --- a/.agents/skills/pr-review-auditor/references/review-checklist.md +++ b/.agents/skills/pr-review-auditor/references/review-checklist.md @@ -11,6 +11,10 @@ - Determine PR base branch, compute merge-base, and audit `..HEAD`. - Review full commit messages, changed files, and relevant patches. - Focus on behavior, CI impact, tests, then style. +- Confirm supported examples and live fixtures use normal upload MIME detection + rather than forcing metadata to satisfy downstream validation. When normal + uploads can produce multiple legitimate MIME types, require sync and async + REST-path coverage for each accepted value. ## Three-Pass Completeness Protocol diff --git a/AGENTS.md b/AGENTS.md index 0a031a2f..d9d1d8c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,13 @@ helpers should pass sequences through without converting to raw IDs manually. - When a payload accepts uploaded content, validate MIME types via `_allowed_mime_types` to surface clear errors before making the request. +- Supported interfaces, examples, and live fixtures must work with the MIME + metadata produced by a normal `create_from_paths` upload. Do not force a MIME + type solely to satisfy a downstream validator. When valid metadata can vary by + platform or content detection, accept each legitimate MIME type and test it + through both clients; reserve explicit MIME overrides for tests that are + intentionally exercising metadata variants or formats the upload stack cannot + infer. - Payload models should mirror pdfRest's request layout field-for-field. Do not use `@model_serializer` on payload models. If callers need a friendlier input shape, use `@model_validator(mode="before")` to map inputs onto the existing @@ -409,9 +416,10 @@ - Examples exercise the real service, load `PDFREST_API_KEY` from the environment (optionally through `python-dotenv`), upload local inputs through - `client.files.create_from_paths`, and use client context managers. Keep the - flow short and instructional while printing enough typed response data for a - user and CI to confirm success. + `client.files.create_from_paths` without forcing a MIME type for supported + inputs, and use client context managers. Keep the flow short and instructional + while printing enough typed response data for a user and CI to confirm + success. - When a public `TypedDict` represents a structured API input, construct it in examples with its keyword constructor, such as `PdfAddLineObject(...)`, diff --git a/TESTING_GUIDELINES.md b/TESTING_GUIDELINES.md index f02cb8da..e4abfb24 100644 --- a/TESTING_GUIDELINES.md +++ b/TESTING_GUIDELINES.md @@ -160,6 +160,11 @@ iteration required. parts and filenames, and that `client.files.create`/`create_from_paths`/`create_from_urls` fetch info documents afterward. +- Exercise supported file types through the normal `create_from_paths` upload + path without forcing a MIME type. If platform or content detection can produce + multiple legitimate MIME values, cover each value through distinct sync and + async REST paths. Use explicit MIME overrides only when a test is + intentionally varying metadata or the upload stack cannot infer the type. - Downloads: cover `download_file`, `files.read_bytes/text/json`, and `files.write_bytes` with `tmp_path`. Confirm file contents match expected bytes. @@ -207,9 +212,10 @@ iteration required. `test_live_files.py`, etc.). - **Fixtures:** Reuse shared fixtures (`pdfrest_api_key`, `pdfrest_live_base_url`). Upload deterministic assets from `tests/resources/` - via `create_from_paths` (or `client.files.create`) so responses are - predictable. Use `pytest.fixture(scope="module"/"class")` and - `pytest_asyncio.fixture` to cache uploaded PDFs/profiles for both transports. + via the normal `create_from_paths` path (or `client.files.create`) without a + forced MIME type so responses reflect customer usage. Use + `pytest.fixture(scope="module"/"class")` and `pytest_asyncio.fixture` to cache + uploaded PDFs/profiles for both transports. - **Sync + async parity:** Every live module should contain matching sync and async tests for success, streaming, and invalid paths (compression levels, conversion options, file streaming helpers). Treat transport-plumbing diff --git a/docs/api-guide.md b/docs/api-guide.md index 60e1d292..60abe3e5 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -141,6 +141,11 @@ formats. [convert_json_to_pdf][pdfrest.PdfRestClient.convert_json_to_pdf], [convert_xml_to_pdf][pdfrest.PdfRestClient.convert_xml_to_pdf], [convert_csv_to_pdf][pdfrest.PdfRestClient.convert_csv_to_pdf] + + For PostScript or EPS conversion, upload a `.joboptions` file first and pass + the returned `PdfRestFile` as `job_options` to apply a custom Distiller + 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_to_word][pdfrest.PdfRestClient.convert_to_word], [convert_to_excel][pdfrest.PdfRestClient.convert_to_excel], diff --git a/docs/llms-full.txt b/docs/llms-full.txt index bd0761ac..8fefad50 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) +- Apply an uploaded `.joboptions` Distiller profile when converting PostScript + or EPS with `convert_postscript_to_pdf` - Convert to standards formats (`PDF/A`, `PDF/X`) ### Extraction and Analysis diff --git a/examples/README.md b/examples/README.md index 4493fdd8..332917de 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,6 +26,8 @@ supported interpreter matrix. - `examples/convert_structured_documents/convert_structured_documents_to_pdf_example.py` – convert Markdown, plain text, JSON, XML, and CSV documents to PDF with format-specific options. +- `examples/convert_postscript_to_pdf/convert_postscript_with_job_options_example.py` + – convert PostScript to PDF with an uploaded custom `.joboptions` profile. - `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_postscript_to_pdf/convert_postscript_with_job_options_example.py b/examples/convert_postscript_to_pdf/convert_postscript_with_job_options_example.py new file mode 100644 index 00000000..886122e5 --- /dev/null +++ b/examples/convert_postscript_to_pdf/convert_postscript_with_job_options_example.py @@ -0,0 +1,52 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Convert PostScript to PDF with a custom Distiller profile. + +This sample demonstrates how to: + +1. Upload the checked-in PostScript and ``.joboptions`` files. +2. Pass the uploaded profile to ``convert_postscript_to_pdf``. +3. Print the generated PDF's metadata and download URL. + +Set ``PDFREST_API_KEY``, then run from the repository root with +``uv run examples/convert_postscript_to_pdf/convert_postscript_with_job_options_example.py``. +All required input files are included in the repository. +""" + +from __future__ import annotations + +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import PdfRestClient + +RESOURCE_DIRECTORY = Path(__file__).resolve().parents[1] / "resources" +POSTSCRIPT_PATH = RESOURCE_DIRECTORY / "job-options-sample.ps" +JOB_OPTIONS_PATH = RESOURCE_DIRECTORY / "valid.joboptions" + + +def convert_postscript_with_job_options() -> None: + """Upload a PostScript document and convert it with a custom profile.""" + load_dotenv() + + with PdfRestClient() as client: + postscript = client.files.create_from_paths(POSTSCRIPT_PATH)[0] + job_options = client.files.create_from_paths(JOB_OPTIONS_PATH)[0] + response = client.convert_postscript_to_pdf( + postscript, + job_options=job_options, + output="custom-profile", + ) + + output = response.output_file + print(f"Created: {output.name}") + print(f"MIME type: {output.type}") + print(f"Size: {output.size} bytes") + print(f"Download URL: {output.url}") + + +if __name__ == "__main__": # pragma: no cover - manual example + convert_postscript_with_job_options() diff --git a/examples/resources/job-options-sample.ps b/examples/resources/job-options-sample.ps new file mode 100644 index 00000000..2572ef78 Binary files /dev/null and b/examples/resources/job-options-sample.ps differ diff --git a/examples/resources/valid.joboptions b/examples/resources/valid.joboptions new file mode 100644 index 00000000..08ebcdd5 --- /dev/null +++ b/examples/resources/valid.joboptions @@ -0,0 +1,28 @@ +%!PS-Adobe-3.0 +% Minimal conservative profile for custom joboptions sanity tests. +<< + /ASCII85EncodePages false + /AutoRotatePages /All + /CompatibilityLevel 1.7 + /CompressObjects /All + /CompressPages true + /CreateJobTicket false + /DefaultRenderingIntent /Default + /ColorConversionStrategy /LeaveColorUnchanged + /EmbedAllFonts true + /EndPage -1 + /LockDistillerParams false + /StartPage 1 + /SubsetFonts true + /UsePrologue false + /DownsampleColorImages false + /ColorImageResolution 300 + /DownsampleGrayImages false + /GrayImageResolution 300 + /DownsampleMonoImages false + /MonoImageResolution 1200 +>> setdistillerparams +<< + /HWResolution [600 600] + /PageSize [612.000 792.000] +>> setpagedevice diff --git a/pyproject.toml b/pyproject.toml index 093ab5eb..9e9d5fbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pdfrest" -version = "1.2.0" +version = "1.3.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 d7662c9b..08b7e757 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -4937,6 +4937,7 @@ def convert_postscript_to_pdf( output: str | None = None, compression: PdfConversionCompression = "lossy", downsample: PdfConversionDownsample | None = None, + job_options: PdfRestFile | Sequence[PdfRestFile] | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -4951,6 +4952,8 @@ def convert_postscript_to_pdf( output: Output filename prefix used by pdfRest when creating files. compression: Compression mode used during conversion. downsample: Image downsampling setting for conversion. + job_options: Uploaded `.joboptions` file containing a custom + Distiller conversion profile. 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. @@ -4968,6 +4971,7 @@ def convert_postscript_to_pdf( "output": output, "compression": compression, "downsample": downsample, + "job_options": job_options, } payload = {key: value for key, value in payload.items() if value is not None} @@ -8377,6 +8381,7 @@ async def convert_postscript_to_pdf( output: str | None = None, compression: PdfConversionCompression = "lossy", downsample: PdfConversionDownsample | None = None, + job_options: PdfRestFile | Sequence[PdfRestFile] | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, extra_body: Body | None = None, @@ -8391,6 +8396,8 @@ async def convert_postscript_to_pdf( output: Output filename prefix used by pdfRest when creating files. compression: Compression mode used during conversion. downsample: Image downsampling setting for conversion. + job_options: Uploaded `.joboptions` file containing a custom + Distiller conversion profile. 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. @@ -8408,6 +8415,7 @@ async def convert_postscript_to_pdf( "output": output, "compression": compression, "downsample": downsample, + "job_options": job_options, } payload = {key: value for key, value in payload.items() if value is not None} diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 88a2721a..e53827f1 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -297,6 +297,18 @@ def validate_file_extensions(value: list[PdfRestFile]) -> list[PdfRestFile]: return validate_file_extensions +def _allowed_file_size( + *, minimum: int, maximum: int, error_msg: str +) -> Callable[[list[PdfRestFile]], list[PdfRestFile]]: + def validate_file_size(value: list[PdfRestFile]) -> list[PdfRestFile]: + for file in value: + if not minimum <= file.size <= maximum: + raise ValueError(error_msg) + return value + + return validate_file_size + + def _int_to_string(value: Any) -> Any: if isinstance(value, int): return str(value) @@ -675,6 +687,11 @@ class ConvertToMarkdownPayload(BaseModel): "application/eps", "application/x-eps", } +_PDF_JOB_OPTIONS_MIME_TYPES = { + "application/octet-stream", + "application/postscript", + "text/plain", +} _PDF_EMAIL_MIME_TYPES = {"message/rfc822"} _PDF_IMAGE_MIME_TYPES = { "image/jpeg", @@ -770,6 +787,37 @@ class ConvertPostscriptToPdfPayload(BaseModel): PdfConversionDownsample | None, Field(serialization_alias="downsample", default=None), ] = None + job_options: Annotated[ + list[PdfRestFile] | None, + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("job_options", "job_options_id"), + serialization_alias="job_options_id", + default=None, + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types( + *_PDF_JOB_OPTIONS_MIME_TYPES, + error_msg="Must be a .joboptions file.", + ) + ), + AfterValidator( + _allowed_file_extensions( + ".joboptions", + error_msg="Must be a .joboptions file.", + ) + ), + AfterValidator( + _allowed_file_size( + minimum=1, + maximum=5 * 1024 * 1024, + error_msg="The .joboptions file must be between 1 byte and 5 MiB.", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None class ConvertEmailToPdfPayload(BaseModel): diff --git a/tests/live/test_live_convert_postscript_to_pdf.py b/tests/live/test_live_convert_postscript_to_pdf.py index 9b02145d..dd00f2db 100644 --- a/tests/live/test_live_convert_postscript_to_pdf.py +++ b/tests/live/test_live_convert_postscript_to_pdf.py @@ -21,6 +21,32 @@ def uploaded_postscript_for_pdf( return client.files.create_from_paths([resource])[0] +@pytest.fixture(scope="module") +def uploaded_job_options_for_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("valid.joboptions") + 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_invalid_job_options_for_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("invalid.joboptions") + 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", "compression", "downsample"), [ @@ -90,6 +116,103 @@ async def test_live_async_convert_postscript_to_pdf_success( assert output_file.name.startswith("live-postscript-async") +def test_live_convert_postscript_to_pdf_with_job_options( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_postscript_for_pdf: PdfRestFile, + uploaded_job_options_for_pdf: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.convert_postscript_to_pdf( + uploaded_postscript_for_pdf, + job_options=uploaded_job_options_for_pdf, + output="live-job-options", + ) + output_bytes = client.files.read_bytes(response.output_file) + + assert response.input_ids == [ + uploaded_postscript_for_pdf.id, + uploaded_job_options_for_pdf.id, + ] + assert response.output_file.name.startswith("live-job-options") + assert response.output_file.type == "application/pdf" + assert response.output_file.size > 0 + assert response.warning is None + assert output_bytes.startswith(b"%PDF-1.6") + + +@pytest.mark.asyncio +async def test_live_async_convert_postscript_to_pdf_with_job_options( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_postscript_for_pdf: PdfRestFile, + uploaded_job_options_for_pdf: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_postscript_to_pdf( + uploaded_postscript_for_pdf, + job_options=uploaded_job_options_for_pdf, + output="live-job-options-async", + ) + output_bytes = await client.files.read_bytes(response.output_file) + + assert response.input_ids == [ + uploaded_postscript_for_pdf.id, + uploaded_job_options_for_pdf.id, + ] + assert response.output_file.name.startswith("live-job-options-async") + assert response.output_file.type == "application/pdf" + assert response.output_file.size > 0 + assert response.warning is None + assert output_bytes.startswith(b"%PDF-1.6") + + +def test_live_convert_postscript_to_pdf_rejects_invalid_job_options( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_postscript_for_pdf: PdfRestFile, + uploaded_invalid_job_options_for_pdf: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"not a valid joboptions profile"), + ): + client.convert_postscript_to_pdf( + uploaded_postscript_for_pdf, + job_options=uploaded_invalid_job_options_for_pdf, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_postscript_to_pdf_rejects_invalid_job_options( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_postscript_for_pdf: PdfRestFile, + uploaded_invalid_job_options_for_pdf: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises( + PdfRestApiError, + match=r"not a valid joboptions profile", + ): + await client.convert_postscript_to_pdf( + uploaded_postscript_for_pdf, + job_options=uploaded_invalid_job_options_for_pdf, + ) + + def test_live_convert_postscript_to_pdf_invalid_downsample( pdfrest_api_key: str, pdfrest_live_base_url: str, diff --git a/tests/resources/invalid.joboptions b/tests/resources/invalid.joboptions new file mode 100644 index 00000000..ed1bd2d0 --- /dev/null +++ b/tests/resources/invalid.joboptions @@ -0,0 +1,3 @@ +This is intentionally not a Distiller parameter dictionary. + +It should be rejected by custom joboptions validation. diff --git a/tests/resources/valid.joboptions b/tests/resources/valid.joboptions new file mode 100644 index 00000000..7eba6170 --- /dev/null +++ b/tests/resources/valid.joboptions @@ -0,0 +1,28 @@ +%!PS-Adobe-3.0 +% Minimal conservative profile for custom joboptions sanity tests. +<< + /ASCII85EncodePages false + /AutoRotatePages /All + /CompatibilityLevel 1.6 + /CompressObjects /All + /CompressPages true + /CreateJobTicket false + /DefaultRenderingIntent /Default + /ColorConversionStrategy /LeaveColorUnchanged + /EmbedAllFonts true + /EndPage -1 + /LockDistillerParams false + /StartPage 1 + /SubsetFonts true + /UsePrologue false + /DownsampleColorImages false + /ColorImageResolution 300 + /DownsampleGrayImages false + /GrayImageResolution 300 + /DownsampleMonoImages false + /MonoImageResolution 1200 +>> setdistillerparams +<< + /HWResolution [600 600] + /PageSize [612.000 792.000] +>> setpagedevice diff --git a/tests/test_convert_postscript_to_pdf.py b/tests/test_convert_postscript_to_pdf.py index 20ae98ac..4435bdc3 100644 --- a/tests/test_convert_postscript_to_pdf.py +++ b/tests/test_convert_postscript_to_pdf.py @@ -7,20 +7,102 @@ from pydantic import ValidationError from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID from pdfrest.models._internal import ConvertPostscriptToPdfPayload from .convert_to_pdf_test_helpers import make_source_file from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, build_file_info_payload +MAX_JOB_OPTIONS_SIZE = 5 * 1024 * 1024 -def test_convert_postscript_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + +def _make_job_options_file( + *, + name: str = "valid.joboptions", + mime_type: str = "application/octet-stream", + size: int = 299, +) -> PdfRestFile: + return make_source_file( + str(PdfRestFileID.generate()), + mime_type, + name, + ).model_copy(update={"size": size}) + + +INVALID_JOB_OPTIONS_CASES: tuple[tuple[PdfRestFile | list[PdfRestFile], str], ...] = ( + ( + _make_job_options_file(mime_type="application/pdf"), + r"Must be a \.joboptions file", + ), + ( + _make_job_options_file(name="profile.txt"), + r"Must be a \.joboptions file", + ), + ( + _make_job_options_file(size=0), + "between 1 byte and 5 MiB", + ), + ( + _make_job_options_file(size=MAX_JOB_OPTIONS_SIZE + 1), + "between 1 byte and 5 MiB", + ), + ([], "at least 1 item"), + ( + [_make_job_options_file(), _make_job_options_file()], + "at most 1 item", + ), +) +INVALID_JOB_OPTIONS_CASE_IDS = ( + "mime-type", + "extension", + "empty-file", + "too-large", + "empty-list", + "multiple", +) + + +@pytest.mark.parametrize( + "job_options_size", + [ + pytest.param(1, id="minimum"), + pytest.param(2, id="minimum-plus-one"), + pytest.param(MAX_JOB_OPTIONS_SIZE - 1, id="maximum-minus-one"), + pytest.param(MAX_JOB_OPTIONS_SIZE, id="maximum"), + ], +) +@pytest.mark.parametrize( + "job_options_name", + [ + pytest.param("valid.joboptions", id="lowercase-extension"), + pytest.param("valid.JOBOPTIONS", id="uppercase-extension"), + ], +) +@pytest.mark.parametrize( + "job_options_mime_type", + [ + pytest.param("application/octet-stream", id="octet-stream"), + pytest.param("application/postscript", id="postscript"), + pytest.param("text/plain", id="plain-text"), + ], +) +def test_convert_postscript_to_pdf_success( + monkeypatch: pytest.MonkeyPatch, + job_options_size: int, + job_options_name: str, + job_options_mime_type: str, +) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_source_file( str(PdfRestFileID.generate(1)), "application/postscript", "example.ps", ) + job_options = _make_job_options_file( + name=job_options_name, + mime_type=job_options_mime_type, + size=job_options_size, + ) output_id = str(PdfRestFileID.generate()) payload_dump = ConvertPostscriptToPdfPayload.model_validate( { @@ -28,6 +110,7 @@ def test_convert_postscript_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> N "output": "converted", "compression": "lossless", "downsample": 150, + "job_options": job_options, } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) @@ -41,7 +124,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "inputId": [input_file.id], + "inputId": [input_file.id, job_options.id], "outputId": [output_id], }, ) @@ -64,6 +147,7 @@ def handler(request: httpx.Request) -> httpx.Response: output="converted", compression="lossless", downsample=150, + job_options=job_options, ) assert seen == {"post": 1, "get": 1} @@ -71,7 +155,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.output_file.name == "converted.pdf" assert response.output_file.type == "application/pdf" assert response.warning is None - assert str(response.input_id) == str(input_file.id) + assert response.input_ids == [input_file.id, job_options.id] def test_convert_postscript_to_pdf_request_customization( @@ -156,9 +240,124 @@ def test_convert_postscript_to_pdf_validation_errors() -> None: ) +@pytest.mark.parametrize( + "size", + [ + pytest.param(1, id="minimum"), + pytest.param(2, id="minimum-plus-one"), + pytest.param(MAX_JOB_OPTIONS_SIZE - 1, id="maximum-minus-one"), + pytest.param(MAX_JOB_OPTIONS_SIZE, id="maximum"), + ], +) +def test_convert_postscript_to_pdf_job_options_size_boundaries(size: int) -> None: + input_file = make_source_file( + str(PdfRestFileID.generate(2)), + "application/postscript", + "example.ps", + ) + job_options = _make_job_options_file(size=size) + + payload = ConvertPostscriptToPdfPayload.model_validate( + {"files": input_file, "job_options": job_options} + ).model_dump(mode="json", by_alias=True, exclude_none=True) + + assert payload == { + "id": str(input_file.id), + "job_options_id": str(job_options.id), + } + + +@pytest.mark.parametrize( + ("job_options", "error_match"), + [ + pytest.param( + _make_job_options_file(name="profile.txt"), + r"Must be a \.joboptions file", + id="extension", + ), + pytest.param( + _make_job_options_file(size=0), + "between 1 byte and 5 MiB", + id="empty", + ), + pytest.param( + _make_job_options_file(size=MAX_JOB_OPTIONS_SIZE + 1), + "between 1 byte and 5 MiB", + id="too-large", + ), + ], +) +def test_convert_postscript_to_pdf_job_options_validation( + job_options: PdfRestFile, + error_match: str, +) -> None: + input_file = make_source_file( + str(PdfRestFileID.generate(2)), + "application/postscript", + "example.ps", + ) + + with pytest.raises(ValidationError, match=error_match): + ConvertPostscriptToPdfPayload.model_validate( + {"files": input_file, "job_options": job_options} + ) + + +@pytest.mark.parametrize( + ("job_options", "error_match"), + INVALID_JOB_OPTIONS_CASES, + ids=INVALID_JOB_OPTIONS_CASE_IDS, +) +def test_convert_postscript_to_pdf_job_options_client_validation( + monkeypatch: pytest.MonkeyPatch, + job_options: PdfRestFile | list[PdfRestFile], + error_match: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_source_file( + str(PdfRestFileID.generate(2)), + "application/postscript", + "example.ps", + ) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match=error_match), + ): + client.convert_postscript_to_pdf(input_file, job_options=job_options) + + @pytest.mark.asyncio +@pytest.mark.parametrize( + "job_options_size", + [ + pytest.param(1, id="minimum"), + pytest.param(2, id="minimum-plus-one"), + pytest.param(MAX_JOB_OPTIONS_SIZE - 1, id="maximum-minus-one"), + pytest.param(MAX_JOB_OPTIONS_SIZE, id="maximum"), + ], +) +@pytest.mark.parametrize( + "job_options_name", + [ + pytest.param("valid.joboptions", id="lowercase-extension"), + pytest.param("valid.JOBOPTIONS", id="uppercase-extension"), + ], +) +@pytest.mark.parametrize( + "job_options_mime_type", + [ + pytest.param("application/octet-stream", id="octet-stream"), + pytest.param("application/postscript", id="postscript"), + pytest.param("text/plain", id="plain-text"), + ], +) async def test_async_convert_postscript_to_pdf_success( monkeypatch: pytest.MonkeyPatch, + job_options_size: int, + job_options_name: str, + job_options_mime_type: str, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_source_file( @@ -166,12 +365,18 @@ async def test_async_convert_postscript_to_pdf_success( "application/x-eps", "graphic.eps", ) + job_options = _make_job_options_file( + name=job_options_name, + mime_type=job_options_mime_type, + size=job_options_size, + ) output_id = str(PdfRestFileID.generate()) payload_dump = ConvertPostscriptToPdfPayload.model_validate( { "files": [input_file], "output": "async-converted", "compression": "lossless", + "job_options": job_options, } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) @@ -185,7 +390,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "inputId": [input_file.id], + "inputId": [input_file.id, job_options.id], "outputId": [output_id], }, ) @@ -206,13 +411,44 @@ def handler(request: httpx.Request) -> httpx.Response: input_file, output="async-converted", compression="lossless", + job_options=job_options, ) assert seen == {"post": 1, "get": 1} assert isinstance(response, PdfRestFileBasedResponse) assert response.output_file.name == "async-converted.pdf" assert response.output_file.type == "application/pdf" - assert str(response.input_id) == str(input_file.id) + assert response.input_ids == [input_file.id, job_options.id] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("job_options", "error_match"), + INVALID_JOB_OPTIONS_CASES, + ids=INVALID_JOB_OPTIONS_CASE_IDS, +) +async def test_async_convert_postscript_to_pdf_job_options_client_validation( + monkeypatch: pytest.MonkeyPatch, + job_options: PdfRestFile | list[PdfRestFile], + error_match: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_source_file( + str(PdfRestFileID.generate(2)), + "application/postscript", + "example.ps", + ) + 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=error_match): + await client.convert_postscript_to_pdf( + input_file, + job_options=job_options, + ) @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index 4461ce38..e07d59a3 100644 --- a/uv.lock +++ b/uv.lock @@ -964,7 +964,7 @@ wheels = [ [[package]] name = "pdfrest" -version = "1.2.0" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "exceptiongroup" },