From 0bb3b763150b90ba8a3c10818869227cf18fe7b3 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Thu, 18 Dec 2025 14:45:04 -0600 Subject: [PATCH 01/13] Add Delete tool Assisted-by: Codex --- src/pdfrest/client.py | 54 ++++++++++++ src/pdfrest/models/__init__.py | 2 + src/pdfrest/models/_internal.py | 19 ++++ src/pdfrest/models/public.py | 26 +++++- tests/live/test_live_delete.py | 41 +++++++++ tests/test_delete_files.py | 151 ++++++++++++++++++++++++++++++++ 6 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 tests/live/test_live_delete.py create mode 100644 tests/test_delete_files.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 5cf73dfa..f6773f03 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -58,6 +58,7 @@ translate_httpx_error, ) from .models import ( + PdfRestDeletionResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -71,6 +72,7 @@ from .models._internal import ( BasePdfRestGraphicPayload, BmpPdfRestPayload, + DeletePayload, GifPdfRestPayload, JpegPdfRestPayload, PdfCompressPayload, @@ -1543,6 +1545,32 @@ def create_from_urls( for file_id in file_ids ] + def delete( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestDeletionResponse: + """Delete one or more uploaded files by reference.""" + + payload = DeletePayload.model_validate({"files": files}) + request = self._client.prepare_request( + "POST", + "/delete", + json_body=payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = self._client.send_request(request) + return PdfRestDeletionResponse.model_validate(raw_payload) + def read_bytes( self, file_ref: PdfRestFile | str, @@ -1817,6 +1845,32 @@ async def fetch(file_id: str) -> PdfRestFile: return await asyncio.gather(*(fetch(file_id) for file_id in file_ids)) + async def delete( + self, + files: PdfRestFile | Sequence[PdfRestFile], + *, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestDeletionResponse: + """Delete one or more uploaded files by reference.""" + + payload = DeletePayload.model_validate({"files": files}) + request = self._client.prepare_request( + "POST", + "/delete", + json_body=payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ), + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + raw_payload = await self._client.send_request(request) + return PdfRestDeletionResponse.model_validate(raw_payload) + async def read_bytes( self, file_ref: PdfRestFile | str, diff --git a/src/pdfrest/models/__init__.py b/src/pdfrest/models/__init__.py index e88b2f3d..54c9aeb4 100644 --- a/src/pdfrest/models/__init__.py +++ b/src/pdfrest/models/__init__.py @@ -1,4 +1,5 @@ from .public import ( + PdfRestDeletionResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -8,6 +9,7 @@ ) __all__ = [ + "PdfRestDeletionResponse", "PdfRestErrorResponse", "PdfRestFile", "PdfRestFileBasedResponse", diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 207bdf61..33cb8747 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -108,6 +108,10 @@ def _serialize_as_comma_separated_string(value: list[Any] | None) -> str | None: return ",".join(str(element) for element in value) +def _serialize_file_ids(value: list[PdfRestFile]) -> str: + return ",".join(str(file.id) for file in value) + + def _serialize_page_ranges(value: list[str | int | tuple[str | int, ...]]) -> str: def join_tuple(value: str | int | tuple[str | int, ...]) -> str: if isinstance(value, tuple): @@ -165,6 +169,21 @@ class UploadURLs(BaseModel): ] +class DeletePayload(BaseModel): + """Adapt caller options into a pdfRest-ready delete request payload.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="ids", + ), + BeforeValidator(_ensure_list), + PlainSerializer(_serialize_file_ids), + ] + + PageNumber = Annotated[int, Field(ge=1), PlainSerializer(lambda x: str(x))] diff --git a/src/pdfrest/models/public.py b/src/pdfrest/models/public.py index 108490ce..3de11476 100644 --- a/src/pdfrest/models/public.py +++ b/src/pdfrest/models/public.py @@ -19,7 +19,15 @@ from pydantic_core import CoreSchema from typing_extensions import override -__all__ = ("PdfRestErrorResponse", "PdfRestFile", "PdfRestFileID", "UpResponse") +__all__ = ( + "PdfRestDeletionResponse", + "PdfRestErrorResponse", + "PdfRestFile", + "PdfRestFileBasedResponse", + "PdfRestFileID", + "PdfRestInfoResponse", + "UpResponse", +) class PdfRestFileID(str): @@ -288,6 +296,22 @@ def output_file(self) -> PdfRestFile: raise ValueError(msg) +class PdfRestDeletionResponse(BaseModel): + """Response returned by the delete tool.""" + + model_config = ConfigDict(extra="allow") + + deletion_responses: Annotated[ + dict[PdfRestFileID, str], + Field( + alias="deletionResponses", + validation_alias=AliasChoices("deletion_responses", "deletionResponses"), + description="Mapping of file ids to deletion results.", + min_length=1, + ), + ] + + class PdfRestInfoResponse(BaseModel): """A response containing the output from the /info route.""" diff --git a/tests/live/test_live_delete.py b/tests/live/test_live_delete.py new file mode 100644 index 00000000..d69c0349 --- /dev/null +++ b/tests/live/test_live_delete.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import pytest + +from pdfrest import PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestDeletionResponse + +from ..resources import get_test_resource_path + + +def test_live_delete_files_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + response = client.files.delete(uploaded) + + assert isinstance(response, PdfRestDeletionResponse) + assert response.deletion_responses[str(uploaded.id)] == "Successfully Deleted" + + +def test_live_delete_files_invalid_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths([resource])[0] + with pytest.raises(PdfRestApiError): + client.files.delete( + uploaded, + extra_body={"ids": ""}, + ) diff --git a/tests/test_delete_files.py b/tests/test_delete_files.py new file mode 100644 index 00000000..d02f5f6c --- /dev/null +++ b/tests/test_delete_files.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestDeletionResponse, PdfRestFileID +from pdfrest.models._internal import DeletePayload + +from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file + + +def test_delete_payload_serialization() -> None: + first = make_pdf_file(PdfRestFileID.generate(1)) + second = make_pdf_file(PdfRestFileID.generate(2)) + + payload = DeletePayload.model_validate({"files": [first, second]}) + payload_dump = payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + + assert payload_dump == {"ids": f"{first.id},{second.id}"} + + +def test_delete_payload_rejects_empty() -> None: + with pytest.raises(ValidationError): + DeletePayload.model_validate({"files": []}) + + +def test_delete_files_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + payload_dump = DeletePayload.model_validate({"files": [file_repr]}).model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/delete": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "deletionResponses": { + str(file_repr.id): "Successfully Deleted", + } + }, + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.files.delete(file_repr) + + assert seen == {"post": 1} + assert isinstance(response, PdfRestDeletionResponse) + assert response.deletion_responses[str(file_repr.id)] == "Successfully Deleted" + + +def test_delete_files_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/delete": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "sync" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["ids"] == str(file_repr.id) + assert payload["debug"] is True + return httpx.Response( + 200, + json={ + "deletionResponses": { + str(file_repr.id): "Successfully Deleted", + } + }, + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.files.delete( + file_repr, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.3, + ) + + assert isinstance(response, PdfRestDeletionResponse) + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.3) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.3) + + +@pytest.mark.asyncio +async def test_async_delete_files_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + file_repr = make_pdf_file(PdfRestFileID.generate(2)) + payload_dump = DeletePayload.model_validate({"files": [file_repr]}).model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + + seen: dict[str, int] = {"post": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/delete": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "deletionResponses": { + str(file_repr.id): "Successfully Deleted", + } + }, + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=transport, + ) as client: + response = await client.files.delete(file_repr) + + assert seen == {"post": 1} + assert isinstance(response, PdfRestDeletionResponse) + assert response.deletion_responses[str(file_repr.id)] == "Successfully Deleted" From 678ae644bfa6c72eac63ae3e0479449a0269d0c7 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 23 Dec 2025 13:46:06 -0600 Subject: [PATCH 02/13] uv: Add exceptiongroup - Python module backports exception group support to Python 3.10 Assisted-by: Codex --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 24154627..46ac53da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ authors = [ ] requires-python = ">=3.10" dependencies = [ + "exceptiongroup>=1.3.0", "httpx>=0.28.1", "pydantic>=2.12.0", ] diff --git a/uv.lock b/uv.lock index 716f8d50..3713f6a1 100644 --- a/uv.lock +++ b/uv.lock @@ -599,6 +599,7 @@ name = "pdfrest" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "exceptiongroup" }, { name = "httpx" }, { name = "pydantic" }, ] @@ -622,6 +623,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "exceptiongroup", specifier = ">=1.3.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.12.0" }, ] From 007dc67adbba5a23d4095d61230b8811f083dcbb Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 23 Dec 2025 13:46:46 -0600 Subject: [PATCH 03/13] delete: Raise exceptions for failures - Raises PdfRestDeleteError when a file can be deleted. - Since there can be more than one file deleted at a time, raises the error inside a PdfRestErrorGroup. Assisted-by: Codex --- src/pdfrest/__init__.py | 4 ++++ src/pdfrest/client.py | 29 +++++++++++++++++++++++++---- src/pdfrest/exceptions.py | 31 ++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/pdfrest/__init__.py b/src/pdfrest/__init__.py index f18112ed..f809dc5b 100644 --- a/src/pdfrest/__init__.py +++ b/src/pdfrest/__init__.py @@ -7,7 +7,9 @@ PdfRestApiError, PdfRestAuthenticationError, PdfRestConfigurationError, + PdfRestDeleteError, PdfRestError, + PdfRestErrorGroup, PdfRestRequestError, PdfRestTimeoutError, PdfRestTransportError, @@ -21,7 +23,9 @@ "PdfRestAuthenticationError", "PdfRestClient", "PdfRestConfigurationError", + "PdfRestDeleteError", "PdfRestError", + "PdfRestErrorGroup", "PdfRestRequestError", "PdfRestTimeoutError", "PdfRestTransportError", diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index f6773f03..8818899f 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -50,7 +50,9 @@ PdfRestAuthenticationError, PdfRestConfigurationError, PdfRestConnectTimeoutError, + PdfRestDeleteError, PdfRestError, + PdfRestErrorGroup, PdfRestPoolTimeoutError, PdfRestRequestError, PdfRestTimeoutError, @@ -111,6 +113,7 @@ MAX_BACKOFF_SECONDS = 8.0 BACKOFF_JITTER_SECONDS = 0.1 RETRYABLE_STATUS_CODES = {408, 425, 429, 499} +_SUCCESSFUL_DELETION_MESSAGE = "successfully deleted" HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] @@ -227,6 +230,20 @@ def _extract_uploaded_file_ids(payload: Any) -> list[str]: return file_ids +def _handle_deletion_failures(response: PdfRestDeletionResponse) -> None: + failures: list[PdfRestDeleteError] = [] + for file_id, result in response.deletion_responses.items(): + normalized_result = result.strip().lower() + if normalized_result != _SUCCESSFUL_DELETION_MESSAGE: + failures.append(PdfRestDeleteError(file_id, result)) + if failures: + msg = "Failed to delete one or more files." + raise PdfRestErrorGroup( + msg, + failures, + ) + + def _normalize_headers(headers: Mapping[str, str]) -> Mapping[str, str]: return {str(key): str(value) for key, value in headers.items()} @@ -1553,7 +1570,7 @@ def delete( extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> PdfRestDeletionResponse: + ) -> None: """Delete one or more uploaded files by reference.""" payload = DeletePayload.model_validate({"files": files}) @@ -1569,7 +1586,9 @@ def delete( timeout=timeout, ) raw_payload = self._client.send_request(request) - return PdfRestDeletionResponse.model_validate(raw_payload) + deletion_response = PdfRestDeletionResponse.model_validate(raw_payload) + _handle_deletion_failures(deletion_response) + return def read_bytes( self, @@ -1853,7 +1872,7 @@ async def delete( extra_headers: AnyMapping | None = None, extra_body: Body | None = None, timeout: TimeoutTypes | None = None, - ) -> PdfRestDeletionResponse: + ) -> None: """Delete one or more uploaded files by reference.""" payload = DeletePayload.model_validate({"files": files}) @@ -1869,7 +1888,9 @@ async def delete( timeout=timeout, ) raw_payload = await self._client.send_request(request) - return PdfRestDeletionResponse.model_validate(raw_payload) + deletion_response = PdfRestDeletionResponse.model_validate(raw_payload) + _handle_deletion_failures(deletion_response) + return async def read_bytes( self, diff --git a/src/pdfrest/exceptions.py b/src/pdfrest/exceptions.py index a3b68b0b..9894fdbb 100644 --- a/src/pdfrest/exceptions.py +++ b/src/pdfrest/exceptions.py @@ -2,17 +2,23 @@ from __future__ import annotations -from typing import Any +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any import httpx from typing_extensions import override +if TYPE_CHECKING: # pragma: no cover + from .models import PdfRestFileID + __all__ = ( "PdfRestApiError", "PdfRestAuthenticationError", "PdfRestConfigurationError", "PdfRestConnectTimeoutError", + "PdfRestDeleteError", "PdfRestError", + "PdfRestErrorGroup", "PdfRestPoolTimeoutError", "PdfRestRequestError", "PdfRestTimeoutError", @@ -20,6 +26,8 @@ "translate_httpx_error", ) +from exceptiongroup import ExceptionGroup + class PdfRestError(Exception): """Base exception for all pdfrest client errors.""" @@ -78,6 +86,27 @@ class PdfRestAuthenticationError(PdfRestApiError): """Raised when authentication with the pdfRest API fails.""" +class PdfRestDeleteError(PdfRestError): + """Raised when an individual file cannot be deleted.""" + + def __init__(self, file_id: PdfRestFileID | str, message: str) -> None: + self.file_id = str(file_id) + self.detail = message + super().__init__(f"Failed to delete file {self.file_id}: {message}") + + +class PdfRestErrorGroup(ExceptionGroup): + """Group of PdfRestError exceptions produced by the PDF REST library.""" + + def __init__(self, message: str, exceptions: Sequence[Exception], /) -> None: + # enforce that everything inside is from your library + for e in exceptions: + if not isinstance(e, PdfRestError): + msg = f"PdfRestErrorGroup may only contain PdfRestError instances, got {type(e)}" + raise TypeError(msg) + super().__init__(message, list(exceptions)) + + def translate_httpx_error(exc: httpx.HTTPError) -> PdfRestError: """Convert an httpx exception into a library-specific exception.""" From 246afd84e82cf6a66851514ae3f718eb1e939731 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 23 Dec 2025 14:14:42 -0600 Subject: [PATCH 04/13] tests: Test delete raising exceptions Assisted-by: Codex --- tests/live/test_live_delete.py | 44 +++++++++--- tests/test_delete_files.py | 125 ++++++++++++++++++++++++++++++--- 2 files changed, 151 insertions(+), 18 deletions(-) diff --git a/tests/live/test_live_delete.py b/tests/live/test_live_delete.py index d69c0349..3ea8cf65 100644 --- a/tests/live/test_live_delete.py +++ b/tests/live/test_live_delete.py @@ -1,9 +1,12 @@ from __future__ import annotations +from secrets import token_urlsafe + import pytest +from pydantic import ValidationError -from pdfrest import PdfRestApiError, PdfRestClient -from pdfrest.models import PdfRestDeletionResponse +from pdfrest import PdfRestClient, PdfRestDeleteError, PdfRestErrorGroup +from pdfrest.models import PdfRestFileID from ..resources import get_test_resource_path @@ -18,10 +21,9 @@ def test_live_delete_files_success( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - response = client.files.delete(uploaded) + result = client.files.delete(uploaded) - assert isinstance(response, PdfRestDeletionResponse) - assert response.deletion_responses[str(uploaded.id)] == "Successfully Deleted" + assert result is None def test_live_delete_files_invalid_id( @@ -34,8 +36,34 @@ def test_live_delete_files_invalid_id( base_url=pdfrest_live_base_url, ) as client: uploaded = client.files.create_from_paths([resource])[0] - with pytest.raises(PdfRestApiError): + with pytest.raises(ValidationError): + client.files.delete(uploaded, extra_body={"ids": token_urlsafe(16)}) + + +def test_live_delete_files_missing_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + bad_id_1 = PdfRestFileID.generate() + bad_id_2 = PdfRestFileID.generate() + uploaded = client.files.create_from_paths([resource])[0] + with pytest.RaisesGroup( + pytest.RaisesExc( + PdfRestDeleteError, + match=f"Failed to delete file {bad_id_1}.*does not exist", + ), + pytest.RaisesExc( + PdfRestDeleteError, + match=f"Failed to delete file {bad_id_2}.*does not exist", + ), + match="Failed to delete one or more files.", + check=lambda eg: isinstance(eg, PdfRestErrorGroup), + ): client.files.delete( - uploaded, - extra_body={"ids": ""}, + uploaded, extra_body={"ids": ",".join([bad_id_1, bad_id_2])} ) diff --git a/tests/test_delete_files.py b/tests/test_delete_files.py index d02f5f6c..68299942 100644 --- a/tests/test_delete_files.py +++ b/tests/test_delete_files.py @@ -6,8 +6,9 @@ import pytest from pydantic import ValidationError -from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import PdfRestDeletionResponse, PdfRestFileID +from pdfrest import AsyncPdfRestClient, PdfRestClient, PdfRestErrorGroup +from pdfrest.exceptions import PdfRestDeleteError +from pdfrest.models import PdfRestFileID from pdfrest.models._internal import DeletePayload from .graphics_test_helpers import ASYNC_API_KEY, VALID_API_KEY, make_pdf_file @@ -57,11 +58,10 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: - response = client.files.delete(file_repr) + result = client.files.delete(file_repr) assert seen == {"post": 1} - assert isinstance(response, PdfRestDeletionResponse) - assert response.deletion_responses[str(file_repr.id)] == "Successfully Deleted" + assert result is None def test_delete_files_request_customization( @@ -92,7 +92,7 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: - response = client.files.delete( + result = client.files.delete( file_repr, extra_query={"trace": "true"}, extra_headers={"X-Debug": "sync"}, @@ -100,7 +100,7 @@ def handler(request: httpx.Request) -> httpx.Response: timeout=0.3, ) - assert isinstance(response, PdfRestDeletionResponse) + assert result is None timeout_value = captured_timeout["value"] assert timeout_value is not None if isinstance(timeout_value, dict): @@ -111,6 +111,74 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(0.3) +def test_delete_files_raises_error_for_failed_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + file_repr = make_pdf_file(PdfRestFileID.generate(1)) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/delete": + return httpx.Response( + 200, + json={ + "deletionResponses": { + str(file_repr.id): "File could not be deleted", + } + }, + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(PdfRestErrorGroup) as exc_info, + ): + client.files.delete(file_repr) + + assert len(exc_info.value.exceptions) == 1 + inner = exc_info.value.exceptions[0] + assert isinstance(inner, PdfRestDeleteError) + assert inner.file_id == str(file_repr.id) + assert "File could not be deleted" in str(inner) + + +def test_delete_files_aggregates_multiple_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + first = make_pdf_file(PdfRestFileID.generate(1)) + second = make_pdf_file(PdfRestFileID.generate(2)) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/delete": + return httpx.Response( + 200, + json={ + "deletionResponses": { + str(first.id): "Successfully Deleted", + str(second.id): "Permission denied", + } + }, + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(PdfRestErrorGroup) as exc_info, + ): + client.files.delete([first, second]) + + assert len(exc_info.value.exceptions) == 1 + inner = exc_info.value.exceptions[0] + assert isinstance(inner, PdfRestDeleteError) + assert inner.file_id == str(second.id) + assert "Permission denied" in str(inner) + + @pytest.mark.asyncio async def test_async_delete_files_success( monkeypatch: pytest.MonkeyPatch, @@ -144,8 +212,45 @@ def handler(request: httpx.Request) -> httpx.Response: api_key=ASYNC_API_KEY, transport=transport, ) as client: - response = await client.files.delete(file_repr) + result = await client.files.delete(file_repr) assert seen == {"post": 1} - assert isinstance(response, PdfRestDeletionResponse) - assert response.deletion_responses[str(file_repr.id)] == "Successfully Deleted" + assert result is None + + +@pytest.mark.asyncio +async def test_async_delete_files_raises_error_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + first = make_pdf_file(PdfRestFileID.generate(1)) + second = make_pdf_file(PdfRestFileID.generate(2)) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/delete": + return httpx.Response( + 200, + json={ + "deletionResponses": { + str(first.id): "Failed dependency", + str(second.id): "Successfully Deleted", + } + }, + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=transport, + ) as client: + with pytest.RaisesGroup( + pytest.RaisesExc( + PdfRestDeleteError, + match=f"Failed to delete file {first.id}.*Failed dependency", + ), + match="Failed to delete one or more files.", + check=lambda eg: isinstance(eg, PdfRestErrorGroup), + ): + await client.files.delete([first, second]) From 5ff0974ff292918fa11684e6137c60cf0bcbaed2 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 23 Dec 2025 15:12:43 -0600 Subject: [PATCH 05/13] tests: Add async live delete tests and improve sync/async error handling - Added async live delete tests under `test_live_delete.py`, covering success, invalid ID, and missing ID scenarios for `AsyncPdfRestClient`. - Improved exception handling to use `pytest.RaisesGroup` for both sync and async delete error aggregation. - Enhanced test customization to validate extra query parameters, headers, and timeout behavior during async operations. Assisted-by: Codex --- tests/live/test_live_delete.py | 68 +++++++++++++++++++++++++- tests/test_delete_files.py | 88 ++++++++++++++++++++++++++++------ 2 files changed, 140 insertions(+), 16 deletions(-) diff --git a/tests/live/test_live_delete.py b/tests/live/test_live_delete.py index 3ea8cf65..75727fef 100644 --- a/tests/live/test_live_delete.py +++ b/tests/live/test_live_delete.py @@ -5,7 +5,12 @@ import pytest from pydantic import ValidationError -from pdfrest import PdfRestClient, PdfRestDeleteError, PdfRestErrorGroup +from pdfrest import ( + AsyncPdfRestClient, + PdfRestClient, + PdfRestDeleteError, + PdfRestErrorGroup, +) from pdfrest.models import PdfRestFileID from ..resources import get_test_resource_path @@ -26,6 +31,22 @@ def test_live_delete_files_success( assert result is None +@pytest.mark.asyncio +async def test_live_async_delete_files_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + result = await client.files.delete(uploaded) + + assert result is None + + def test_live_delete_files_invalid_id( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -40,6 +61,21 @@ def test_live_delete_files_invalid_id( client.files.delete(uploaded, extra_body={"ids": token_urlsafe(16)}) +@pytest.mark.asyncio +async def test_live_async_delete_files_invalid_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = (await client.files.create_from_paths([resource]))[0] + with pytest.raises(ValidationError): + await client.files.delete(uploaded, extra_body={"ids": token_urlsafe(16)}) + + def test_live_delete_files_missing_id( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -67,3 +103,33 @@ def test_live_delete_files_missing_id( client.files.delete( uploaded, extra_body={"ids": ",".join([bad_id_1, bad_id_2])} ) + + +@pytest.mark.asyncio +async def test_live_async_delete_files_missing_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> None: + resource = get_test_resource_path("report.pdf") + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + bad_id_1 = PdfRestFileID.generate() + bad_id_2 = PdfRestFileID.generate() + uploaded = (await client.files.create_from_paths([resource]))[0] + with pytest.RaisesGroup( + pytest.RaisesExc( + PdfRestDeleteError, + match=f"Failed to delete file {bad_id_1}.*does not exist", + ), + pytest.RaisesExc( + PdfRestDeleteError, + match=f"Failed to delete file {bad_id_2}.*does not exist", + ), + match="Failed to delete one or more files.", + check=lambda eg: isinstance(eg, PdfRestErrorGroup), + ): + await client.files.delete( + uploaded, extra_body={"ids": ",".join([bad_id_1, bad_id_2])} + ) diff --git a/tests/test_delete_files.py b/tests/test_delete_files.py index 68299942..c1d12774 100644 --- a/tests/test_delete_files.py +++ b/tests/test_delete_files.py @@ -27,7 +27,10 @@ def test_delete_payload_serialization() -> None: def test_delete_payload_rejects_empty() -> None: - with pytest.raises(ValidationError): + with pytest.raises( + ValidationError, + match="List should have at least 1 item after validation", + ): DeletePayload.model_validate({"files": []}) @@ -133,16 +136,19 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(PdfRestErrorGroup) as exc_info, + pytest.RaisesGroup( + pytest.RaisesExc( + PdfRestDeleteError, + match=( + f"Failed to delete file {file_repr.id}.*File could not be deleted" + ), + ), + match="Failed to delete one or more files.", + check=lambda eg: isinstance(eg, PdfRestErrorGroup), + ), ): client.files.delete(file_repr) - assert len(exc_info.value.exceptions) == 1 - inner = exc_info.value.exceptions[0] - assert isinstance(inner, PdfRestDeleteError) - assert inner.file_id == str(file_repr.id) - assert "File could not be deleted" in str(inner) - def test_delete_files_aggregates_multiple_failures( monkeypatch: pytest.MonkeyPatch, @@ -168,16 +174,17 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(PdfRestErrorGroup) as exc_info, + pytest.RaisesGroup( + pytest.RaisesExc( + PdfRestDeleteError, + match=f"Failed to delete file {second.id}.*Permission denied", + ), + match="Failed to delete one or more files.", + check=lambda eg: isinstance(eg, PdfRestErrorGroup), + ), ): client.files.delete([first, second]) - assert len(exc_info.value.exceptions) == 1 - inner = exc_info.value.exceptions[0] - assert isinstance(inner, PdfRestDeleteError) - assert inner.file_id == str(second.id) - assert "Permission denied" in str(inner) - @pytest.mark.asyncio async def test_async_delete_files_success( @@ -218,6 +225,57 @@ def handler(request: httpx.Request) -> httpx.Response: assert result is None +@pytest.mark.asyncio +async def test_async_delete_files_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + file_repr = make_pdf_file(PdfRestFileID.generate(2)) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/delete": + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["ids"] == str(file_repr.id) + assert payload["diagnostics"] == "enabled" + return httpx.Response( + 200, + json={ + "deletionResponses": { + str(file_repr.id): "Successfully Deleted", + } + }, + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=transport, + ) as client: + result = await client.files.delete( + file_repr, + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"diagnostics": "enabled"}, + timeout=0.55, + ) + + assert result is None + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.55) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.55) + + @pytest.mark.asyncio async def test_async_delete_files_raises_error_group( monkeypatch: pytest.MonkeyPatch, From 16ced32b32c6a2367689fa9a7ec56234de58d187 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 23 Dec 2025 15:13:05 -0600 Subject: [PATCH 06/13] uv: Update basedpyright --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 3713f6a1..0372fa3c 100644 --- a/uv.lock +++ b/uv.lock @@ -55,14 +55,14 @@ wheels = [ [[package]] name = "basedpyright" -version = "1.34.0" +version = "1.36.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodejs-wheel-binaries" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/77/ded02ba2b400807b291fa2b9d29ac7f473e86a45d1f5212d8276e9029107/basedpyright-1.34.0.tar.gz", hash = "sha256:7ae3b06f644fac15fdd14a00d0d1f12f92a8205ae1609aabd5a0799b1a68be1d", size = 22803348, upload-time = "2025-11-19T14:48:16.38Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/8a/4c5d74314fe085f8f9b1a92b7c96e2a116651b6c7596e4def872d5d7abf0/basedpyright-1.36.2.tar.gz", hash = "sha256:b596b1a6e6006c7dfd483efc1d602574f238321e28f70bc66e87255784b70630", size = 22835798, upload-time = "2025-12-23T02:31:27.357Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/9e/ced31964ed49f06be6197bd530958b6ddca9a079a8d7ee0ee7429cae9e27/basedpyright-1.34.0-py3-none-any.whl", hash = "sha256:e76015c1ebb671d2c6d7fef8a12bc0f1b9d15d74e17847b7b95a3a66e187c70f", size = 11865958, upload-time = "2025-11-19T14:48:13.724Z" }, + { url = "https://files.pythonhosted.org/packages/69/88/0aaac8e5062cd83434ce41fac844646d0f285b574cda0eeb732e916db22b/basedpyright-1.36.2-py3-none-any.whl", hash = "sha256:8dfd74fad77fcccc066ea0af5fd07e920b6f88cb1b403936aa78ab5aaef51526", size = 11882631, upload-time = "2025-12-23T02:31:24.537Z" }, ] [[package]] From c97e854fea5bcf1569db7943cc84abbc9d4ca3bc Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 23 Dec 2025 15:13:57 -0600 Subject: [PATCH 07/13] docs: Expand testing guidelines for exception groups and context managers - Updated `AGENTS.md` and `TESTING_GUIDELINES.md` with instructions on handling `ExceptionGroup` subclasses like `PdfRestErrorGroup` in tests. - Emphasized verifying individual errors within `ExceptionGroups` using `pytest.RaisesGroup` and the `check=` hook for improved test accuracy. - Added guidance to use a single `with` statement for combined context managers in compliance with ruff's SIM117 rule. Assisted-by: Codex --- AGENTS.md | 10 ++++++++++ TESTING_GUIDELINES.md | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 043ec69f..f6e26cc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,11 +138,21 @@ - Write pytest tests: files named `test_*.py`, test functions `test_*`, fixtures in `conftest.py` where shared. +- Follow ruff’s SIM117 rule: when combining context managers (e.g., a client and + `pytest.RaisesGroup`), use a single `with (...)` statement instead of nesting + them to keep tests idiomatic and lint-clean. + - Cover both client transports in every new test module (unit and live suites): add distinct test cases (not parameterized branches) that exercise each assertion through `PdfRestClient` and `AsyncPdfRestClient` so sync/async behaviour stays independently verifiable. +- When endpoints may raise `PdfRestErrorGroup` (or any future pdfRest-specific + exception groups), assert them with `pytest.RaisesGroup`/`pytest.RaisesExc`, + and use the `check=` hook to confirm the outer group is the expected class so + each inner error is validated individually rather than matching the group + message alone. + - Ensure high-value coverage of public functions and edge cases; document intent in test docstrings when non-obvious. diff --git a/TESTING_GUIDELINES.md b/TESTING_GUIDELINES.md index 9df71bd5..c0852a26 100644 --- a/TESTING_GUIDELINES.md +++ b/TESTING_GUIDELINES.md @@ -222,10 +222,16 @@ iteration required. - Local validation failures (`ValidationError`, `ValueError`) that should prevent HTTP calls. - Server/transport failures (`PdfRestApiError`, `PdfRestAuthenticationError`, - `PdfRestTimeoutError`, `PdfRestTransportError`). + `PdfRestTimeoutError`, `PdfRestTransportError`, `PdfRestErrorGroup`, etc.). - When behaviour should short-circuit locally (bad UUIDs, empty query lists, missing profiles), configure the transport to raise if invoked so the test proves no HTTP request occurs. +- When endpoints intentionally raise pdfRest-specific `ExceptionGroup` + subclasses (such as `PdfRestErrorGroup` produced by delete failures), capture + them with `pytest.RaisesGroup`/`pytest.RaisesExc`, and use the `check=` hook + to assert the aggregate is the expected group class. This verifies both the + group message and each individual member (`PdfRestDeleteError`, future custom + errors) instead of relying on the aggregate text alone. ## Additional Expectations From 981db65d55a03bd8c7554d4dd5dc9da79b9f3704 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 24 Dec 2025 11:14:01 -0600 Subject: [PATCH 08/13] delete: Add examples for file deletion using Python 3.10 and 3.11 - Introduced `delete_example.py` showcasing async file deletion with pdfRest: - A Python 3.11 version using `except*` syntax for exception groups. - A Python 3.10 version leveraging the `exceptiongroup` backport. - Updated `pyproject.toml`, `uv.lock`, and `pyrightconfig.json` to include `python-dotenv` for environment variable management. - Added `ruff.toml` configurations targeting `py310` and `py311` in examples. Assisted-by: Codex --- examples/delete/delete_example.py | 52 ++++++++++++++++++ examples/delete/python-3.10/delete_example.py | 48 ++++++++++++++++ examples/delete/python-3.10/ruff.toml | 4 ++ examples/resources/report.pdf | Bin 0 -> 25588 bytes examples/ruff.toml | 4 ++ pyproject.toml | 1 + pyrightconfig.json | 4 ++ uv.lock | 2 + 8 files changed, 115 insertions(+) create mode 100644 examples/delete/delete_example.py create mode 100644 examples/delete/python-3.10/delete_example.py create mode 100644 examples/delete/python-3.10/ruff.toml create mode 100644 examples/resources/report.pdf create mode 100644 examples/ruff.toml diff --git a/examples/delete/delete_example.py b/examples/delete/delete_example.py new file mode 100644 index 00000000..d93b8fbe --- /dev/null +++ b/examples/delete/delete_example.py @@ -0,0 +1,52 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Delete files with pdfRest's async client on Python 3.11+. + +This sample shows how to: + +1. Upload a local resource so we have a file id to delete. +2. Delete that file successfully. +3. Demonstrate how `PdfRestErrorGroup` behaves when we try to delete the same + file again (Python 3.11 also allows `except* PdfRestDeleteError` if you want + to tighten the example even further). + +Run with `uv run --project ../.. python delete_example.py`; the script uses the +checked-in `examples/resources/report.pdf` sample so no additional setup is +required. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import AsyncPdfRestClient, PdfRestDeleteError + +RESOURCE = Path(__file__).resolve().parents[1] / "resources" / "report.pdf" + + +async def delete_with_except_star() -> None: + load_dotenv() + async with AsyncPdfRestClient() as client: + uploaded = (await client.files.create_from_paths([RESOURCE]))[0] + print(f"Uploaded {uploaded.name} with id={uploaded.id}") + + await client.files.delete(uploaded) + print("First deletion succeeded.\n") + + print("Attempting to delete the same file again to trigger errors...") + try: + await client.files.delete(uploaded) + except* PdfRestDeleteError as group: + for error in group.exceptions: + print(f"- Cleanup failed for {error.file_id}: {error.detail}") + else: # pragma: no cover - would require server bug + print("Second deletion unexpectedly succeeded.") + + +if __name__ == "__main__": # pragma: no cover - manual example + asyncio.run(delete_with_except_star()) diff --git a/examples/delete/python-3.10/delete_example.py b/examples/delete/python-3.10/delete_example.py new file mode 100644 index 00000000..5690dfaf --- /dev/null +++ b/examples/delete/python-3.10/delete_example.py @@ -0,0 +1,48 @@ +# /// script +# requires-python = "==3.10" +# dependencies = ["pdfrest", "exceptiongroup", "python-dotenv"] +# /// +"""Delete files with pdfRest's async client on Python 3.10. + +Python 3.10 lacks the built-in `except*` syntax, so this example uses the +`exceptiongroup` backport to catch `PdfRestErrorGroup` and inspect individual +`PdfRestDeleteError` instances when cleanup fails. + +Run with `uv run --project ../.. python delete_example.py`; the shared +`examples/resources/report.pdf` sample ships with the repository. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from dotenv import load_dotenv +from exceptiongroup import BaseExceptionGroup, catch + +from pdfrest import AsyncPdfRestClient, PdfRestDeleteError + +RESOURCE = Path(__file__).resolve().parents[2] / "resources" / "report.pdf" + + +def _log_delete_errors(group: BaseExceptionGroup) -> None: + for error in group.exceptions: + print(f"- Cleanup failed for {error.file_id}: {error.detail}") + + +async def delete_with_exceptiongroup_catch() -> None: + load_dotenv() + async with AsyncPdfRestClient() as client: + uploaded = (await client.files.create_from_paths([RESOURCE]))[0] + print(f"Uploaded {uploaded.name} with id={uploaded.id}") + + await client.files.delete(uploaded) + print("First deletion succeeded.\n") + + print("Attempting to delete the same file again to trigger errors...") + with catch({PdfRestDeleteError: _log_delete_errors}): + await client.files.delete(uploaded) + + +if __name__ == "__main__": # pragma: no cover - manual example + asyncio.run(delete_with_exceptiongroup_catch()) diff --git a/examples/delete/python-3.10/ruff.toml b/examples/delete/python-3.10/ruff.toml new file mode 100644 index 00000000..bc20e03c --- /dev/null +++ b/examples/delete/python-3.10/ruff.toml @@ -0,0 +1,4 @@ +# Extend the `ruff.toml` file in the examples directory... + +extend = "../../ruff.toml" +target-version = "py310" diff --git a/examples/resources/report.pdf b/examples/resources/report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..996131f42dcb33a6a11c55851ce08b70d305c039 GIT binary patch literal 25588 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 literal 0 HcmV?d00001 diff --git a/examples/ruff.toml b/examples/ruff.toml new file mode 100644 index 00000000..74d34a58 --- /dev/null +++ b/examples/ruff.toml @@ -0,0 +1,4 @@ +# Extend the `pyproject.toml` file in the parent directory... + +extend = "../pyproject.toml" +target-version = "py311" diff --git a/pyproject.toml b/pyproject.toml index 46ac53da..2b8be6b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dev = [ "pytest-xdist>=3.8.0", "nox>=2025.5.1", "basedpyright>=1.34.0", + "python-dotenv>=1.0.1", ] [tool.pytest.ini_options] diff --git a/pyrightconfig.json b/pyrightconfig.json index a1f10a43..36d3b75a 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -22,6 +22,10 @@ { "root": "src" }, + { + "root": "examples", + "pythonVersion": "3.11" + }, { "root": "tests", "reportUnknownLambdaType": "none", diff --git a/uv.lock b/uv.lock index 0372fa3c..ba0e7705 100644 --- a/uv.lock +++ b/uv.lock @@ -618,6 +618,7 @@ dev = [ { name = "pytest-md" }, { name = "pytest-rerunfailures" }, { name = "pytest-xdist" }, + { name = "python-dotenv" }, { name = "ruff" }, ] @@ -642,6 +643,7 @@ dev = [ { name = "pytest-md", specifier = ">=0.2.0" }, { name = "pytest-rerunfailures", specifier = ">=16.0.1" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "ruff", specifier = ">=0.6.9" }, ] From c7d8e45fbe355aaa3c8df8fd2fcb653b2e51dda8 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 24 Dec 2025 11:14:52 -0600 Subject: [PATCH 09/13] github/workflows: Add examples job - Introduced a new `examples` job targeting Python versions 3.10 to 3.14. - Runs example sessions using `nox` and caches dependencies for efficiency. - Updated the `publish` job to depend on the newly added `examples` workflow. --- .github/workflows/test-and-publish.yml | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index ead3bbb9..98884370 100644 --- a/.github/workflows/test-and-publish.yml +++ b/.github/workflows/test-and-publish.yml @@ -39,9 +39,38 @@ jobs: env: PDFREST_API_KEY: ${{ secrets.PDFREST_API_KEY }} + examples: + name: Examples (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + permissions: + id-token: write + contents: read + packages: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: 0.8.22 + python-version: ${{ matrix.python-version }} + enable-cache: true + cache-suffix: test-and-publish + cache-dependency-glob: uv.lock + - name: Run examples with nox + run: uvx nox --python ${{ matrix.python-version }} --session examples + env: + PDFREST_API_KEY: ${{ secrets.PDFREST_API_KEY }} + publish: name: Publish to CodeArtifact - needs: tests + needs: + - tests + - examples if: github.event_name == 'release' runs-on: ubuntu-latest permissions: From 576368311e5fff0baac98a8669375559bd765559 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 24 Dec 2025 11:16:51 -0600 Subject: [PATCH 10/13] noxfile: Add support for example script discovery and testing - Implemented utilities to discover example scripts in the `examples` directory, including support for Python-version-specific overrides. - Introduced `_load_script_metadata` to parse and load metadata from example scripts (e.g., required Python version and dependencies). - Added `_scripts_for_python` to filter and select compatible scripts based on their metadata and interpreter version. - Created a new `examples` session in `noxfile` to execute discovered example scripts across supported Python versions. - Introduced a `run-example` session to allow running a single example script with its matching interpreter via `nox`. Assisted-by: Codex --- noxfile.py | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 8cdfc69c..261fbefb 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,10 +1,163 @@ +from __future__ import annotations + import argparse +import ast +from collections.abc import Iterable +from dataclasses import dataclass +from functools import cache +from pathlib import Path import nox +from packaging.specifiers import SpecifierSet +from packaging.version import Version nox.options.default_venv_backend = "uv" -python_versions = ["3.10", "3.11", "3.12", "3.13", "3.14"] +python_versions = ("3.10", "3.11", "3.12", "3.13", "3.14") +PROJECT_ROOT = Path(__file__).resolve().parent +DEFAULT_EXAMPLE_PYTHON = "3.11" +EXAMPLES_DIR = PROJECT_ROOT / "examples" + + +@dataclass(frozen=True) +class ExampleScript: + base: Path + overrides: dict[str, Path] + + def select_for_python(self, python_version: str) -> Path: + interpreter = Version(python_version) + for version_str, script_path in sorted( + self.overrides.items(), key=lambda item: Version(item[0]) + ): + if interpreter <= Version(version_str): + return script_path + return self.base + + +def _is_override_dir(path: Path) -> bool: + return path.is_dir() and path.name.startswith("python-") + + +def _discover_example_scripts() -> list[ExampleScript]: + examples: list[ExampleScript] = [] + if not EXAMPLES_DIR.exists(): + return examples + + for script in sorted(EXAMPLES_DIR.rglob("*.py")): + relative_parts = script.relative_to(EXAMPLES_DIR).parts + if not relative_parts: + continue + if relative_parts[0] == "resources": + continue + if any(part.startswith("python-") for part in relative_parts): + continue + + parent = script.parent + overrides: dict[str, Path] = {} + for override_dir in parent.iterdir(): + if not _is_override_dir(override_dir): + continue + override_script = override_dir / script.name + if override_script.exists(): + overrides[override_dir.name.removeprefix("python-")] = override_script + + examples.append(ExampleScript(base=script, overrides=overrides)) + + return examples + + +EXAMPLE_SCRIPTS = _discover_example_scripts() + + +@dataclass(frozen=True) +class ScriptMetadata: + requires_python: str | None + dependencies: tuple[str, ...] + + +@cache +def _load_script_metadata(script: Path) -> ScriptMetadata: + requires_python: str | None = None + dependencies: tuple[str, ...] = () + if not script.exists(): + return ScriptMetadata(requires_python, dependencies) + + lines = script.read_text().splitlines() + if not lines or lines[0].strip() != "# /// script": + return ScriptMetadata(requires_python, dependencies) + + block: list[str] = [] + for line in lines[1:]: + stripped = line.strip() + if stripped == "# ///": + break + if stripped.startswith("# "): + block.append(stripped[2:]) + else: + break + + metadata: dict[str, str] = {} + for entry in block: + if "=" not in entry: + continue + key, value = entry.split("=", 1) + metadata[key.strip()] = value.strip() + + if raw := metadata.get("requires-python"): + requires_python = ast.literal_eval(raw) + if raw := metadata.get("dependencies"): + dependencies = tuple(ast.literal_eval(raw)) + + return ScriptMetadata(requires_python, dependencies) + + +def _script_supports_python(script: Path, python_version: str) -> bool: + metadata = _load_script_metadata(script) + if not metadata.requires_python: + return True + spec = SpecifierSet(metadata.requires_python) + return Version(python_version) in spec + + +def _collect_script_dependencies(scripts: Iterable[Path]) -> list[str]: + deps: set[str] = set() + for script in scripts: + metadata = _load_script_metadata(script) + for dependency in metadata.dependencies: + if dependency.split("[", 1)[0] == "pdfrest": + continue + deps.add(dependency) + return sorted(deps) + + +def _scripts_for_python(python_version: str) -> list[Path]: + selected: list[Path] = [] + for example in EXAMPLE_SCRIPTS: + script = example.select_for_python(python_version) + if _script_supports_python(script, python_version): + selected.append(script) + return sorted(selected) + + +def _preferred_python_for_script(script: Path) -> str: + metadata = _load_script_metadata(script) + if not metadata.requires_python: + return DEFAULT_EXAMPLE_PYTHON + + spec = SpecifierSet(metadata.requires_python) + for version in python_versions: + if Version(version) in spec: + return version + return DEFAULT_EXAMPLE_PYTHON + + +def _infer_python_version_from_path(script: Path) -> str | None: + for parent in script.parents: + name = parent.name + if name.startswith("python-"): + _, _, version = name.partition("-") + return version + return None @nox.session(name="tests", python=python_versions, reuse_venv=True) @@ -42,3 +195,76 @@ def tests(session: nox.Session) -> None: "--cov-report=term-missing", *pytest_args, ) + + +@nox.session(name="examples", python=python_versions, reuse_venv=True) +def run_examples(session: nox.Session) -> None: + """Execute example scripts across supported interpreters.""" + if not session.python: + session.error("Interpreter selection is required for the examples session.") + + if type(session.python) is not str: + msg = f"Unexpected type for session.python: {type(session.python)}" + raise TypeError(msg) + scripts = _scripts_for_python(session.python) + if not scripts: + session.skip(f"No example scripts registered for Python {session.python}.") + + deps = _collect_script_dependencies(scripts) + if deps: + session.install(*deps) + session.install(".") + + for script in scripts: + session.log(f"Running example: {script.relative_to(PROJECT_ROOT)}") + _ = session.run("python", str(script)) + + +@nox.session(name="run-example", python=False, reuse_venv=False, tags=["examples"]) +def run_example(session: nox.Session) -> None: + """Run a single example script with the matching interpreter. + + Usage: + nox -s run-example -- path/to/script.py [script args...] + """ + + if not session.posargs: + session.error("Provide the path to an example script.") + + script_path = Path(session.posargs[0]).resolve() + if not script_path.exists(): + session.error(f"Example script not found: {script_path}") + + required_python = _infer_python_version_from_path(script_path) + if required_python is None: + required_python = _preferred_python_for_script(script_path) + + extra_args = session.posargs[1:] + tmp_root = Path(session.create_tmp()) + temp_env = tmp_root / f"uv-env-{required_python.replace('.', '_')}" + temp_env.mkdir(parents=True, exist_ok=True) + + cmd = [ + "uv", + "run", + "--project", + str(PROJECT_ROOT), + "--python", + required_python, + "python", + str(script_path), + *extra_args, + ] + env = session.env.copy() + env.update( + { + "UV_PROJECT_ENVIRONMENT": str(temp_env), + "UV_PYTHON_INSTALL_DIR": str(tmp_root / "uv-python"), + } + ) + _ = session.run( + *cmd, + env=env, + external=True, + success_codes=[0], + ) From 4a071ab934381be6c963eeb69eca990ce7edab7b Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 24 Dec 2025 11:21:48 -0600 Subject: [PATCH 11/13] github/workflows: Update `setup-uv` to v0.9.18 --- .github/workflows/test-and-publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index 98884370..837ca918 100644 --- a/.github/workflows/test-and-publish.yml +++ b/.github/workflows/test-and-publish.yml @@ -29,7 +29,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v6 with: - version: 0.8.22 + version: 0.9.18 python-version: ${{ matrix.python-version }} enable-cache: true cache-suffix: test-and-publish @@ -56,7 +56,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v6 with: - version: 0.8.22 + version: 0.9.18 python-version: ${{ matrix.python-version }} enable-cache: true cache-suffix: test-and-publish @@ -89,7 +89,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v6 with: - version: 0.8.22 + version: 0.9.18 enable-cache: true cache-suffix: pre-commit cache-dependency-glob: uv.lock From 5bccfcbebec960e0b4c62c656e8246acdd2b77b0 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 24 Dec 2025 11:46:58 -0600 Subject: [PATCH 12/13] pyproject: Remove private repo, move to workflow command - Remove the tool.uv.index, as it was causing examples not to work in CI, because AWS CodeArtifact wasn't available. - Instead, pass the publish url and username to the `uv publish` command. --- .github/workflows/test-and-publish.yml | 2 +- pyproject.toml | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index 837ca918..4775d8cb 100644 --- a/.github/workflows/test-and-publish.yml +++ b/.github/workflows/test-and-publish.yml @@ -106,4 +106,4 @@ jobs: - name: Build distribution artifacts run: uv build --python 3.11 - name: Publish package to CodeArtifact - run: uv publish --index cit-pypi + run: uv publish --publish-url=https://datalogics-304774597385.d.codeartifact.us-east-2.amazonaws.com/pypi/cit-pypi/ --username __token__ diff --git a/pyproject.toml b/pyproject.toml index 2b8be6b7..69d2a422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,9 +107,3 @@ trailing_comma_inline_array = true keyring-provider = "subprocess" no-build = true no-binary-package = ["pdfrest"] - -[[tool.uv.index]] -name = "cit-pypi" -url = "https://aws@datalogics-304774597385.d.codeartifact.us-east-2.amazonaws.com/pypi/cit-pypi/simple/" -publish-url = "https://aws@datalogics-304774597385.d.codeartifact.us-east-2.amazonaws.com/pypi/cit-pypi/" -username = "__token__" From 2a3aa6105f52418d627c54fa01b84dc92ee6982e Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 24 Dec 2025 12:13:08 -0600 Subject: [PATCH 13/13] docs/examples: Add usage instructions and examples README - Updated `README.md` with instructions on running example scripts. - Added a new `examples/README.md` explaining how to execute and test example scripts using `uv` and `nox`. - Highlighted support for Python-version-specific overrides and the use of `.env` files to manage environment variables. Assisted-by: Codex --- README.md | 7 +++++++ examples/README.md | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 examples/README.md diff --git a/README.md b/README.md index 2dad8fa5..99ff6936 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,13 @@ Python client library for the PDFRest service. The project is managed with [uv](https://docs.astral.sh/uv/) and targets Python 3.9 and newer. +## Running examples + +```bash +uvx nox -s examples +uv run nox -s run-example -- examples/delete/delete_example.py +``` + ## Getting started ```bash diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..6d168504 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,20 @@ +# Examples + +Each example script includes [PEP 723](https://peps.python.org/pep-0723/) +metadata so `uv` can create a disposable environment and install the script's +dependencies without touching the project-wide virtualenv. Run them directly +with `uv run` instead of relying on `--project` mode: + +```bash +# Default (Python 3.11+) +uv run examples/delete/delete_example.py + +# Version-specific overrides +uv run --python 3.10 examples/delete/python-3.10/delete_example.py +``` + +The commands above read `PDFREST_API_KEY` from your environment (you can manage +that via `.env` if desired), upload the checked-in sample assets under +`examples/resources/`, and exercise the async client end-to-end. Use +`uvx nox -s examples` when you want to execute every example across the +supported interpreter matrix.