Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <status>` when the envelope has no `message`.

## [0.1.0] - 2026-09-30

### Added
Expand All @@ -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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion src/shellrent_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 64 additions & 9 deletions src/shellrent_sdk/_errors.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,44 +10,99 @@
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):
"""The API answered with an error response documented in the specification.

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)
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/test_errors.py
Original file line number Diff line number Diff line change
@@ -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)
Loading