Skip to content
Closed
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
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.0.3"
version = "1.1.0"
description = "Python client library for interacting with the pdfRest API"
readme = {file = "README.md", content-type = "text/markdown"}
authors = [
Expand Down
6 changes: 5 additions & 1 deletion src/pdfrest/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,11 @@ def _extract_error_details(
pdfrest_error = PdfRestErrorResponse.model_validate_json(response.content)
except ValidationError:
return None, response.text
return pdfrest_error.error, None
error_detail = pdfrest_error.error_detail
if error_detail is None:
return pdfrest_error.error, None
error_payload = error_detail.model_dump(by_alias=True, exclude_none=True)
return pdfrest_error.error, error_payload


class _SyncApiClient(_BaseApiClient[httpx.Client]):
Expand Down
4 changes: 4 additions & 0 deletions src/pdfrest/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
ExtractedTextWordStyle,
ExtractTextResponse,
PdfRestDeletionResponse,
PdfRestErrorDetail,
PdfRestErrorIssue,
PdfRestErrorResponse,
PdfRestFile,
PdfRestFileBasedResponse,
Expand All @@ -37,6 +39,8 @@
"ExtractedTextWordFont",
"ExtractedTextWordStyle",
"PdfRestDeletionResponse",
"PdfRestErrorDetail",
"PdfRestErrorIssue",
"PdfRestErrorResponse",
"PdfRestFile",
"PdfRestFileBasedResponse",
Expand Down
28 changes: 27 additions & 1 deletion src/pdfrest/models/public.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
"ExtractedTextWordFont",
"ExtractedTextWordStyle",
"PdfRestDeletionResponse",
"PdfRestErrorDetail",
"PdfRestErrorIssue",
"PdfRestErrorResponse",
"PdfRestFile",
"PdfRestFileBasedResponse",
Expand Down Expand Up @@ -246,9 +248,33 @@ class UpResponse(BaseModel):
class PdfRestErrorResponse(BaseModel):
"""Error response payloads from pdfRest."""

error: str | None = Field(alias="message")
error: str | None = Field(validation_alias=AliasChoices("error", "message"))
"""Human-readable error message returned by the API."""

error_detail: PdfRestErrorDetail | None = Field(default=None, alias="errorDetail")
"""Structured validation issues returned by the API when available."""

model_config = ConfigDict(extra="allow", frozen=True)


class PdfRestErrorIssue(BaseModel):
"""Single issue entry inside an `errorDetail` response."""

path: str
message: str
minimum: int | float | None = None
maximum: int | float | None = None
expected: str | None = None
received: Any | None = None

model_config = ConfigDict(extra="allow", frozen=True)


class PdfRestErrorDetail(BaseModel):
"""Structured details for API validation errors."""

issues: list[PdfRestErrorIssue] = Field(default_factory=list)

model_config = ConfigDict(extra="allow", frozen=True)


Expand Down
73 changes: 73 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,30 @@ def _build_file_info(
}


def _build_error_detail_response() -> dict[str, Any]:
return {
"error": "-.8 is not within the acceptable range for opacity",
"errorDetail": {
"issues": [
{
"path": "fields.text_objects[0].opacity",
"message": "Too small: expected number to be >=0",
"minimum": 0,
"maximum": 1,
},
{
"path": "fields.text_objects[0].text_color_cmyk[2]",
"message": (
'Invalid text_color_cmyk yellow value "foo" (expected 0-100).'
),
"minimum": 0,
"maximum": 100,
},
]
},
}


class NonSeekableByteStream(BytesIO):
def __init__(self, payload: bytes) -> None:
super().__init__(payload)
Expand Down Expand Up @@ -939,6 +963,30 @@ def handler(_: httpx.Request) -> httpx.Response:
assert exc_info.value.status_code == 500


def test_client_preserves_structured_error_detail(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("PDFREST_API_KEY", VALID_API_KEY)
error_payload = _build_error_detail_response()

def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(400, json=error_payload)

transport = httpx.MockTransport(handler)
with (
pytest.raises(PdfRestApiError, match=r"acceptable range for opacity") as exc,
PdfRestClient(transport=transport) as client,
):
client.up()

assert exc.value.status_code == 400
assert exc.value.response_content == error_payload["errorDetail"]
assert isinstance(exc.value.response_content, dict)
issues = exc.value.response_content["issues"]
assert issues[0]["path"] == "fields.text_objects[0].opacity"
assert issues[1]["path"] == "fields.text_objects[0].text_color_cmyk[2]"


@pytest.mark.asyncio
async def test_async_client_up(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PDFREST_API_KEY", ASYNC_API_KEY)
Expand All @@ -954,6 +1002,31 @@ def handler(request: httpx.Request) -> httpx.Response:
assert response.status == "OK"


@pytest.mark.asyncio
async def test_async_client_preserves_structured_error_detail(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("PDFREST_API_KEY", ASYNC_API_KEY)
error_payload = _build_error_detail_response()

def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(400, json=error_payload)

transport = httpx.MockTransport(handler)
async with AsyncPdfRestClient(transport=transport) as client:
with pytest.raises(
PdfRestApiError, match=r"acceptable range for opacity"
) as exc:
await client.up()

assert exc.value.status_code == 400
assert exc.value.response_content == error_payload["errorDetail"]
assert isinstance(exc.value.response_content, dict)
issues = exc.value.response_content["issues"]
assert issues[0]["path"] == "fields.text_objects[0].opacity"
assert issues[1]["path"] == "fields.text_objects[0].text_color_cmyk[2]"


@pytest.mark.asyncio
async def test_async_up_with_query_and_timeout(
monkeypatch: pytest.MonkeyPatch,
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading