Skip to content

Commit a090f18

Browse files
Translate PDF: Verify language code
- Add `AfterValidator` on language string - Use new dependency `langcodes` to validate language codes Assisted-by: Codex
1 parent 2ab5abc commit a090f18

4 files changed

Lines changed: 107 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ requires-python = ">=3.10"
1010
dependencies = [
1111
"exceptiongroup>=1.3.0",
1212
"httpx>=0.28.1",
13+
"langcodes>=3.4.0",
1314
"pydantic>=2.12.0",
1415
]
1516

src/pdfrest/models/_internal.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pathlib import PurePath
77
from typing import Annotated, Any, Generic, Literal, TypeVar
88

9+
from langcodes import tag_is_valid
910
from pydantic import (
1011
AfterValidator,
1112
AliasChoices,
@@ -175,6 +176,45 @@ def _int_to_string(value: Any) -> Any:
175176
return value
176177

177178

179+
_OUTPUT_LANGUAGE_ERROR = (
180+
"The provided 'output_language' language tag is invalid. Format 'output_language' as "
181+
"a valid 2-3 character ISO 639 language code (e.g., 'en', 'es', 'fra'), optionally "
182+
"with a script, alphabetic region, or numeric region (e.g., 'zh-Hant', 'eng-US', "
183+
"'es-419'). See documentation for recommended formats."
184+
)
185+
186+
187+
def _validate_output_language(value: str) -> str:
188+
if not value:
189+
raise ValueError(_OUTPUT_LANGUAGE_ERROR)
190+
191+
trimmed = value.strip()
192+
if not trimmed:
193+
raise ValueError(_OUTPUT_LANGUAGE_ERROR)
194+
195+
segments = trimmed.split("-")
196+
if len(segments) > 2:
197+
raise ValueError(_OUTPUT_LANGUAGE_ERROR)
198+
199+
language = segments[0]
200+
if not re.fullmatch(r"[A-Za-z]{2,3}", language):
201+
raise ValueError(_OUTPUT_LANGUAGE_ERROR)
202+
203+
if len(segments) == 2:
204+
subtag = segments[1]
205+
if not (
206+
re.fullmatch(r"[A-Za-z]{4}", subtag)
207+
or re.fullmatch(r"[A-Za-z]{2}", subtag)
208+
or re.fullmatch(r"[0-9]{3}", subtag)
209+
):
210+
raise ValueError(_OUTPUT_LANGUAGE_ERROR)
211+
212+
if not tag_is_valid(trimmed):
213+
raise ValueError(_OUTPUT_LANGUAGE_ERROR)
214+
215+
return trimmed
216+
217+
178218
class UploadURLs(BaseModel):
179219
url: Annotated[
180220
list[HttpUrl] | HttpUrl,
@@ -464,7 +504,8 @@ class TranslatePdfTextPayload(BaseModel):
464504
]
465505
output_language: Annotated[
466506
str,
467-
Field(serialization_alias="output_language", min_length=1),
507+
Field(serialization_alias="output_language"),
508+
AfterValidator(_validate_output_language),
468509
]
469510
pages: Annotated[
470511
list[AscendingPageRange] | None,

tests/test_translate_pdf_text.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import re
45

56
import httpx
67
import pytest
@@ -17,6 +18,13 @@
1718

1819
from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file
1920

21+
OUTPUT_LANGUAGE_ERROR = (
22+
"The provided 'output_language' language tag is invalid. Format 'output_language' "
23+
"as a valid 2-3 character ISO 639 language code (e.g., 'en', 'es', 'fra'), "
24+
"optionally with a script, alphabetic region, or numeric region (e.g., 'zh-Hant', "
25+
"'eng-US', 'es-419'). See documentation for recommended formats."
26+
)
27+
2028

2129
def _make_markdown_file(file_id: str) -> PdfRestFile:
2230
return PdfRestFile.model_validate(
@@ -54,6 +62,51 @@ def test_translate_payload_rejects_invalid_mime() -> None:
5462
)
5563

5664

65+
@pytest.mark.parametrize(
66+
"output_language",
67+
[
68+
pytest.param("en", id="language-2-letter"),
69+
pytest.param("fra", id="language-3-letter"),
70+
pytest.param("zh-Hant", id="script"),
71+
pytest.param("eng-US", id="alpha-region"),
72+
pytest.param("es-419", id="numeric-region"),
73+
],
74+
)
75+
def test_translate_payload_accepts_valid_output_language(
76+
output_language: str,
77+
) -> None:
78+
file_repr = make_pdf_file(PdfRestFileID.generate(1))
79+
payload = TranslatePdfTextPayload.model_validate(
80+
{"files": [file_repr], "output_language": output_language}
81+
)
82+
83+
assert payload.output_language == output_language
84+
85+
86+
@pytest.mark.parametrize(
87+
"output_language",
88+
[
89+
pytest.param("", id="empty"),
90+
pytest.param("e", id="too-short"),
91+
pytest.param("english", id="not-a-code"),
92+
pytest.param("eng-USA", id="long-subtag"),
93+
pytest.param("en-1234", id="long-numeric-region"),
94+
pytest.param("en-US-extra", id="too-many-subtags"),
95+
],
96+
)
97+
def test_translate_payload_rejects_invalid_output_language(
98+
output_language: str,
99+
) -> None:
100+
file_repr = make_pdf_file(PdfRestFileID.generate(1))
101+
with pytest.raises(
102+
ValidationError,
103+
match=re.escape(OUTPUT_LANGUAGE_ERROR),
104+
):
105+
TranslatePdfTextPayload.model_validate(
106+
{"files": [file_repr], "output_language": output_language}
107+
)
108+
109+
57110
def test_translate_payload_requires_target_language() -> None:
58111
file_repr = make_pdf_file(PdfRestFileID.generate(1))
59112
with pytest.raises(ValidationError):

uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)