diff --git a/.github/workflows/test-and-publish.yml b/.github/workflows/test-and-publish.yml index ead3bbb9..4775d8cb 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 @@ -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.9.18 + 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: @@ -60,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 @@ -77,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/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/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/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 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. 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 00000000..996131f4 Binary files /dev/null and b/examples/resources/report.pdf differ 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/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], + ) diff --git a/pyproject.toml b/pyproject.toml index 24154627..69d2a422 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", ] @@ -31,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] @@ -105,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__" 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/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 5cf73dfa..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, @@ -58,6 +60,7 @@ translate_httpx_error, ) from .models import ( + PdfRestDeletionResponse, PdfRestErrorResponse, PdfRestFile, PdfRestFileBasedResponse, @@ -71,6 +74,7 @@ from .models._internal import ( BasePdfRestGraphicPayload, BmpPdfRestPayload, + DeletePayload, GifPdfRestPayload, JpegPdfRestPayload, PdfCompressPayload, @@ -109,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"] @@ -225,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()} @@ -1543,6 +1562,34 @@ 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, + ) -> None: + """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) + deletion_response = PdfRestDeletionResponse.model_validate(raw_payload) + _handle_deletion_failures(deletion_response) + return + def read_bytes( self, file_ref: PdfRestFile | str, @@ -1817,6 +1864,34 @@ 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, + ) -> None: + """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) + deletion_response = PdfRestDeletionResponse.model_validate(raw_payload) + _handle_deletion_failures(deletion_response) + return + async def read_bytes( self, file_ref: PdfRestFile | str, 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.""" 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..75727fef --- /dev/null +++ b/tests/live/test_live_delete.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from secrets import token_urlsafe + +import pytest +from pydantic import ValidationError + +from pdfrest import ( + AsyncPdfRestClient, + PdfRestClient, + PdfRestDeleteError, + PdfRestErrorGroup, +) +from pdfrest.models import PdfRestFileID + +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] + result = client.files.delete(uploaded) + + 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, +) -> 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(ValidationError): + 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, +) -> 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": ",".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 new file mode 100644 index 00000000..c1d12774 --- /dev/null +++ b/tests/test_delete_files.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +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 + + +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, + match="List should have at least 1 item after validation", + ): + 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: + result = client.files.delete(file_repr) + + assert seen == {"post": 1} + assert result is None + + +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: + result = client.files.delete( + file_repr, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.3, + ) + + 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.3) for component in timeout_value.values() + ) + else: + 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.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) + + +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.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]) + + +@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: + result = await client.files.delete(file_repr) + + assert seen == {"post": 1} + 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, +) -> 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]) diff --git a/uv.lock b/uv.lock index 716f8d50..ba0e7705 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]] @@ -599,6 +599,7 @@ name = "pdfrest" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "exceptiongroup" }, { name = "httpx" }, { name = "pydantic" }, ] @@ -617,11 +618,13 @@ dev = [ { name = "pytest-md" }, { name = "pytest-rerunfailures" }, { name = "pytest-xdist" }, + { name = "python-dotenv" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ + { name = "exceptiongroup", specifier = ">=1.3.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.12.0" }, ] @@ -640,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" }, ]