The generated code

A tour of the package generated from the petstore schema (the exact package this repository pins in its tests). Everything is plain action0-client code — if you have hand-written operations before, there is nothing new to learn.

Models — models.py

Schema components become dataclasses; enums become enum.Enum subclasses; each model gets a module-level converter function building it from a decoded JSON payload (required keys via data[...], optional ones via data.get(...), dates/UUIDs/enums/nested models converted). The schema’s human-readable text survives: a component’s description becomes the class docstring, a property’s description becomes a #: doc-comment above its field (which Sphinx autodoc reads as the attribute’s documentation):

@dataclass
class Pet:
    """One pet of the store."""

    #: The pet's unique identifier.
    id: int
    name: str
    #: The pet's sale status.
    status: PetStatus | None = None
    #: The pet's day of birth.
    #: Unknown for pets rescued from the wild.
    born_on: datetime.date | None = None  # "bornOn" on the wire
    friends: list[Pet] | None = None


def pet_from_json(data: Any) -> Pet: ...

A schema combining properties with additionalProperties gets a catch-all field: the converter collects every payload key outside the declared properties into a dict typed after the additionalProperties schema — APIs answering with dynamic keys (Open-Meteo’s ensemble member variables, for example) stay fully typed:

@dataclass
class HealthRecord:
    """One veterinary examination of a pet."""

    clinic: str
    #: The day of the examination. Null when it is not recorded.
    examined_on: datetime.date | None = None
    notes: str | None = None
    #: Measured values by name (weight, temperature, ...).
    additional_properties: dict[str, float] | None = None


# payload keys outside this set land in additional_properties
_HEALTH_RECORD_PROPERTIES = {"clinic", "examinedOn", "notes"}


def health_record_from_json(data: Any) -> HealthRecord: ...

The catch-all is a parse-side feature: serialized into a JSON request body, the model would send it as a nested additional_properties object, not flattened — leave it None there.

Operations — operations.py

One class per endpoint, method and path fixed per class, parameters and body as typed fields (wire spellings preserved via the specifiers’ name argument), the response parsed into the model. The operation’s summary and description form the class docstring; parameter and body-property descriptions become #: doc-comments above their fields:

class GetPet(JsonOperation[Pet]):
    """``GET /pets/{petId}``"""

    method = Method.GET
    path = "/pets/{pet_id}"

    #: The identifier of the pet to operate on.
    pet_id: int = path_param()

    def load_json(self, data: Any) -> Pet:
        return pet_from_json(data)

A 204 endpoint subclasses Operation[None], a non-JSON response Operation[bytes]. A JSON result that needs no conversion — a plain scalar, or an object without typed properties like an additionalProperties: integer inventory — is returned through typing.cast, since handing the decoded Any back as-is would fail mypy strict:

class GetInventory(JsonOperation[dict[str, int]]):
    """``GET /store/inventory`` — Returns pet quantities by status."""

    method = Method.GET
    path = "/store/inventory"

    def load_json(self, data: Any) -> dict[str, int]:
        return cast(dict[str, int], data)

Error responses — errors.py

Documented 4xx/5xx (or 4XX/5XX/default) responses with a JSON object schema become typed exceptions: one action0.client.APIError subclass per (status, error model) pair, named after the status, carrying the parsed payload as .error. The operation overrides check to raise them; an error body that is not a JSON object falls through to the plain APIError:

class NotFoundError(APIError):
    """Raised for the documented ``404`` answer, parsed into a :py:class:`Error`."""

    def __init__(self, message: str, *, response: Response, error: Error) -> None: ...
class GetPet(JsonOperation[Pet]):
    ...

    def check(self, response: Response) -> None:
        if response.status == 404:
            data = decode_error(response)
            if isinstance(data, dict):
                message = f"{type(self).__name__}: unexpected status {response.status}"
                raise NotFoundError(
                    f"{message} {response.phrase}".rstrip(),
                    response=response,
                    error=error_from_json(data),
                )
        super().check(response)

The client — client.py

The security schemes become constructor credentials; an apiKey-in-query scheme is added to every request in prepare():

class PetstoreClient(APIClient[BackendT_co]):
    """The Petstore API client."""

    def __init__(
        self,
        backend: BackendT_co,
        token: str,
        api_key_auth: str,
        base_url: str = "https://petstore.example.com/v1",
    ) -> None: ...

Using it

The client stays generic over the backend, so the execution model is your choice — sync, asyncio or Twisted, with the static types following along:

from action0.client.backends.requests import RequestsBackend
from petstore_client import GetPet, PetstoreClient

client = PetstoreClient(RequestsBackend(), token="...", api_key_auth="...")
pet = client.send(GetPet(pet_id=42))  # Pet

from action0.client.backends.httpx import AsyncHttpxBackend

client = PetstoreClient(AsyncHttpxBackend(), token="...", api_key_auth="...")
pet = await client.send(GetPet(pet_id=42))  # Awaitable[Pet]

Non-2xx responses raise action0.client.APIError with the request and response attached — a documented error status raises the generated subclass from errors.py with the parsed payload on top:

from petstore_client import NotFoundError

try:
    pet = client.send(GetPet(pet_id=999))
except NotFoundError as error:  # the documented 404, parsed
    print(error.error.code, error.error.message)

Transport problems arrive as TransportError / TimeoutError — see the action0-client error guide.

Testing your integration

The stub backends of action0.client.testing drive generated clients without a server, exactly like the tests of this repository drive the pinned petstore package:

from action0.client.testing import StubBackend
from action0.req import Response

backend = StubBackend(
    Response(200, headers={"Content-Type": "application/json"}, body=b'{"id": 1, "name": "Rex"}')
)
client = PetstoreClient(backend, token="t", api_key_auth="k")

pet = client.send(GetPet(pet_id=1))
print(pet.name)  # Rex
print(backend.requests[0].url.path)  # /v1/pets/1