From cfaf8898bb56bb29b15102697d8f2aff0a4326ad Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 10:41:35 -0500 Subject: [PATCH 01/10] client: Add PostScript job options support - Accept uploaded .joboptions profiles in sync and async conversion - Validate profile type, extension, size, and file count - Serialize the uploaded profile as the job_options_id request field Assisted-by: Codex --- src/pdfrest/client.py | 8 ++ src/pdfrest/models/_internal.py | 43 ++++++ tests/test_convert_postscript_to_pdf.py | 167 +++++++++++++++++++++++- 3 files changed, 213 insertions(+), 5 deletions(-) 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..50349f30 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) @@ -770,6 +782,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( + "application/octet-stream", + 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/test_convert_postscript_to_pdf.py b/tests/test_convert_postscript_to_pdf.py index 20ae98ac..eef4365c 100644 --- a/tests/test_convert_postscript_to_pdf.py +++ b/tests/test_convert_postscript_to_pdf.py @@ -7,12 +7,27 @@ 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 _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}) + def test_convert_postscript_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) @@ -21,6 +36,7 @@ def test_convert_postscript_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> N "application/postscript", "example.ps", ) + job_options = _make_job_options_file() output_id = str(PdfRestFileID.generate()) payload_dump = ConvertPostscriptToPdfPayload.model_validate( { @@ -28,6 +44,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 +58,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 +81,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 +89,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,6 +174,102 @@ def test_convert_postscript_to_pdf_validation_errors() -> None: ) +@pytest.mark.parametrize( + "size", + [ + pytest.param(1, id="minimum"), + 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"), + [ + pytest.param( + _make_job_options_file(mime_type="text/plain"), + r"Must be a \.joboptions file", + id="mime-type", + ), + pytest.param( + [_make_job_options_file(), _make_job_options_file()], + "at most 1 item", + id="multiple", + ), + ], +) +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 async def test_async_convert_postscript_to_pdf_success( monkeypatch: pytest.MonkeyPatch, @@ -166,12 +280,14 @@ async def test_async_convert_postscript_to_pdf_success( "application/x-eps", "graphic.eps", ) + job_options = _make_job_options_file() 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 +301,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 +322,54 @@ 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"), + [ + pytest.param( + _make_job_options_file(mime_type="text/plain"), + r"Must be a \.joboptions file", + id="mime-type", + ), + pytest.param( + [_make_job_options_file(), _make_job_options_file()], + "at most 1 item", + id="multiple", + ), + ], +) +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 From b0f0be668cf3936810405720ddc942467ae2f5ac Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 10:42:15 -0500 Subject: [PATCH 02/10] tests: Cover PostScript job options live - Upload a known-valid Distiller profile for integration coverage - Verify sync and async conversions return both uploaded input IDs Assisted-by: Codex --- .../test_live_convert_postscript_to_pdf.py | 68 +++++++++++++++++++ tests/resources/valid.joboptions | 28 ++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/resources/valid.joboptions diff --git a/tests/live/test_live_convert_postscript_to_pdf.py b/tests/live/test_live_convert_postscript_to_pdf.py index 9b02145d..01a1d6f3 100644 --- a/tests/live/test_live_convert_postscript_to_pdf.py +++ b/tests/live/test_live_convert_postscript_to_pdf.py @@ -21,6 +21,21 @@ 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, "application/octet-stream")])[ + 0 + ] + + @pytest.mark.parametrize( ("output_name", "compression", "downsample"), [ @@ -90,6 +105,59 @@ 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", + ) + + 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 + + +@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", + ) + + 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 + + def test_live_convert_postscript_to_pdf_invalid_downsample( pdfrest_api_key: str, pdfrest_live_base_url: str, diff --git a/tests/resources/valid.joboptions b/tests/resources/valid.joboptions new file mode 100644 index 00000000..08ebcdd5 --- /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.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 From 10e45c92a2c5fe28f722a7d69affc48f68e9997f Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 10:43:04 -0500 Subject: [PATCH 03/10] examples: Demonstrate PostScript job options - Add a runnable conversion with an uploaded Distiller profile - Include deterministic PostScript and joboptions example assets - Document the workflow in the example inventory and API guides Assisted-by: Codex --- docs/api-guide.md | 5 ++ docs/llms-full.txt | 2 + examples/README.md | 2 + ...ert_postscript_with_job_options_example.py | 54 ++++++++++++++++++ examples/resources/job-options-sample.ps | Bin 0 -> 211 bytes examples/resources/valid.joboptions | 28 +++++++++ 6 files changed, 91 insertions(+) create mode 100644 examples/convert_postscript_to_pdf/convert_postscript_with_job_options_example.py create mode 100644 examples/resources/job-options-sample.ps create mode 100644 examples/resources/valid.joboptions 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..f9bd148a --- /dev/null +++ b/examples/convert_postscript_to_pdf/convert_postscript_with_job_options_example.py @@ -0,0 +1,54 @@ +# /// 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, "application/octet-stream")] + )[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 0000000000000000000000000000000000000000..2572ef782f297a2294181096a5afc824fe8ab373 GIT binary patch literal 211 zcmXYrO%8%E5QX> setdistillerparams +<< + /HWResolution [600 600] + /PageSize [612.000 792.000] +>> setpagedevice From a8b41fb0adb3d95f2f975adb91988c101766d9e6 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 10:43:25 -0500 Subject: [PATCH 04/10] pyproject: Bump package version to 1.3.0 - Update project metadata for the joboptions feature release - Keep the locked editable package version in sync Assisted-by: Codex --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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" }, From d853518469f848509f26e7dfd37668701b1d9e80 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 13:30:29 -0500 Subject: [PATCH 05/10] tests: Complete job options validation coverage - Exercise metadata and cardinality failures through both clients - Verify accepted file sizes at and adjacent to each boundary - Ensure invalid inputs fail before reaching the transport Assisted-by: Codex --- tests/test_convert_postscript_to_pdf.py | 91 +++++++++++++++++-------- 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/tests/test_convert_postscript_to_pdf.py b/tests/test_convert_postscript_to_pdf.py index eef4365c..86d4f58d 100644 --- a/tests/test_convert_postscript_to_pdf.py +++ b/tests/test_convert_postscript_to_pdf.py @@ -29,14 +29,59 @@ def _make_job_options_file( ).model_copy(update={"size": size}) -def test_convert_postscript_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: +INVALID_JOB_OPTIONS_CASES: tuple[tuple[PdfRestFile | list[PdfRestFile], str], ...] = ( + ( + _make_job_options_file(mime_type="text/plain"), + 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"), + ], +) +def test_convert_postscript_to_pdf_success( + monkeypatch: pytest.MonkeyPatch, + job_options_size: int, +) -> 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() + job_options = _make_job_options_file(size=job_options_size) output_id = str(PdfRestFileID.generate()) payload_dump = ConvertPostscriptToPdfPayload.model_validate( { @@ -178,6 +223,8 @@ def test_convert_postscript_to_pdf_validation_errors() -> None: "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"), ], ) @@ -237,18 +284,8 @@ def test_convert_postscript_to_pdf_job_options_validation( @pytest.mark.parametrize( ("job_options", "error_match"), - [ - pytest.param( - _make_job_options_file(mime_type="text/plain"), - r"Must be a \.joboptions file", - id="mime-type", - ), - pytest.param( - [_make_job_options_file(), _make_job_options_file()], - "at most 1 item", - id="multiple", - ), - ], + INVALID_JOB_OPTIONS_CASES, + ids=INVALID_JOB_OPTIONS_CASE_IDS, ) def test_convert_postscript_to_pdf_job_options_client_validation( monkeypatch: pytest.MonkeyPatch, @@ -271,8 +308,18 @@ def test_convert_postscript_to_pdf_job_options_client_validation( @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"), + ], +) async def test_async_convert_postscript_to_pdf_success( monkeypatch: pytest.MonkeyPatch, + job_options_size: int, ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_source_file( @@ -280,7 +327,7 @@ async def test_async_convert_postscript_to_pdf_success( "application/x-eps", "graphic.eps", ) - job_options = _make_job_options_file() + job_options = _make_job_options_file(size=job_options_size) output_id = str(PdfRestFileID.generate()) payload_dump = ConvertPostscriptToPdfPayload.model_validate( { @@ -335,18 +382,8 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio @pytest.mark.parametrize( ("job_options", "error_match"), - [ - pytest.param( - _make_job_options_file(mime_type="text/plain"), - r"Must be a \.joboptions file", - id="mime-type", - ), - pytest.param( - [_make_job_options_file(), _make_job_options_file()], - "at most 1 item", - id="multiple", - ), - ], + 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, From 151aed9fd753a16e480ee1c3fdc88a96420c22b7 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 13:31:10 -0500 Subject: [PATCH 06/10] tests: Verify job options affect generated PDFs - Set the validation profile to request PDF 1.6 output - Download sync and async results to confirm the requested version Assisted-by: Codex --- tests/live/test_live_convert_postscript_to_pdf.py | 4 ++++ tests/resources/valid.joboptions | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/live/test_live_convert_postscript_to_pdf.py b/tests/live/test_live_convert_postscript_to_pdf.py index 01a1d6f3..0606eace 100644 --- a/tests/live/test_live_convert_postscript_to_pdf.py +++ b/tests/live/test_live_convert_postscript_to_pdf.py @@ -120,6 +120,7 @@ def test_live_convert_postscript_to_pdf_with_job_options( 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, @@ -129,6 +130,7 @@ def test_live_convert_postscript_to_pdf_with_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 @@ -147,6 +149,7 @@ async def test_live_async_convert_postscript_to_pdf_with_job_options( 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, @@ -156,6 +159,7 @@ async def test_live_async_convert_postscript_to_pdf_with_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") def test_live_convert_postscript_to_pdf_invalid_downsample( diff --git a/tests/resources/valid.joboptions b/tests/resources/valid.joboptions index 08ebcdd5..7eba6170 100644 --- a/tests/resources/valid.joboptions +++ b/tests/resources/valid.joboptions @@ -3,7 +3,7 @@ << /ASCII85EncodePages false /AutoRotatePages /All - /CompatibilityLevel 1.7 + /CompatibilityLevel 1.6 /CompressObjects /All /CompressPages true /CreateJobTicket false From b636277eb15950bc3cc2905edda818f90fca254c Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 13:31:41 -0500 Subject: [PATCH 07/10] tests: Cover malformed job options profiles live - Upload an invalid Distiller profile as a live-test fixture - Assert the service rejection through sync and async clients Assisted-by: Codex --- .../test_live_convert_postscript_to_pdf.py | 55 +++++++++++++++++++ tests/resources/invalid.joboptions | 3 + 2 files changed, 58 insertions(+) create mode 100644 tests/resources/invalid.joboptions diff --git a/tests/live/test_live_convert_postscript_to_pdf.py b/tests/live/test_live_convert_postscript_to_pdf.py index 0606eace..684ef097 100644 --- a/tests/live/test_live_convert_postscript_to_pdf.py +++ b/tests/live/test_live_convert_postscript_to_pdf.py @@ -36,6 +36,21 @@ def uploaded_job_options_for_pdf( ] +@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, "application/octet-stream")])[ + 0 + ] + + @pytest.mark.parametrize( ("output_name", "compression", "downsample"), [ @@ -162,6 +177,46 @@ async def test_live_async_convert_postscript_to_pdf_with_job_options( 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. From d908f269197709781e55064131b800957e60e2f3 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 10:27:13 -0500 Subject: [PATCH 08/10] client: Add passwords to graphic conversions - Accept open passwords for PNG, BMP, GIF, JPEG, and TIFF helpers - Serialize passwords through the shared sync and async payload contract - Cover request serialization and empty-password rejection Assisted-by: Codex --- src/pdfrest/client.py | 40 ++++++++++ src/pdfrest/models/_internal.py | 4 + tests/test_convert_to_bmp.py | 6 ++ tests/test_convert_to_gif.py | 6 ++ tests/test_convert_to_jpeg.py | 6 ++ tests/test_convert_to_png.py | 6 ++ tests/test_convert_to_tiff.py | 6 ++ tests/test_graphic_payload_validation.py | 94 +++++++++++++++++++++++- 8 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 08b7e757..9c8a8b1f 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -5780,6 +5780,7 @@ def convert_to_png( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: PngColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -5796,6 +5797,7 @@ def convert_to_png( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -5820,6 +5822,8 @@ def convert_to_png( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return self._convert_to_graphic( endpoint="/png", @@ -5838,6 +5842,7 @@ def convert_to_bmp( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: BmpColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -5854,6 +5859,7 @@ def convert_to_bmp( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -5878,6 +5884,8 @@ def convert_to_bmp( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return self._convert_to_graphic( endpoint="/bmp", @@ -5896,6 +5904,7 @@ def convert_to_gif( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: GifColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -5912,6 +5921,7 @@ def convert_to_gif( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -5936,6 +5946,8 @@ def convert_to_gif( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return self._convert_to_graphic( endpoint="/gif", @@ -5954,6 +5966,7 @@ def convert_to_jpeg( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: JpegColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", jpeg_quality: int = 75, @@ -5971,6 +5984,7 @@ def convert_to_jpeg( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. jpeg_quality: JPEG quality setting (1-100) controlling compression level and output fidelity. @@ -5997,6 +6011,8 @@ def convert_to_jpeg( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return self._convert_to_graphic( endpoint="/jpg", @@ -6015,6 +6031,7 @@ def convert_to_tiff( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: TiffColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -6031,6 +6048,7 @@ def convert_to_tiff( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -6055,6 +6073,8 @@ def convert_to_tiff( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return self._convert_to_graphic( endpoint="/tif", @@ -9108,6 +9128,7 @@ async def convert_to_png( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: PngColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -9124,6 +9145,7 @@ async def convert_to_png( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -9148,6 +9170,8 @@ async def convert_to_png( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return await self._convert_to_graphic( endpoint="/png", @@ -9166,6 +9190,7 @@ async def convert_to_bmp( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: BmpColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -9182,6 +9207,7 @@ async def convert_to_bmp( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -9206,6 +9232,8 @@ async def convert_to_bmp( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return await self._convert_to_graphic( endpoint="/bmp", @@ -9224,6 +9252,7 @@ async def convert_to_gif( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: GifColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -9240,6 +9269,7 @@ async def convert_to_gif( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -9264,6 +9294,8 @@ async def convert_to_gif( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return await self._convert_to_graphic( endpoint="/gif", @@ -9282,6 +9314,7 @@ async def convert_to_jpeg( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: JpegColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", jpeg_quality: int = 75, @@ -9299,6 +9332,7 @@ async def convert_to_jpeg( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. jpeg_quality: JPEG quality setting (1-100) controlling compression level and output fidelity. @@ -9325,6 +9359,8 @@ async def convert_to_jpeg( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return await self._convert_to_graphic( endpoint="/jpg", @@ -9343,6 +9379,7 @@ async def convert_to_tiff( output_prefix: str | None = None, page_range: str | Sequence[str] | None = None, resolution: int = 300, + password: str | None = None, color_model: TiffColorModel = "rgb", smoothing: GraphicSmoothing | Sequence[GraphicSmoothing] = "none", extra_query: Query | None = None, @@ -9359,6 +9396,7 @@ async def convert_to_tiff( output_prefix: Filename prefix used for generated per-page/per-file image outputs. page_range: Page selection string/list for image conversion outputs. resolution: Raster output resolution in DPI for generated image files. + password: Password for opening an encrypted input PDF. color_model: Output image color model. smoothing: Graphic smoothing mode for image output endpoints. extra_query: Additional query parameters merged into the request. @@ -9383,6 +9421,8 @@ async def convert_to_tiff( payload["output_prefix"] = output_prefix if page_range is not None: payload["page_range"] = page_range + if password is not None: + payload["password"] = password return await self._convert_to_graphic( endpoint="/tif", diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 50349f30..34b88737 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1643,6 +1643,10 @@ class BasePdfRestGraphicPayload(BaseModel, Generic[ColorModelT]): PlainSerializer(_serialize_page_ranges), ] resolution: Annotated[int, Field(ge=12, le=2400, default=300)] + password: Annotated[ + str | None, + Field(min_length=1, default=None), + ] = None color_model: Annotated[ColorModelT, Field(default=...)] smoothing: Annotated[ list[Literal["none", "all", "text", "line", "image"]], diff --git a/tests/test_convert_to_bmp.py b/tests/test_convert_to_bmp.py index a07eb749..f5a87455 100644 --- a/tests/test_convert_to_bmp.py +++ b/tests/test_convert_to_bmp.py @@ -20,6 +20,8 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + @pytest.mark.parametrize("color_model", ["rgb", "gray"]) def test_convert_to_bmp_success( @@ -36,6 +38,7 @@ def test_convert_to_bmp_success( "output_prefix": "converted", "page_range": ["1", "2-3"], "resolution": 600, + "password": _TEST_PASSWORD, "color_model": color_model, "smoothing": ["text", "image"], } @@ -68,6 +71,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="converted", page_range=["1", "2-3"], resolution=600, + password=_TEST_PASSWORD, color_model=color_model, # type: ignore[arg-type] smoothing=["text", "image"], ) @@ -231,6 +235,7 @@ async def test_async_convert_to_bmp_success( "output_prefix": "async-output", "page_range": "1-2", "resolution": 450, + "password": _TEST_PASSWORD, "color_model": "gray", "smoothing": ["all"], } @@ -266,6 +271,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="async-output", page_range="1-2", resolution=450, + password=_TEST_PASSWORD, color_model="gray", smoothing=["all"], ) diff --git a/tests/test_convert_to_gif.py b/tests/test_convert_to_gif.py index 00569c67..88c9d485 100644 --- a/tests/test_convert_to_gif.py +++ b/tests/test_convert_to_gif.py @@ -20,6 +20,8 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + @pytest.mark.parametrize("color_model", ["rgb", "gray"]) def test_convert_to_gif_success( @@ -36,6 +38,7 @@ def test_convert_to_gif_success( "output_prefix": "converted", "page_range": ["1", "3-4"], "resolution": 500, + "password": _TEST_PASSWORD, "color_model": color_model, "smoothing": ["text", "image"], } @@ -68,6 +71,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="converted", page_range=["1", "3-4"], resolution=500, + password=_TEST_PASSWORD, color_model=color_model, # type: ignore[arg-type] smoothing=["text", "image"], ) @@ -228,6 +232,7 @@ async def test_async_convert_to_gif_success( "output_prefix": "async-output", "page_range": "1-2", "resolution": 400, + "password": _TEST_PASSWORD, "color_model": "gray", "smoothing": ["all"], } @@ -263,6 +268,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="async-output", page_range="1-2", resolution=400, + password=_TEST_PASSWORD, color_model="gray", smoothing=["all"], ) diff --git a/tests/test_convert_to_jpeg.py b/tests/test_convert_to_jpeg.py index 4ebf5a4e..950171d6 100644 --- a/tests/test_convert_to_jpeg.py +++ b/tests/test_convert_to_jpeg.py @@ -20,6 +20,8 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + @pytest.mark.parametrize("color_model", ["rgb", "cmyk", "gray"]) def test_convert_to_jpeg_success( @@ -36,6 +38,7 @@ def test_convert_to_jpeg_success( "output_prefix": "converted", "page_range": ["1", "2-3"], "resolution": 450, + "password": _TEST_PASSWORD, "color_model": color_model, "jpeg_quality": 90, "smoothing": ["text", "image"], @@ -71,6 +74,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="converted", page_range=["1", "2-3"], resolution=450, + password=_TEST_PASSWORD, color_model=color_model, # type: ignore[arg-type] smoothing=["text", "image"], jpeg_quality=90, @@ -428,6 +432,7 @@ async def test_async_convert_to_jpeg_success( "output_prefix": "async-output", "page_range": "1-2", "resolution": 500, + "password": _TEST_PASSWORD, "color_model": color_model, "jpeg_quality": 85, "smoothing": ["all"], @@ -466,6 +471,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="async-output", page_range="1-2", resolution=500, + password=_TEST_PASSWORD, color_model=color_model, smoothing=["all"], jpeg_quality=85, diff --git a/tests/test_convert_to_png.py b/tests/test_convert_to_png.py index 01569644..a0551eb4 100644 --- a/tests/test_convert_to_png.py +++ b/tests/test_convert_to_png.py @@ -19,6 +19,8 @@ make_pdf_file, ) +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + @pytest.mark.parametrize("color_model", ["rgb", "rgba", "gray"]) def test_convert_to_png_success( @@ -35,6 +37,7 @@ def test_convert_to_png_success( "output_prefix": "converted", "page_range": ["1", "2-3"], "resolution": 600, + "password": _TEST_PASSWORD, "color_model": color_model, "smoothing": ["text", "image"], } @@ -72,6 +75,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="converted", page_range=["1", "2-3"], resolution=600, + password=_TEST_PASSWORD, color_model=color_model, # type: ignore[arg-type] smoothing=["text", "image"], ) @@ -252,6 +256,7 @@ async def test_async_convert_to_png_success(monkeypatch: pytest.MonkeyPatch) -> "output_prefix": "async-output", "page_range": "1-2", "resolution": 450, + "password": _TEST_PASSWORD, "color_model": "rgb", "smoothing": ["all"], } @@ -290,6 +295,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="async-output", page_range="1-2", resolution=450, + password=_TEST_PASSWORD, color_model="rgb", smoothing=["all"], ) diff --git a/tests/test_convert_to_tiff.py b/tests/test_convert_to_tiff.py index 13fe836d..1d77af8c 100644 --- a/tests/test_convert_to_tiff.py +++ b/tests/test_convert_to_tiff.py @@ -20,6 +20,8 @@ ) from .resources import get_test_resource_path +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + @pytest.mark.parametrize( "color_model", @@ -39,6 +41,7 @@ def test_convert_to_tiff_success( "output_prefix": "converted", "page_range": ["1", "last"], "resolution": 600, + "password": _TEST_PASSWORD, "color_model": color_model, "smoothing": ["text", "image"], } @@ -71,6 +74,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="converted", page_range=["1", "last"], resolution=600, + password=_TEST_PASSWORD, color_model=color_model, # type: ignore[arg-type] smoothing=["text", "image"], ) @@ -236,6 +240,7 @@ async def test_async_convert_to_tiff_success( "output_prefix": "async-output", "page_range": "1-2", "resolution": 500, + "password": _TEST_PASSWORD, "color_model": "rgba", "smoothing": ["all"], } @@ -271,6 +276,7 @@ def handler(request: httpx.Request) -> httpx.Response: output_prefix="async-output", page_range="1-2", resolution=500, + password=_TEST_PASSWORD, color_model="rgba", smoothing=["all"], ) diff --git a/tests/test_graphic_payload_validation.py b/tests/test_graphic_payload_validation.py index cea6c5a1..ba4c762d 100644 --- a/tests/test_graphic_payload_validation.py +++ b/tests/test_graphic_payload_validation.py @@ -3,9 +3,11 @@ import re from typing import Any +import httpx import pytest from pydantic import ValidationError +from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models._internal import ( BasePdfRestGraphicPayload, BmpPdfRestPayload, @@ -15,7 +17,7 @@ TiffPdfRestPayload, ) -from .graphics_test_helpers import make_pdf_file +from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file PAYLOAD_MODELS: tuple[type[BasePdfRestGraphicPayload[Any]], ...] = ( PngPdfRestPayload, @@ -24,6 +26,96 @@ JpegPdfRestPayload, TiffPdfRestPayload, ) +GRAPHIC_METHOD_NAMES = ( + "convert_to_png", + "convert_to_bmp", + "convert_to_gif", + "convert_to_jpeg", + "convert_to_tiff", +) +_TEST_PASSWORD = "open-sesame" # noqa: S105 - Test-only password. + + +@pytest.mark.parametrize("payload_model", PAYLOAD_MODELS) +def test_graphic_payload_serializes_password( + payload_model: type[BasePdfRestGraphicPayload[Any]], +) -> None: + payload = payload_model.model_validate( + { + "files": [make_pdf_file("12345678-1234-4abc-8def-1234567890ab")], + "password": _TEST_PASSWORD, + } + ) + + data = payload.model_dump(mode="json", by_alias=True, exclude_none=True) + assert data["password"] == _TEST_PASSWORD + + +@pytest.mark.parametrize("payload_model", PAYLOAD_MODELS) +def test_graphic_payload_rejects_empty_password( + payload_model: type[BasePdfRestGraphicPayload[Any]], +) -> None: + with pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ): + payload_model.model_validate( + { + "files": [make_pdf_file("12345678-1234-4abc-8def-1234567890ab")], + "password": "", + } + ) + + +@pytest.mark.parametrize("method_name", GRAPHIC_METHOD_NAMES) +def test_graphic_client_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, + method_name: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(handler), + ) as client, + pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ), + ): + getattr(client, method_name)( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method_name", GRAPHIC_METHOD_NAMES) +async def test_async_graphic_client_rejects_empty_password( + monkeypatch: pytest.MonkeyPatch, + method_name: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when password validation fails.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(handler), + ) as client: + with pytest.raises( + ValidationError, + match=r"password\s+String should have at least 1 character", + ): + await getattr(client, method_name)( + make_pdf_file("12345678-1234-4abc-8def-1234567890ab"), + password="", + ) @pytest.mark.parametrize("payload_model", PAYLOAD_MODELS) From 221594475923afdbe9beab13e5228539163e9787 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 10:27:59 -0500 Subject: [PATCH 09/10] tests: Cover graphic conversion passwords live - Verify encrypted inputs across PNG, BMP, GIF, JPEG, and TIFF - Exercise correct passwords through synchronous and asynchronous clients - Confirm incorrect passwords return an API error Assisted-by: Codex --- tests/live/test_live_graphic_conversions.py | 120 ++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/tests/live/test_live_graphic_conversions.py b/tests/live/test_live_graphic_conversions.py index 9c13ae31..6c3d9d18 100644 --- a/tests/live/test_live_graphic_conversions.py +++ b/tests/live/test_live_graphic_conversions.py @@ -43,6 +43,8 @@ class _GraphicEndpointSpec(NamedTuple): "jpeg": ("image/jpeg", ".jpg"), "tiff": ("image/tiff", ".tif"), } +_GRAPHIC_PASSWORD = "graphic-password" # noqa: S105 - Test-only password. +_WRONG_GRAPHIC_PASSWORD = "wrong-password" # noqa: S105 - Test-only password. def _enumerate_color_models( @@ -145,6 +147,124 @@ def uploaded_20_page_pdf( return client.files.create_from_paths([resource])[0] +@pytest.fixture(scope="module") +def encrypted_pdf_for_graphics( + 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: + uploaded = client.files.create_from_paths([resource])[0] + return client.add_open_password( + uploaded, + new_open_password=_GRAPHIC_PASSWORD, + output="graphic-password-source", + ).output_file + + +@pytest.mark.parametrize( + ("endpoint_label", "spec"), + PAYLOAD_MODELS.items(), + ids=list(PAYLOAD_MODELS), +) +def test_live_graphic_password( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + encrypted_pdf_for_graphics: PdfRestFile, + endpoint_label: str, + spec: _GraphicEndpointSpec, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + client_method = getattr(client, spec.method_name) + response = client_method( + encrypted_pdf_for_graphics, + password=_GRAPHIC_PASSWORD, + output_prefix=f"live-password-{endpoint_label}", + ) + + expected_mime, expected_suffix = _expected_file_format(endpoint_label) + _assert_output_files( + response.output_files, + expected_mime=expected_mime, + expected_suffix=expected_suffix, + ) + assert str(response.input_id) == str(encrypted_pdf_for_graphics.id) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("endpoint_label", "spec"), + PAYLOAD_MODELS.items(), + ids=list(PAYLOAD_MODELS), +) +async def test_live_async_graphic_password( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + encrypted_pdf_for_graphics: PdfRestFile, + endpoint_label: str, + spec: _GraphicEndpointSpec, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + client_method = getattr(client, spec.method_name) + response = await client_method( + encrypted_pdf_for_graphics, + password=_GRAPHIC_PASSWORD, + output_prefix=f"live-async-password-{endpoint_label}", + ) + + expected_mime, expected_suffix = _expected_file_format(endpoint_label) + _assert_output_files( + response.output_files, + expected_mime=expected_mime, + expected_suffix=expected_suffix, + ) + assert str(response.input_id) == str(encrypted_pdf_for_graphics.id) + + +def test_live_graphic_invalid_password( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + encrypted_pdf_for_graphics: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)password"), + ): + client.convert_to_png( + encrypted_pdf_for_graphics, + password=_WRONG_GRAPHIC_PASSWORD, + ) + + +@pytest.mark.asyncio +async def test_live_async_graphic_invalid_password( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + encrypted_pdf_for_graphics: 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)password"): + await client.convert_to_png( + encrypted_pdf_for_graphics, + password=_WRONG_GRAPHIC_PASSWORD, + ) + + def test_live_convert_to_png_success( pdfrest_api_key: str, pdfrest_live_base_url: str, From a7769950d27850c6763223398393876cca60d3f4 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 14 Oct 2026 10:35:57 -0500 Subject: [PATCH 10/10] examples: Demonstrate encrypted PDF image conversion - Add a runnable protected-PDF-to-PNG workflow using an open password - Link the example from the inventory and API guide for discoverability Assisted-by: Codex --- docs/api-guide.md | 5 ++ examples/README.md | 3 + ...t_password_protected_pdf_to_png_example.py | 55 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 examples/convert_to_image/convert_password_protected_pdf_to_png_example.py diff --git a/docs/api-guide.md b/docs/api-guide.md index 60abe3e5..b954b904 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -158,6 +158,11 @@ formats. [convert_to_jpeg][pdfrest.PdfRestClient.convert_to_jpeg], [convert_to_tiff][pdfrest.PdfRestClient.convert_to_tiff] +All PDF-to-image helpers accept `password` for opening an encrypted input PDF. +See the +[password-protected PDF example](https://github.com/pdfrest/pdfrest-python/blob/main/examples/convert_to_image/convert_password_protected_pdf_to_png_example.py) +for the complete upload, protection, and conversion workflow. + ## Forms data workflows Use this group when users need to transfer form field values in/out of PDFs. diff --git a/examples/README.md b/examples/README.md index 332917de..63227ae3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,6 +28,9 @@ supported interpreter matrix. 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/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/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_to_image/convert_password_protected_pdf_to_png_example.py b/examples/convert_to_image/convert_password_protected_pdf_to_png_example.py new file mode 100644 index 00000000..934fd4fe --- /dev/null +++ b/examples/convert_to_image/convert_password_protected_pdf_to_png_example.py @@ -0,0 +1,55 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Convert a password-protected PDF into PNG images. + +This sample demonstrates how to: + +1. Upload the bundled ``examples/resources/report.pdf`` resource. +2. Add an open password to create a protected PDF. +3. Supply that password to ``convert_to_png``. +4. Print metadata for every generated PNG image. + +Set ``PDFREST_API_KEY``, then run from the repository root with +``uv run examples/convert_to_image/convert_password_protected_pdf_to_png_example.py``. +The input PDF is included in the repository, so no additional files are required. +""" + +from __future__ import annotations + +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import PdfRestClient + +RESOURCE = Path(__file__).resolve().parents[1] / "resources" / "report.pdf" +PASSWORD = "example-password" # noqa: S105 - Demonstration-only password. + + +def convert_password_protected_pdf() -> None: + """Protect the sample PDF, then convert its pages with the same password.""" + load_dotenv() + + with PdfRestClient() as client: + uploaded = client.files.create_from_paths([RESOURCE])[0] + protected = client.add_open_password( + uploaded, + new_open_password=PASSWORD, + output="password-protected-report", + ).output_file + response = client.convert_to_png( + protected, + password=PASSWORD, + output_prefix="password-protected-report-page", + ) + + print(f"Created {len(response.output_files)} PNG image(s)") + for output in response.output_files: + print(f"{output.name}: {output.type}, {output.size} bytes") + print(f" Download URL: {output.url}") + + +if __name__ == "__main__": # pragma: no cover - manual example + convert_password_protected_pdf()