Skip to content

Commit 9e00671

Browse files
Add support for multi-format graphic conversions (sync & async)
- Implemented methods to convert PDF files to BMP, GIF, JPEG, PNG, and TIFF formats in both synchronous and asynchronous clients. - Centralized `convert_to_graphic` method for reusable logic across all conversion methods. - Added payload models for each graphic format to ensure parameter validation and API compatibility. - Extended tests to verify conversion logic, including live integration tests for color models, resolution bounds, and smoothing options. Assisted-by: Codex
1 parent e21ba8d commit 9e00671

11 files changed

Lines changed: 3331 additions & 297 deletions

src/pdfrest/client.py

Lines changed: 428 additions & 51 deletions
Large diffs are not rendered by default.

src/pdfrest/models/_internal.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import re
44
from collections.abc import Callable
55
from pathlib import PurePath
6-
from typing import Annotated, Any, Literal
6+
from typing import Annotated, Any, Generic, Literal, TypeVar
77

88
from pydantic import (
99
AfterValidator,
@@ -178,7 +178,10 @@ class UploadURLs(BaseModel):
178178
]
179179

180180

181-
class ConvertToGraphic(BaseModel):
181+
ColorModelT = TypeVar("ColorModelT", bound=str)
182+
183+
184+
class BasePdfRestGraphicPayload(BaseModel, Generic[ColorModelT]):
182185
files: Annotated[
183186
list[PdfRestFile],
184187
Field(
@@ -207,7 +210,7 @@ class ConvertToGraphic(BaseModel):
207210
PlainSerializer(_serialize_as_comma_separated_string),
208211
]
209212
resolution: Annotated[int, Field(ge=12, le=2400, default=300)]
210-
color_model: Annotated[Literal["rgb", "rgba", "gray"], Field(default="rgb")]
213+
color_model: Annotated[ColorModelT, Field(default=...)]
211214
smoothing: Annotated[
212215
list[Literal["none", "all", "text", "line", "image"]],
213216
Field(default="none"),
@@ -217,6 +220,41 @@ class ConvertToGraphic(BaseModel):
217220
]
218221

219222

223+
class PngPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "rgba", "gray"]]):
224+
"""Adapt caller options into a pdfRest-ready PNG request payload."""
225+
226+
color_model: Annotated[Literal["rgb", "rgba", "gray"], Field(default="rgb")]
227+
228+
229+
class BmpPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "gray"]]):
230+
"""Adapt caller options into a pdfRest-ready BMP request payload."""
231+
232+
color_model: Annotated[Literal["rgb", "gray"], Field(default="rgb")]
233+
234+
235+
class GifPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "gray"]]):
236+
"""Adapt caller options into a pdfRest-ready GIF request payload."""
237+
238+
color_model: Annotated[Literal["rgb", "gray"], Field(default="rgb")]
239+
240+
241+
class JpegPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "cmyk", "gray"]]):
242+
"""Adapt caller options into a pdfRest-ready JPEG request payload."""
243+
244+
color_model: Annotated[Literal["rgb", "cmyk", "gray"], Field(default="rgb")]
245+
jpeg_quality: Annotated[int, Field(ge=1, le=100, default=75)]
246+
247+
248+
class TiffPdfRestPayload(
249+
BasePdfRestGraphicPayload[Literal["rgb", "rgba", "cmyk", "lab", "gray"]]
250+
):
251+
"""Adapt caller options into a pdfRest-ready TIFF request payload."""
252+
253+
color_model: Annotated[
254+
Literal["rgb", "rgba", "cmyk", "lab", "gray"], Field(default="rgb")
255+
]
256+
257+
220258
class PdfRestRawUploadedFile(BaseModel):
221259
"""The response sent by /upload is a list of these. /unzip returns files like this
222260
with outputUrl"""

