Skip to content

Commit deb2e9f

Browse files
models: Extend with validation utilities and new response types
- Added robust validation utilities (`_validate_output_prefix`, `_require_positive_page`, `_validate_page_range_entry`, etc.) to enhance input checks for models. - Introduced new models (`PdfRestFileBasedResponse`, `PdfRestRawFileResponse`, `PdfRestRawUploadedFile`, `ConvertToGraphic`) to support pdfRest API operations. - Enhanced `PdfRestFileID` with detailed validation, prefix handling, and Pydantic v2 integration, including JSON schema support. - Updated module imports, `_internal.py`, and `public.py` to include new definitions and align with the latest changes. Assisted-by: Codex
1 parent 73de3c2 commit deb2e9f

3 files changed

Lines changed: 492 additions & 6 deletions

File tree

src/pdfrest/models/__init__.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1-
from .public import PdfRestErrorResponse, PdfRestFile, UpResponse
1+
from .public import (
2+
PdfRestErrorResponse,
3+
PdfRestFile,
4+
PdfRestFileBasedResponse,
5+
PdfRestFileID,
6+
UpResponse,
7+
)
28

3-
__all__ = ("PdfRestErrorResponse", "PdfRestFile", "UpResponse")
9+
__all__ = [
10+
"PdfRestErrorResponse",
11+
"PdfRestFile",
12+
"PdfRestFileBasedResponse",
13+
"PdfRestFileID",
14+
"UpResponse",
15+
]

src/pdfrest/models/_internal.py

Lines changed: 250 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
from __future__ import annotations
22

3-
from typing import Annotated, Any
3+
import re
4+
from collections.abc import Callable
5+
from pathlib import PurePath
6+
from typing import Annotated, Any, Literal
47

58
from pydantic import (
9+
AfterValidator,
10+
AliasChoices,
611
BaseModel,
712
BeforeValidator,
13+
ConfigDict,
814
Field,
915
HttpUrl,
16+
PlainSerializer,
17+
model_validator,
1018
)
1119

20+
from . import PdfRestFile
21+
from .public import PdfRestFileID
22+
1223

1324
def _ensure_list(value: Any) -> Any:
1425
if value is None:
@@ -22,10 +33,248 @@ def _list_of_strings(value: list[Any]) -> list[str]:
2233
return [str(e) for e in value]
2334

2435

