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.
Table of Contents
- Why
- Install
- Quick start
- Alternative: the unihttp agent skills
- What you get
- Using the client
- CLI options
- Serializers
- Generation options
- OpenAPI coverage
- Checking the output —
--check - Limitations
- Development
- License
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)
Alternative: the unihttp agent skills
No spec, or you would rather have the client written for you?
unihttp ships
agent skills for Claude Code, Codex,
and other .agents/-aware agents. unihttp-client scaffolds the same kind of packaged,
typed client — models, request classes, a client, ruff/mypy config, and tests —
from an OpenAPI 3.x spec or from a plain description of the API; unihttp teaches
the agent to write idiomatic unihttp code by hand.
claude plugin marketplace add goduni/unihttp
claude plugin install unihttp@unihttp
Which to use: this generator is deterministic and regenerable — the same spec always produces the same package, and it swallows specs far larger than an agent's context. Reach for the skills when there is no machine-readable spec at all, or when you want a small client shaped by hand as it is written.
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) |
--inheritance |
off by default — render allOf: [$ref] as a base class instead of merging its fields in |
--stubs |
off by default — also emit client.pyi so PyCharm sees every method signature (details) |
--check |
run ruff and mypy --strict on the output (details) |
--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
inheritance = false # allOf: [$ref] -> a base class instead of merged fields
stubs = false # also emit client.pyi (see --stubs)
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) )
If you are reaching for imperative only because your editor cannot see through
bind_method, --stubs gets you the same signatures without
changing the runtime code.
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()
Inheritance — --inheritance
What to do with allOf: [{$ref: Base}, ...].
-
off (default) — the base's properties are merged into each subtype, and a base with a
discriminatorbecomes a union alias:@dataclass class CallbackButton: text: str # copied from Button payload: str type: Literal["callback"] = "callback" type Button = CallbackButton | LinkButton
-
--inheritance— the base stays a class and subtypes inherit from it, keeping only their own properties plus the discriminator tag:@dataclass(kw_only=True) class Button: type: str text: str @dataclass(kw_only=True) class CallbackButton(Button): payload: str type: Literal["callback"] = "callback"
isinstancethen works across the hierarchy, and a subtype's own properties stay in one place instead of being copied into every variant.Scope and rules:
- Only an
allOfwith exactly one$refmaps onto a base class — several refs are mixin-style composition with no single parent to pick, so those keep the merge behaviour. So does a$refto an enum or a non-object schema. - Only a base that declares at least one property of its own becomes a class. The
usual polymorphism idiom puts the discriminator on a bare
oneOfholder with no properties (written either as nopropertieskey or as an emptyproperties: {}); there is nothing to inherit from it, so it stays a union alias (type Button = CallbackButton | LinkButton) and keeps decoding into the concrete variant.--inheritanceonly changes how the subtypes get their shared fields. - Constructors become keyword-only for the models in a hierarchy — a subclass may pin an inherited field to a default while adding required fields of its own, which positional ordering cannot express. Models outside every hierarchy are untouched.
- A property explicitly declared by a subtype stays on that subtype, even when it is
identical to the inherited property. Compatible overrides render normally — that
includes narrowing a
$refto a schema that inherits from the base's (companion: Petovercompanion: Creature) andintegerovernumber. An override the base cannot admit, such asv: str | Noneoverv: str, means the subtype is not substitutable for its base: that is a defect in the spec and worth fixing there, so it is reported as a warning. The generated model stays faithful to the schema and carries a local# type: ignore[assignment, unused-ignore], which keeps it clean undermypy --stricteither way. - Naming an inherited property in the subtype's
requiredwithout restating the property still tightens it: the subtype re-declares it with the base's annotation and no default, so the constructor demands it. - The discriminator tag is pinned even when the base types the property as an enum
(
type: {$ref: ButtonKind}) — the idiomatic form.Literal['callback']is not assignable toButtonKind, so the subtype pins the matching member instead:class CallbackButton(Button): type: ButtonKind = ButtonKind.CALLBACK payload: str
- Two properties whose names collapse onto one Python identifier (
packSizeon the base,pack_sizeon the subtype) stay separate fields: the subtype's is renamed and aliased back to its wire name rather than shadowing the inherited one.
One thing to know: when a discriminated base does stay a class, no serializer resolves the concrete subtype from a base-class annotation on its own — a field typed
Buttondecodes intoButton. The generated class carries a# discriminator: type (callback=CallbackButton, ...)comment with the mapping so the tagged decoding can be wired in_serialization.py. Leave--inheritanceoff if you want polymorphic responses to parse into subtypes out of the box. - Only an
Type stubs — --stubs
A declarative client binds each operation from its request class:
class FrankfurterAPIClient(HTTPXSyncClient):
get_rates_for_date = bind_method(GetRatesForDate)
bind_method returns a descriptor whose __get__ overloads carry a ParamSpec taken
from the request dataclass. mypy and pyright resolve that; PyCharm does not, so it
shows no signature, no parameter info, and no return type for any operation. That is a
limitation of the IDE, not something the generated code can work around at runtime.
--stubs writes a client.pyi next to client.py. The runtime module is unchanged —
it still binds declaratively — but type checkers and IDEs read the stub, which spells
every operation out, docstrings included:
class FrankfurterAPIClient(HTTPXSyncClient):
def __init__(
self,
base_url: str = DEFAULT_BASE_URL,
*,
session: Any = None,
middleware: list[Any] | None = None,
) -> None: ...
def get_rates_for_date(
self,
*,
date: str,
base: Omittable[str] = Omitted(),
symbols: Omittable[list[str]] = Omitted(),
amount: Omittable[float] = Omitted(),
) -> ExchangeRates:
"""Historical exchange rates for a date
Reference rates for a specific day (YYYY-MM-DD)...
"""
Notes:
- Only
client.pygets a stub. Models and request classes are plain dataclasses, Pydantic models, ormsgspec.Structs — PyCharm resolves all three on its own, and every extra stub would be one more module that type checkers read instead of the implementation. - It cannot be combined with
--style imperative, which already spells the same signatures out inclient.py; the generator rejects the combination rather than emitting two copies that can drift apart. --checkrunsmypy --stricttwice when stubs are on: once as a consumer sees the package (the stub wins) and once with the stub excluded, soclient.pyis still type-checked rather than being silently skipped.
OpenAPI coverage
- 3.0 and 3.1; JSON or YAML; file or URL; internal and external
$ref. - Schemas: objects,
allOf(merged, or real inheritance with--inheritance),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. - Prose: a schema's
descriptionbecomes a class docstring, and a property's becomes a PEP 258 attribute docstring under the field — so editors show it on hover:class Pet(BaseModel): id: int """Server-assigned identifier."""
The same already holds for parameters and body fields on request classes. Attribute docstrings are inert at runtime, so nothing about construction or decoding changes. - Security: apiKey, http bearer/basic, oauth2, openIdConnect.
Checking the output — --check
--check runs ruff check and mypy --strict over the generated package. With
--stubs it runs mypy a second time with client.pyi
excluded, so the runtime module a stub would otherwise hide stays checked too.
ruff reads the [tool.ruff] table the generator writes into the package's own
pyproject.toml — never ruff's built-in defaults, which change between ruff releases,
and never a config discovered from the working directory, which belongs to whoever ran
the generator. The same table also settles how the output is formatted, which is what
makes generation reproducible (see Formatting).
Both tools are ordinary dependencies of the generator, so installing it installs them —
there is nothing extra to add. They are also resolved from the generator's own
environment rather than from PATH, so an unrelated ruff or mypy installed
system-wide can never take over and lint the output by different rules.
Formatting
The generated pyproject.toml carries a [tool.ruff] table, and every generated file
is formatted and linted against it. That is the whole reason the same spec produces the
same bytes wherever you run the generator: the alternative — letting ruff discover
config the way it normally does — wraps the output at the line-length of whatever
project happens to be around it.
The rule set is deliberately broad, and what it switches off it switches off because the
spec decides it, not the generator: parameter names come from the wire (id, type),
Omitted() is a singleton rather than a mutable default, and forward references are
resolved by deferred imports.
Like everything else in the package, the table is generated: regenerating rewrites
pyproject.toml, so keep local lint preferences in the project that consumes the
client rather than in the client itself.
One thing to know if you installed the generator standalone (uv tool install, pipx):
mypy --strict has to resolve the generated code's imports — unihttp and your chosen
serializer — and a standalone install has neither. Activate the project virtualenv you
intend to install the client into before running with --check, and the generator points
mypy at it. Without an activated virtualenv, --check from a standalone install reports
import-not-found; install the generator into the project environment instead:
uv add --dev unihttp-openapi-generator
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
Release notes, including the breaking changes between versions, live in CHANGELOG.md.
License
MIT
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.3.1.tar.gz.
File metadata
- Download URL: unihttp_openapi_generator-0.3.1.tar.gz
- Upload date:
- Size: 76.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f2c8a566d5cf3371c007a217cdb128bdd04f285a9903826b4d63fb02c168aefd
|
|
| MD5 |
802d7398b19c71f4f74ce525e4889011
|
|
| BLAKE2b-256 |
5aad7e6346d609214771564286b52d0354fa4f911fa5f5c01e103acf66c738a0
|
File details
Details for the file unihttp_openapi_generator-0.3.1-py3-none-any.whl.
File metadata
- Download URL: unihttp_openapi_generator-0.3.1-py3-none-any.whl
- Upload date:
- Size: 87.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f4f42e5f2e21197436870bf04839a6428553485b2209eb5a21ef90a9e1ab7c15
|
|
| MD5 |
01d18f3a9babf0e3da8453705848985f
|
|
| BLAKE2b-256 |
ea9a42fd1d4e7d5de6af2d87ad85c0b8ffd663e4766b2d65d03e8115581e0765
|