Turn an OpenAPI spec into a typed Python API client built on unihttp.
Point it at a spec; get back an installable package with data models, request
classes, a sync and/or async client, an exception hierarchy, and authentication
wiring. The output is formatted with ruff and type-checks clean under mypy --strict.
- Actually typed. Models, parameters, and return values carry real annotations;
the generated code passes
mypy --strict. Your editor knows the shape of every request and response. - Three model backends. Choose
adaptix(default),pydantic, ormsgspecfor the generated models — same client, your serializer. - Sync, async, or both. Backed by
httpx,aiohttp,requests,niquests, orzapros, chosen per client. - Faithful to the spec.
allOf/oneOf/anyOf, discriminated unions, enums, formats, nullable, defaults, multipart uploads, query array styles, security schemes, and error responses are all carried through. - Proven on large specs. The Stripe, GitHub, OpenAI, and Kubernetes specs each
generate clean, importable code that passes
ruffandmypy --stricton every serializer. - Readable, regenerable output. Deterministic,
ruff-formatted, organized by tag.
pip install unihttp-openapi-generator
# or, with uv:
uv tool install unihttp-openapi-generatorunihttp-openapi-generator generate openapi.yaml \
--output-dir ./out --package-name acme_clientThe spec can be a local path or a URL, in JSON or YAML. Install the result and use it:
pip install ./outfrom acme_client import AcmeClient
with AcmeClient(base_url="https://api.example.com", token="...") as client:
pet = client.pets.get_pet(pet_id=1) # -> a typed model
print(pet.name)out/
├── pyproject.toml # installable; pins unihttp + your serializer + backend
├── README.md
└── acme_client/
├── __init__.py # exports the client(s), DEFAULT_BASE_URL, SERVERS
├── py.typed
├── models.py # dataclass / BaseModel / msgspec.Struct
├── _serialization.py # request/response (de)serialization wiring
├── exceptions.py # ApiError hierarchy + status -> exception map
├── auth.py # credential middlewares (when the spec defines security)
├── methods/<tag>.py # one request class per operation
└── client.py # the client(s)
A request class and the client constructor (real output):
@dataclass
class GetBooking(BaseMethod[GetBookingResponse]):
"""Get a booking
Returns the details of a specific booking.
"""
__url__ = "/bookings/{bookingId}"
__method__ = "GET"
booking_id: Path[UUID]
class TrainTravelAPIClient(RequestsSyncClient):
def __init__(self, base_url: str = DEFAULT_BASE_URL, *,
session: Any = None, middleware: list[Any] | None = None,
token: str | None = None) -> None:
...Clients are context managers and close their transport on exit.
from acme_client import AcmeClient
with AcmeClient(base_url="https://api.example.com", token="secret") as client:
booking = client.bookings.get_booking(booking_id=some_uuid) # grouped layout
# client.get_booking(...) # flat layoutAsync clients expose the same surface; their methods are awaitables:
import asyncio
from acme_client import AsyncAcmeClient
async def main() -> None:
async with AsyncAcmeClient(token="secret") as client:
trips = await client.trips.get_trips(origin=a, destination=b, date=when)
asyncio.run(main())The default base URL is taken from the spec's servers (preferring a production
entry). Every server is also exported:
from acme_client import DEFAULT_BASE_URL, SERVERS
client = AcmeClient(base_url=SERVERS["Production"])Each security scheme becomes a constructor keyword that is injected via middleware:
| Scheme | Keyword | Sent as |
|---|---|---|
| http bearer / oauth2 / openIdConnect | token: str |
Authorization: Bearer <token> |
| apiKey (header or query) | <scheme>: str |
the named header or query parameter |
| http basic | <scheme>: tuple[str, str] |
Authorization: Basic <base64> |
Build the underlying HTTP client yourself and pass it as session= (its type matches
the chosen backend — requests.Session by default, httpx.Client, aiohttp.ClientSession, …):
import requests
session = requests.Session()
session.headers["User-Agent"] = "acme/1.0"
client = AcmeClient(session=session)Non-2xx responses raise. <package>.exceptions defines a base ApiError plus a
subclass per status code (NotFoundError, UnprocessableEntityError, …), with
4xx/5xx falling back to unihttp's ClientError/ServerError.
from acme_client.exceptions import ApiError, NotFoundError
try:
booking = client.bookings.get_booking(booking_id=bad_id)
except NotFoundError as exc:
print(exc.status_code, exc.response.data)
except ApiError:
...Pass any unihttp middleware; auth and error mapping are composed around it.
from unihttp.middlewares.retry import RetryMiddleware
client = AcmeClient(middleware=[RetryMiddleware(retries=3)])unihttp-openapi-generator generate SPEC [options]
| Option | Values (default) |
|---|---|
-o, --output-dir |
path (required) |
--package-name |
identifier (required) |
--serializer |
adaptix · pydantic · msgspec (adaptix) |
--client |
both · sync · async (both) |
--sync-backend |
httpx · requests · niquests · zapros (requests) |
--async-backend |
httpx · aiohttp · niquests · zapros (aiohttp) |
--layout |
auto · flat · grouped (auto) |
--file-layout |
single · per-object (single) |
--style |
declarative · imperative (declarative) |
--optional |
none · omitted (none) — omitted distinguishes absent from null (adaptix) |
--strip-prefix |
auto or a dotted prefix to drop from schema names (e.g. io.k8s.api.core.v1.Pod → CoreV1Pod) |
--check |
run ruff and mypy --strict on the output |
--config |
TOML config file |
Keep your generation settings in a TOML file so a regenerate is a single command and the configuration lives in version control.
Precedence. For every setting: an explicit CLI flag wins, otherwise the config file, otherwise the built-in default. So you can pin a project's settings in the file and still override one of them ad hoc on the command line:
unihttp-openapi-generator generate # use the discovered config
unihttp-openapi-generator generate --serializer msgspec # override just this oneDiscovery order (the first that exists is used):
- the file passed to
--config FILE, unihttp-openapi-generator.tomlin the current directory,- a
[tool.unihttp-openapi-generator]table inpyproject.toml.
Keys mirror the CLI options exactly. spec, output_dir, and package_name are
required (from the file or the command line); everything else is optional and falls
back to the default shown in the CLI options table. Unknown keys are
rejected so typos surface immediately.
A fully annotated unihttp-openapi-generator.toml:
spec = "https://api.example.com/openapi.json" # path or URL; JSON or YAML
output_dir = "out" # where the package is written
package_name = "acme_client" # importable package name
serializer = "adaptix" # adaptix | pydantic | msgspec
client = "both" # both | sync | async
sync_backend = "requests" # httpx | requests | niquests | zapros
async_backend = "aiohttp" # httpx | aiohttp | niquests | zapros
layout = "auto" # auto | flat | grouped (client shape)
file_layout = "single" # single | per-object (files on disk)
style = "declarative" # declarative | imperative (method style)
optional = "none" # none | omitted (optional model fields)
strip_prefix = "auto" # "auto" or a dotted prefix to drop from schema names
check = true # run ruff + mypy --strict on the outputOr, to keep it inside an existing project, drop the same keys under a table in
pyproject.toml:
[tool.unihttp-openapi-generator]
spec = "openapi.yaml"
output_dir = "out"
package_name = "acme_client"
serializer = "pydantic"
client = "async"| adaptix (default) | pydantic | msgspec | |
|---|---|---|---|
| Model type | @dataclass |
BaseModel |
msgspec.Struct |
| Field aliasing | full (retort name mapping) | Field(alias=…) |
field(name=…) |
| Query array styles | full | explode only | explode only |
| Runtime validation | — | yes | yes |
adaptix gives the highest fidelity (parameter aliases and all query array styles).
pydantic adds runtime validation; msgspec is the fastest.
These shape the surface and style of the generated code. All have sensible defaults; reach for them to match an existing codebase or taste.
How methods are exposed on the client.
flat— every operation is a method on one client class:client.get_booking(booking_id=...) client.create_booking(body=...)
grouped— operations are grouped into sub-clients by their OpenAPI tag (nicer for large APIs):client.bookings.get_booking(booking_id=...) client.payments.create_payment(...)
auto(default) —flatwhen the spec has at most one tag,groupedotherwise.
How the package is split on disk. The import surface is identical either way.
single(default) — onemodels.pyand onemethods/<tag>.pyper tag. Fewer, larger files.per-object— one file per model/enum and per request method (models/<name>.py,methods/<tag>/<method>.py). Easier to navigate and gives small, focused diffs on regeneration, at the cost of many files. Cross-references between modules are resolved automatically without circular imports.
How client methods are written.
declarative(default) — methods are bound from the request classes. Compact; the call signature comes from the request dataclass:class BookingsClient: get_booking = bind_method(GetBooking)
imperative— an explicit, fully-typed wrapper per operation. More generated code, but the signature is spelled out for the best editor experience:def get_trips(self, *, origin: UUID, destination: UUID, date: datetime, page: int = 1, limit: int = 10) -> GetTripsResponse: return self.call_method(GetTrips(origin=origin, destination=destination, date=date, page=page, limit=limit))
How optional model fields are represented (adaptix only).
none(default) —T | None = None. Simple, but "field absent" and "field is null" both read asNone.middle_name: str | None = None
omitted—Omittable[T] = Omitted(). Distinguishes a field you never set from one set tonull; unset fields are dropped from the request body entirely. Useful for PATCH-style APIs where sendingnullclears a value:middle_name: Omittable[str | None] = Omitted()
- 3.0 and 3.1; JSON or YAML; file or URL; internal and external
$ref. - Schemas: objects,
allOfmerge,oneOf/anyOf, discriminator (including polymorphic bases), enums andconst, formats, nullable,additionalProperties, constraints, recursion, andreadOnly(excluded from request bodies). - Operations: path/query/header parameters with defaults, JSON/form/multipart bodies,
file uploads, typed responses, and
deprecated. - Security: apiKey, http bearer/basic, oauth2, openIdConnect.
- Response headers are not exposed; methods return the response body.
deepObjectquery parameters and full parameter aliasing work onadaptix; onpydanticandmsgspecthey are limited.- Swagger / OpenAPI 2.0 is not supported (use the OpenAPI 3 description if a service publishes both, as Kubernetes does).
uv sync
uv run pytest
uv run ruff check src tests
uv run mypyMIT