tests/graphics_test_helpers.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable
4+
from datetime import datetime, timezone
5+
from typing import Any
6+
7+
from pdfrest.models import PdfRestFile
8+
9+
VALID_API_KEY = "12345678-1234-1234-1234-123456789abc"
10+
ASYNC_API_KEY = "fedcba98-7654-3210-fedc-ba9876543210"
11+
12+
13+
def build_file_info_payload(file_id: str, name: str, mime_type: str) -> dict[str, Any]:
14+
return {
15+
"id": file_id,
16+
"name": name,
17+
"url": f"https://api.pdfrest.com/resource/{file_id}",
18+
"type": mime_type,
19+
"size": 256,
20+
"modified": datetime(2024, 1, 1, tzinfo=timezone.utc)
21+
.isoformat()
22+
.replace("+00:00", "Z"),
23+
"scheduledDeletionTimeUtc": None,
24+
}
25+
26+
27+
def make_pdf_file(file_id: str, name: str = "example.pdf") -> PdfRestFile:
28+
return PdfRestFile.model_validate(
29+
{
30+
"id": file_id,
31+
"name": name,
32+
"url": f"https://api.pdfrest.com/resource/{file_id}",
33+
"type": "application/pdf",
34+
"size": 1024,
35+
"modified": datetime(2024, 1, 1, tzinfo=timezone.utc)
36+
.isoformat()
37+
.replace("+00:00", "Z"),
38+
"scheduledDeletionTimeUtc": None,
39+
}
40+
)
41+
42+
43+
def assert_conversion_payload(
44+
payload: dict[str, Any],
45+
expected: dict[str, Any],
46+
*,
47+
allowed_extras: Iterable[str] | None = None,
48+
) -> None:
49+
for key, value in expected.items():
50+
assert payload[key] == value
51+
extra_keys = set(payload) - set(expected)
52+
permitted = {"color_model", "resolution"}
53+
if allowed_extras is not None:
54+
permitted.update(allowed_extras)
55+
assert extra_keys <= permitted
56+
if "resolution" not in expected and "resolution" in payload:
57+
assert payload["resolution"] == 300
58+
if "color_model" not in expected and "color_model" in payload:
59+
assert payload["color_model"] == "rgb"

