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
- 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
- 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)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@unihttpWhich 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.
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) |
--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 |
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
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 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))
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.
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()
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 subtype that restates an inherited property just to attach prose, or to relax it
to nullable, simply inherits it: re-declaring
v: str | Noneover the base'sv: stris rejected bymypy --strict. Genuine narrowings are kept — including aLiteraltag over astrbase, but not over a base of a different scalar type (Literal['one', 'two']does not narrow anint). So is a restatement that changes thedefault, tightens theconstraints, or makes the fieldrequired. - 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
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.
- 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. - Security: apiKey, http bearer/basic, oauth2, openIdConnect.
--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.
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.
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- 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