Generate typed Python API clients from OpenAPI 3.0/3.1 specs, built on unihttp
Project description
unihttp-openapi-generator
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.
Why
- 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.
Install
pip install unihttp-openapi-generator
# or, with uv:
uv tool install unihttp-openapi-generator
Quick start
unihttp-openapi-generator generate openapi.yaml \
--output-dir ./out --package-name acme_client
The spec can be a local path or a URL, in JSON or YAML. Install the result and use it:
pip install ./out
from 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)
What you get
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:
...
Using the client
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 layout
Async 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())
Base URL and servers
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"])
Authentication
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> |
Custom headers, cookies, timeouts
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)
Errors
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:
...
Middleware
Pass any unihttp middleware; auth and error mapping are composed around it.
from unihttp.middlewares.retry import RetryMiddleware
client = AcmeClient(middleware=[RetryMiddleware(retries=3)])
CLI options
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 |
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 one
Discovery 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 output
Or, 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"
Serializers
| 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.
Generation options
These shape the surface and style of the generated code. All have sensible defaults; reach for them to match an existing codebase or taste.
Client layout — --layout
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.
File layout — --file-layout
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.
Method style — --style
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))
Optional fields — --optional
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()
OpenAPI coverage
- 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.
Limitations
- 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).
Development
uv sync
uv run pytest
uv run ruff check src tests
uv run mypy
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file unihttp_openapi_generator-0.1.0.tar.gz.
File metadata
- Download URL: unihttp_openapi_generator-0.1.0.tar.gz
- Upload date:
- Size: 44.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d574a7f033485b1c4c2a16a449f853c89a85b163269797960db9b8c5b0efc80b
|
|
| MD5 |
80715506e78208d9c74b0c6a2afa7415
|
|
| BLAKE2b-256 |
c8066d9e1085ef05b20a30dc264884711726cca85b55fa51a973bc01b8335e7b
|
File details
Details for the file unihttp_openapi_generator-0.1.0-py3-none-any.whl.
File metadata
- Download URL: unihttp_openapi_generator-0.1.0-py3-none-any.whl
- Upload date:
- Size: 61.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a767d296880f5b5baa538276da4465f20e4841de3ef6891d9841c28125875a3a
|
|
| MD5 |
c007618bc491863c8c0dbadf0157afa7
|
|
| BLAKE2b-256 |
0ea04f42e7d474fc426ca3174257e8ebbf365c8247b8c304c82a9823e2075dbb
|