tests/live/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Package for live integration tests.
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable
4+
from typing import TYPE_CHECKING, Any, NamedTuple, get_args
5+
6+
import pytest
7+
8+
from pdfrest import PdfRestApiError, PdfRestClient
9+
from pdfrest.models._internal import (
10+
BasePdfRestGraphicPayload,
11+
BmpPdfRestPayload,
12+
GifPdfRestPayload,
13+
JpegPdfRestPayload,
14+
PngPdfRestPayload,
15+
TiffPdfRestPayload,
16+
)
17+
18+
from ..resources import get_test_resource_path
19+
20+
21+
class _GraphicEndpointSpec(NamedTuple):
22+
method_name: str
23+
payload_model: type[BasePdfRestGraphicPayload[Any]]
24+
25+
26+
PAYLOAD_MODELS: dict[str, _GraphicEndpointSpec] = {
27+
"png": _GraphicEndpointSpec("convert_to_png", PngPdfRestPayload),
28+
"bmp": _GraphicEndpointSpec("convert_to_bmp", BmpPdfRestPayload),
29+
"gif": _GraphicEndpointSpec("convert_to_gif", GifPdfRestPayload),
30+
"jpeg": _GraphicEndpointSpec("convert_to_jpeg", JpegPdfRestPayload),
31+
"tiff": _GraphicEndpointSpec("convert_to_tiff", TiffPdfRestPayload),
32+
}
33+
34+
35+
def _enumerate_color_models(
36+
payload_model: type[BasePdfRestGraphicPayload[Any]],
37+
) -> Iterable[str]:
38+
field = payload_model.model_fields["color_model"]
39+
return get_args(field.annotation) or ()
40+
41+
42+
def _resolution_bounds(
43+
payload_model: type[BasePdfRestGraphicPayload[Any]],
44+
) -> tuple[int, int]:
45+
field = payload_model.model_fields["resolution"]
46+
ge = field.metadata[0].ge if field.metadata else 12
47+
le = field.metadata[1].le if len(field.metadata) > 1 else 2400
48+
return int(ge), int(le)
49+
50+
51+
def _valid_color_cases() -> list[Any]:
52+
cases = []
53+
for label, spec in PAYLOAD_MODELS.items():
54+
for color_model in _enumerate_color_models(spec.payload_model):
55+
cases.append(
56+
pytest.param(label, spec, color_model, id=f"{label}-{color_model}")
57+
)
58+
return cases
59+
60+
61+
def _invalid_color_cases() -> list[Any]:
62+
cases = []
63+
candidates = ("lab", "rgba", "cmyk", "xyz", "ultraviolet", "infrared-spectrum")
64+
for label, spec in PAYLOAD_MODELS.items():
65+
allowed = set(_enumerate_color_models(spec.payload_model))
66+
seen: set[str] = set()
67+
for value in (*candidates, "not-a-color-model"):
68+
if value in allowed or value in seen:
69+
continue
70+
seen.add(value)
71+
cases.append(pytest.param(label, spec, value, id=f"{label}-{value}"))
72+
return cases
73+
74+
75+
_SMOOTHING_VALUES: tuple[str, ...] = ("none", "all", "text", "line", "image")
76+
77+
78+
def _valid_smoothing_cases() -> list[Any]:
79+
cases = []
80+
for label, spec in PAYLOAD_MODELS.items():
81+
for smoothing in _SMOOTHING_VALUES:
82+
cases.append(
83+
pytest.param(label, spec, smoothing, id=f"{label}-{smoothing}")
84+
)
85+
return cases
86+
87+
88+
def _invalid_smoothing_cases() -> list[Any]:
89+
cases = []
90+
invalid_inputs: tuple[Any, ...] = (
91+
"quantum",
92+
"super-smooth",
93+
["line", "hyperreal"],
94+
)
95+
for label, spec in PAYLOAD_MODELS.items():
96+
for candidate in invalid_inputs:
97+
case_id = (
98+
f"{label}-{'-'.join(candidate)}"
99+
if isinstance(candidate, list)
100+
else f"{label}-{candidate}"
101+
)
102+
cases.append(pytest.param(label, spec, candidate, id=case_id))
103+
return cases
104+
105+
106+
@pytest.mark.parametrize(
107+
("_endpoint_label", "spec", "color_model"),
108+
_valid_color_cases(),
109+
)
110+
def test_live_graphic_valid_color_models(
111+
pdfrest_api_key: str,
112+
pdfrest_live_base_url: str,
113+
_endpoint_label: str,
114+
spec: _GraphicEndpointSpec,
115+
color_model: str,
116+
) -> None:
117+
if TYPE_CHECKING:
118+
return
119+
120+
resource = get_test_resource_path("report.pdf")
121+
payload_model = spec.payload_model
122+
resolution = _resolution_bounds(payload_model)[0]
123+
with PdfRestClient(
124+
api_key=pdfrest_api_key, base_url=pdfrest_live_base_url
125+
) as client:
126+
uploaded = client.files.create_from_paths([resource])[0]
127+
client_method = getattr(client, spec.method_name)
128+
response = client_method(
129+
uploaded,
130+
color_model=color_model,
131+
resolution=resolution,
132+
)
133+
assert response.output_files
134+
135+
136+
@pytest.mark.parametrize(
137+
("_endpoint_label", "spec", "invalid_color"),
138+
_invalid_color_cases(),
139+
)
140+
def test_live_graphic_invalid_color_model(
141+
pdfrest_api_key: str,
142+
pdfrest_live_base_url: str,
143+
_endpoint_label: str,
144+
spec: _GraphicEndpointSpec,
145+
invalid_color: str,
146+
) -> None:
147+
payload_model = spec.payload_model
148+
149+
resource = get_test_resource_path("report.pdf")
150+
with PdfRestClient(
151+
api_key=pdfrest_api_key, base_url=pdfrest_live_base_url
152+
) as client:
153+
uploaded = client.files.create_from_paths([resource])[0]
154+
client_method = getattr(client, spec.method_name)
155+
resolution = _resolution_bounds(payload_model)[0]
156+
with pytest.raises(PdfRestApiError):
157+
client_method(
158+
uploaded,
159+
resolution=resolution,
160+
extra_body={"color_model": invalid_color},
161+
)
162+
163+
164+
@pytest.mark.parametrize(
165+
("_endpoint_label", "spec"),
166+
PAYLOAD_MODELS.items(),
167+
ids=list(PAYLOAD_MODELS),
168+
)
169+
def test_live_graphic_resolution_bounds(
170+
pdfrest_api_key: str,
171+
pdfrest_live_base_url: str,
172+
_endpoint_label: str,
173+
spec: _GraphicEndpointSpec,
174+
) -> None:
175+
payload_model = spec.payload_model
176+
min_res, max_res = _resolution_bounds(payload_model)
177+
resource = get_test_resource_path("report.pdf")
178+
179+
with PdfRestClient(
180+
api_key=pdfrest_api_key, base_url=pdfrest_live_base_url
181+
) as client:
182+
uploaded = client.files.create_from_paths([resource])[0]
183+
client_method = getattr(client, spec.method_name)
184+
185+
response_min = client_method(
186+
uploaded,
187+
resolution=min_res,
188+
)
189+
assert response_min.output_files
190+
191+
response_max = client_method(
192+
uploaded,
193+
resolution=max_res,
194+
)
195+
assert response_max.output_files
196+
197+
with pytest.raises(PdfRestApiError):
198+
client_method(
199+
uploaded,
200+
resolution=min_res,
201+
extra_body={"resolution": min_res - 1},
202+
)
203+
204+
with pytest.raises(PdfRestApiError):
205+
client_method(
206+
uploaded,
207+
resolution=max_res,
208+
extra_body={"resolution": max_res + 1},
209+
)
210+
211+
212+
@pytest.mark.parametrize(
213+
("_endpoint_label", "spec", "smoothing_value"),
214+
_valid_smoothing_cases(),
215+
)
216+
def test_live_graphic_valid_smoothing(
217+
pdfrest_api_key: str,
218+
pdfrest_live_base_url: str,
219+
_endpoint_label: str,
220+
spec: _GraphicEndpointSpec,
221+
smoothing_value: str,
222+
) -> None:
223+
if TYPE_CHECKING:
224+
return
225+
226+
resource = get_test_resource_path("report.pdf")
227+
with PdfRestClient(
228+
api_key=pdfrest_api_key, base_url=pdfrest_live_base_url
229+
) as client:
230+
uploaded = client.files.create_from_paths([resource])[0]
231+
client_method = getattr(client, spec.method_name)
232+
response = client_method(
233+
uploaded,
234+
smoothing=smoothing_value,
235+
)
236+
assert response.output_files
237+
238+
239+
@pytest.mark.parametrize(
240+
("_endpoint_label", "spec", "invalid_smoothing"),
241+
_invalid_smoothing_cases(),
242+
)
243+
def test_live_graphic_invalid_smoothing(
244+
pdfrest_api_key: str,
245+
pdfrest_live_base_url: str,
246+
_endpoint_label: str,
247+
spec: _GraphicEndpointSpec,
248+
invalid_smoothing: Any,
249+
) -> None:
250+
resource = get_test_resource_path("report.pdf")
251+
with PdfRestClient(
252+
api_key=pdfrest_api_key, base_url=pdfrest_live_base_url
253+
) as client:
254+
uploaded = client.files.create_from_paths([resource])[0]
255+
client_method = getattr(client, spec.method_name)
256+
with pytest.raises(PdfRestApiError):
257+
client_method(
258+
uploaded,
259+
smoothing="none",
260+
extra_body={"smoothing": invalid_smoothing},
261+
)

0 commit comments

Comments
 (0)