Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/skills/pr-review-auditor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
- Determine PR base branch, compute merge-base, and audit `<merge_base>..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

Expand Down
14 changes: 11 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(...)`,
Expand Down
12 changes: 9 additions & 3 deletions TESTING_GUIDELINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/api-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
2 changes: 2 additions & 0 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Binary file added examples/resources/job-options-sample.ps
Binary file not shown.
28 changes: 28 additions & 0 deletions examples/resources/valid.joboptions
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
8 changes: 8 additions & 0 deletions src/pdfrest/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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}

Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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}

Expand Down
48 changes: 48 additions & 0 deletions src/pdfrest/models/_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading