diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d65757..1d84782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-10-01 + +### Added + +- `unwrap(response, error_type=...)`: another generated client passes its own `ApiError` model, so + that its documented error responses raise `ApiException` instead of `UnexpectedStatus`. Without + `error_type`, `unwrap()` works and is typed as before. +- `ApiErrorLike`, the protocol of the error envelopes of the generated clients (`error` and + `message`). + +### Changed + +- `ApiException.error` is typed as `ApiErrorLike` instead of `ApiError`, so that it accepts the + `ApiError` of any generated client. At runtime it is still the `ApiError` of the response; for its + other fields, narrow it with `isinstance(e.error, ApiError)`. +- The message of `ApiException` is `HTTP ` when the envelope has no `message`. + ## [0.1.0] - 2026-09-30 ### Added @@ -28,5 +45,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `invalid_client` or, for its rate limit, `rate_limited`. - The `shellrent` command, with `shellrent api` and `shellrent token`. -[Unreleased]: https://github.com/shellrent/shellrent-sdk-python/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/shellrent/shellrent-sdk-python/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/shellrent/shellrent-sdk-python/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/shellrent/shellrent-sdk-python/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index b0b9c6a..54e161e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,9 @@ da `spec/openapi.yaml`; a mano solo autenticazione, creazione del client, CLI, t - `auth.py`, `http.py` e `cli.py` non importano nulla da `shellrent_sdk.client` (lo verifica `test_layers.py`). - I transport di default di `RetryTransport` seguono l'ambiente come quelli di httpx: proxy con `urllib.request.getproxies`/`proxy_bypass` (httpx non ha un'API pubblica), certificati con `trust_env`. -- L'interfaccia pubblica di `auth`, `http` e della CLI (`main(argv)`, comandi, variabili d'ambiente, exit - code) è usata da `shellrent-internal-sdk`: cambiarla è una modifica incompatibile (major). +- L'interfaccia pubblica di `auth`, `http`, di `unwrap()` e `ApiException` (con `ApiErrorLike`) e della CLI + (`main(argv)`, comandi, variabili d'ambiente, exit code) è usata da `shellrent-internal-sdk`: cambiarla è + una modifica incompatibile (major). Il client generato interno passa il proprio `ApiError` a + `unwrap(error_type=...)`: `ApiException` non dipende dall'`ApiError` di questo pacchetto. - `create_client()` restituisce `AuthenticatedClient` perché le funzioni generate lo richiedono nel tipo; il token lo mette `OAuth2Auth` a ogni richiesta, non il campo `token` del client. diff --git a/src/shellrent_sdk/__init__.py b/src/shellrent_sdk/__init__.py index fca43eb..2f1e362 100644 --- a/src/shellrent_sdk/__init__.py +++ b/src/shellrent_sdk/__init__.py @@ -4,12 +4,13 @@ one module per operationId; the models in ``shellrent_sdk.client.models``. """ -from ._errors import ApiException, unwrap +from ._errors import ApiErrorLike, ApiException, unwrap from ._factory import create_client from .auth import ClientCredentials, FileTokenStore, MemoryTokenStore, TokenError, TokenStore from .client.errors import UnexpectedStatus __all__ = [ + "ApiErrorLike", "ApiException", "ClientCredentials", "FileTokenStore", diff --git a/src/shellrent_sdk/_errors.py b/src/shellrent_sdk/_errors.py index db4a029..e8c4c18 100644 --- a/src/shellrent_sdk/_errors.py +++ b/src/shellrent_sdk/_errors.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, Protocol, TypeVar, overload from .client.errors import UnexpectedStatus from .client.types import Response @@ -10,6 +10,36 @@ from .client.models.api_error import ApiError T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) + + +class ApiErrorLike(Protocol): + """The error envelope of the API: the ``ApiError`` model of any generated client.""" + + @property + def error(self) -> int: ... + + @property + def message(self) -> str | None: ... + + +E = TypeVar("E", bound=ApiErrorLike) + + +class _ResponseLike(Protocol[T_co]): + """The ``Response`` of any generated client.""" + + @property + def status_code(self) -> int: ... + + @property + def content(self) -> bytes: ... + + @property + def headers(self) -> Mapping[str, str]: ... + + @property + def parsed(self) -> T_co | None: ... class ApiException(Exception): @@ -17,37 +47,62 @@ class ApiException(Exception): Attributes: status_code: HTTP status, such as 404. - error: The error envelope, with ``message``. + error: The error envelope, with ``message``: the ``ApiError`` of the generated client. headers: Headers of the response, such as ``Retry-After``. content: Body of the response. """ def __init__( - self, status_code: int, error: ApiError, headers: Mapping[str, str], content: bytes + self, status_code: int, error: ApiErrorLike, headers: Mapping[str, str], content: bytes ) -> None: - super().__init__(f"HTTP {status_code}: {error.message}") + if error.message is None: + super().__init__(f"HTTP {status_code}") + else: + super().__init__(f"HTTP {status_code}: {error.message}") self.status_code = status_code self.error = error self.headers = headers self.content = content -def unwrap(response: Response[T | ApiError]) -> T: +@overload +def unwrap(response: Response[T | ApiError], *, error_type: type[ApiError] | None = None) -> T: ... + + +@overload +def unwrap(response: _ResponseLike[T | E], *, error_type: type[E]) -> T: ... + + +def unwrap( + response: _ResponseLike[object], *, error_type: type[ApiErrorLike] | None = None +) -> object: """Returns the parsed body of a successful response and raises an exception for the others. Takes the result of the ``sync_detailed()`` and ``asyncio_detailed()`` functions:: purchase = unwrap(get_purchase.sync_detailed(123, client=client)) + Args: + response: The response of a generated function. + error_type: The ``ApiError`` model of the generated client; default the one of + ``shellrent_sdk.client``. In a direct call mypy cannot tell the error envelope from the + envelope of the body, so another generated client wraps ``unwrap()`` with its own types:: + + def unwrap(response: Response[T | ApiError]) -> T: + return shellrent_sdk.unwrap(response, error_type=ApiError) + Raises: - ApiException: An error response documented in the specification (``ApiError``). + ApiException: An error response documented in the specification (``error_type``). UnexpectedStatus: Any other response outside 2xx, or without a parsed body. """ - # Imported here: importing the models takes a while, and the CLI does not need them. - from .client.models.api_error import ApiError + if error_type is None: + # Imported here: importing the models takes a while, and the CLI does not need them. + from .client.models.api_error import ApiError + + error_type = ApiError parsed = response.parsed - if isinstance(parsed, ApiError): + if isinstance(parsed, error_type): raise ApiException(response.status_code, parsed, response.headers, response.content) if parsed is None or not 200 <= response.status_code < 300: raise UnexpectedStatus(response.status_code, response.content) diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py new file mode 100644 index 0000000..486970f --- /dev/null +++ b/tests/unit/test_errors.py @@ -0,0 +1,95 @@ +"""unwrap() with the models of another generated client, such as the one of shellrent-internal-sdk.""" + +from __future__ import annotations + +from collections.abc import MutableMapping +from http import HTTPStatus +from io import BytesIO +from typing import Generic, TypeVar, assert_type + +import pytest +from attrs import define + +from shellrent_sdk import ApiException, unwrap +from shellrent_sdk.client.models import ApiError +from shellrent_sdk.client.types import File, Response + +T = TypeVar("T") + + +# The types of another generated client, with the same shape as those of shellrent_sdk.client. +@define +class OtherResponse(Generic[T]): + status_code: HTTPStatus + content: bytes + headers: MutableMapping[str, str] + parsed: T | None + + +@define +class OtherApiError: + error: int + message: str | None + + +@define +class OtherStatus: + error: int + message: str | None + data: str + + +def other_unwrap(response: OtherResponse[T | OtherApiError]) -> T: + """The wrapper of the other client, as in the docstring of unwrap().""" + return unwrap(response, error_type=OtherApiError) + + +def test_unwrap_raises_api_exception_for_the_api_error_of_another_client() -> None: + error = OtherApiError(error=404, message="Not found") + response: OtherResponse[OtherStatus | OtherApiError] = OtherResponse( + HTTPStatus.NOT_FOUND, b"{}", {"X-Request-Id": "abc"}, error + ) + + with pytest.raises(ApiException) as info: + other_unwrap(response) + + assert info.value.status_code == 404 + assert info.value.error is error + assert info.value.headers["X-Request-Id"] == "abc" + assert str(info.value) == "HTTP 404: Not found" + + +def test_unwrap_returns_the_body_of_another_client() -> None: + status = OtherStatus(error=0, message=None, data="ok") + response: OtherResponse[OtherStatus | OtherApiError] = OtherResponse( + HTTPStatus.OK, b"{}", {}, status + ) + + assert assert_type(other_unwrap(response), OtherStatus) is status + + +def test_api_exception_without_message() -> None: + response: OtherResponse[OtherStatus | OtherApiError] = OtherResponse( + HTTPStatus.CONFLICT, b"{}", {}, OtherApiError(error=409, message=None) + ) + + with pytest.raises(ApiException) as info: + other_unwrap(response) + + assert str(info.value) == "HTTP 409" + + +def test_the_api_error_of_shellrent_sdk_is_the_default() -> None: + file = File(payload=BytesIO(b"%PDF")) + response: Response[File | ApiError] = Response(HTTPStatus.OK, b"%PDF", {}, file) + error: Response[File | ApiError] = Response( + HTTPStatus.NOT_FOUND, + b"{}", + {}, + ApiError(error=404, message="Not found", data=None, meta=None), + ) + + assert assert_type(unwrap(response), File) is file + assert assert_type(unwrap(response, error_type=ApiError), File) is file + with pytest.raises(ApiException): + unwrap(error, error_type=ApiError)