36+
def _validate_output_prefix(value: str | None) -> str | None:
37+
"""Validate output prefix to prevent directory traversal and reserved or unsafe names."""
38+
if value is None:
39+
return None
40+
if "/" in value or "\\" in value or ":" in value:
41+
msg = "The output prefix must not contain a directory separator."
42+
raise ValueError(msg)
43+
if value.startswith("."):
44+
msg = "The output prefix must not start with a `.`."
45+
raise ValueError(msg)
46+
if ".." in value:
47+
msg = "The output prefix must not contain `..`."
48+
raise ValueError(msg)
49+
basename = PurePath(value).name
50+
if value != basename:
51+
msg = "The output prefix must not include directory components."
52+
raise ValueError(msg)
53+
if basename in {"profile.json", "metadata.json"}:
54+
msg = "The output prefix is a reserved name."
55+
raise ValueError(msg)
56+
special_chars_pattern = r"[`!@#$%^&*()+=\[\]{};':\"\\|,<>?~]"
57+
matches = re.findall(special_chars_pattern, value)
58+
if matches:
59+
violations: list[str] = []
60+
for char in matches:
61+
if char not in violations:
62+
violations.append(char)
63+
msg = (
64+
"The output prefix must not contain special characters: "
65+
+ ", ".join(repr(char) for char in violations)
66+
+ "."
67+
)
68+
raise ValueError(msg)
69+
return value
70+
71+
72+
def _require_positive_page(
73+
text: str, *, description: str, require_page_word: bool = False
74+
) -> str:
75+
if not text.isdigit() or int(text) < 1:
76+
message = (
77+
f"{description} must be a page number greater than or equal to 1."
78+
if require_page_word
79+
else f"{description} must be greater than or equal to 1."
80+
)
81+
raise ValueError(message)
82+
return text
83+
84+
85+
def _validate_page_range_entry(value: str) -> str:
86+
"""Normalize and validate a single page range entry."""
87+
if not isinstance(value, str):
88+
msg = "Each page range entry must be a string."
89+
raise TypeError(msg)
90+
entry = value.strip()
91+
if entry == "":
92+
msg = "Each page range entry must be a non-empty string."
93+
raise ValueError(msg)
94+
if entry == "last":
95+
return entry
96+
if entry.isdigit():
97+
return _require_positive_page(entry, description="Page numbers")
98+
if "-" in entry:
99+
start_raw, end_raw = (part.strip() for part in entry.split("-", maxsplit=1))
100+
start = _require_positive_page(
101+
start_raw, description="Page range start", require_page_word=True
102+
)
103+
if end_raw == "last":
104+
return f"{start}-last"
105+
end = _require_positive_page(
106+
end_raw, description="Page range end", require_page_word=True
107+
)
108+
if int(end) < int(start):
109+
msg = "Page range end must be greater than or equal to the start."
110+
raise ValueError(msg)
111+
return f"{start}-{end}"
112+
msg = "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'."
113+
raise ValueError(msg)
114+
115+
116+
def _split_comma_list(value: Any) -> Any:
117+
if isinstance(value, str):
118+
return value.split(",")
119+
if isinstance(value, list):
120+
return value
121+
msg = "Must be a comma separated string or a list of strings."
122+
raise ValueError(msg)
123+
124+
125+
def _pdfrest_file_to_id(value: Any) -> Any:
126+
if isinstance(value, PdfRestFile):
127+
return value.id
128+
return value
129+
130+
131+
def _serialize_as_first_file_id(value: list[PdfRestFile]) -> str:
132+
return str(value[0].id)
133+
134+
135+
def _serialize_as_comma_separated_string(value: list[Any] | None) -> str | None:
136+
if value is None:
137+
return None
138+
return ",".join(value)
139+
140+
141+
PageRangeEntry = Annotated[str, AfterValidator(_validate_page_range_entry)]
142+
143+
144+
def _allowed_mime_types(
145+
allowed_mime_types: str, *more_allowed_mime_types: str, error_msg: str | None
146+
) -> Callable[[Any], Any]:
147+
combined_allowed_mime_types = [allowed_mime_types, *more_allowed_mime_types]
148+
149+
def allowed_mime_types_validator(
150+
value: PdfRestFile | list[PdfRestFile],
151+
) -> PdfRestFile | list[PdfRestFile]:
152+
if isinstance(value, list):
153+
for item in value:
154+
allowed_mime_types_validator(item)
155+
return value
156+
if value.type not in combined_allowed_mime_types:
157+
msg = error_msg or f"The file type must be one of: {allowed_mime_types}"
158+
raise ValueError(msg)
159+
return value
160+
161+
return allowed_mime_types_validator
162+
163+
164+
def _int_to_string(value: Any) -> Any:
165+
if isinstance(value, int):
166+
return str(value)
167+
if isinstance(value, list):
168+
return [_int_to_string(item) for item in value]
169+
return value
170+
171+
25172
class UploadURLs(BaseModel):
26173
url: Annotated[
27174
list[HttpUrl] | HttpUrl,
28175
Field(min_length=1),
29176
BeforeValidator(_list_of_strings),
30177
BeforeValidator(_ensure_list),
31178
]
179+
180+
181+
class ConvertToGraphic(BaseModel):
182+
files: Annotated[
183+
list[PdfRestFile],
184+
Field(
185+
min_length=1,
186+
max_length=1,
187+
validation_alias=AliasChoices("file", "files"),
188+
serialization_alias="id",
189+
),
190+
AfterValidator(
191+
_allowed_mime_types("application/pdf", error_msg="Must be a PDF file")
192+
),
193+
BeforeValidator(_ensure_list),
194+
PlainSerializer(_serialize_as_first_file_id),
195+
]
196+
output_prefix: Annotated[
197+
str | None,
198+
Field(serialization_alias="output", min_length=1, default=None),
199+
AfterValidator(_validate_output_prefix),
200+
]
201+
page_range: Annotated[
202+
list[PageRangeEntry] | None,
203+
Field(serialization_alias="pages", min_length=1, default=None),
204+
BeforeValidator(_ensure_list),
205+
BeforeValidator(_split_comma_list),
206+
BeforeValidator(_int_to_string),
207+
PlainSerializer(_serialize_as_comma_separated_string),
208+
]
209+
resolution: Annotated[int, Field(ge=12, le=2400, default=300)]
210+
color_model: Annotated[Literal["rgb", "rgba", "gray"], Field(default="rgb")]
211+
smoothing: Annotated[
212+
list[Literal["none", "all", "text", "line", "image"]],
213+
Field(default="none"),
214+
BeforeValidator(_ensure_list),
215+
BeforeValidator(_split_comma_list),
216+
PlainSerializer(_serialize_as_comma_separated_string),
217+
]
218+
219+
220+
class PdfRestRawUploadedFile(BaseModel):
221+
"""The response sent by /upload is a list of these. /unzip returns files like this
222+
with outputUrl"""
223+
224+
name: Annotated[str, Field(description="The name of the file")]
225+
id: Annotated[PdfRestFileID, Field(description="The id of the file")]
226+
output_url: Annotated[
227+
str | None,
228+
Field(description="The url of the unzipped file", alias="outputUrl"),
229+
BeforeValidator(_ensure_list),
230+
] = None
231+
232+
233+
class PdfRestRawFileResponse(BaseModel):
234+
"""The raw response from file-based pdfRest calls."""
235+
236+
# Allow all extra fields to be stored and serialized
237+
# See: https://docs.pydantic.dev/latest/concepts/models/#extra-fields
238+
model_config = ConfigDict(extra="allow")
239+
240+
input_id: Annotated[
241+
list[PdfRestFileID],
242+
Field(alias="inputId", description="The id of the input file"),
243+
BeforeValidator(_ensure_list),
244+
]
245+
output_urls: Annotated[
246+
list[HttpUrl] | None,
247+
Field(alias="outputUrl", description="The url of the file"),
248+
BeforeValidator(_ensure_list),
249+
] = None
250+
output_ids: Annotated[
251+
list[PdfRestFileID] | None,
252+
Field(alias="outputId", description="The id of the file"),
253+
BeforeValidator(_ensure_list),
254+
] = None
255+
files: Annotated[
256+
list[PdfRestRawUploadedFile] | None,
257+
Field(description="The file(s) returned from the /unzip operation"),
258+
BeforeValidator(_ensure_list),
259+
] = None
260+
warning: Annotated[
261+
str | None,
262+
Field(
263+
description="A warning that was generated during the pdfRest operation",
264+
),
265+
] = None
266+
267+
@model_validator(mode="after")
268+
def _check_output_id_or_files(self) -> Any:
269+
if self.output_ids is None and self.files is None:
270+
msg = "output_id or files must be specified"
271+
raise ValueError(msg)
272+
return self
273+
274+
@property
275+
def ids(self) -> list[PdfRestFileID] | None:
276+
if self.output_ids is not None:
277+
return self.output_ids
278+
if self.files is not None:
279+
return [f.id for f in self.files]
280+
return None

0 commit comments

Comments
 (0)