clientsmith
Turn an OpenAPI 3.0/3.1 spec into a clean, fully-typed Python SDK: sync and async clients on httpx,
Pydantic v2 models, typed errors, retries with backoff, auth (bearer, API key, basic, OAuth2 client credentials)
and pagination iterators. The output is readable code you would be happy to commit, formatted with ruff,
mypy --strict clean, and byte-for-byte stable across regenerations so diffs only show real API changes.
Features
- Spec loading: JSON/YAML,
$refresolution across files (including cycles),allOfmerging,oneOf/anyOfto discriminated unions (Field(discriminator=...)) or plain unions, 3.0nullableand 3.1 type arrays. - Naming: operationId to
snake_casemethods with the resource name stripped (listPetsonpetsbecomesclient.pets.list()), collision handling, PascalCase models,strEnums, reserved words and non-identifier JSON keys escaped with Pydantic aliases, inline schemas namedParent+Property. - Models: constraints become
Field(ge=..., max_length=..., pattern=...),datetime/date/UUIDformats, required/nullable/default rules, recursive models withmodel_rebuild(). - Clients:
ClientandAsyncClient, one resource class per tag, path/query/header/cookie parameters with OpenAPIstyle/explode(form, spaceDelimited, pipeDelimited, deepObject, simple, label, matrix), JSON / form / multipart bodies, binary and streaming downloads. - Errors:
ApiError->ClientError/ServerError-> one subclass per documented error status (NotFoundError,RateLimitError, ...) with the parsed body. - Retries: 429/5xx with exponential backoff,
Retry-Afterhonored, idempotency-aware, fully configurable. - Pagination: cursor, next-link and offset patterns auto-detected (or set with config hints) and exposed as
*_iter()generators and async generators. - Scaffolding:
pyproject.toml,py.typed, a README with usage examples taken from your operations. - Tooling:
clientsmith diff old.yaml new.yamlpreviews API-surface changes (and flags breaking ones),clientsmith check spec.yamllints a spec for generator-relevant problems,--templatesoverrides any Jinja template.
Install
git clone <this repo> && cd clientsmith
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
Requires Python 3.11+ to run the generator. Generated packages require Python 3.11+, httpx and pydantic>=2.5.
Quickstart
.venv/bin/clientsmith generate examples/petstore.yaml -o out/ --package petstore_client
Generated 12 files in out/ (10 operations, 15 models)
from petstore_client import Client
from petstore_client.models import NewPet, NewPetPetType
with Client("http://localhost:8180", api_key="demo-key") as client:
for pet in client.pets.list_iter(limit=50): # walks every page
print(pet.name)
created = client.pets.create(NewPet(name="Biscuit", pet_type=NewPetPetType.DOG))
The committed result of this exact run lives in examples/generated/.
Usage
clientsmith generate
clientsmith generate SPEC -o OUT [--package NAME] [--config clientsmith.toml] [--templates DIR]
Writes OUT/pyproject.toml, OUT/README.md and the package OUT/<package>/. --package defaults to the snake-cased
info.title plus _client. Warnings found while parsing are printed to stderr. Regenerating into the same directory
rewrites only files whose content changed and deletes resource modules for tags that disappeared.
clientsmith check
$ clientsmith check examples/petstore.yaml
0 warning(s), 10 operations, 15 models
$ clientsmith check tests/specs/tricky.yaml
warning [ambiguous-name]: schema 'PetType' collides after renaming; generated as PetType2
warning [missing-operation-id]: POST /things: no operationId; derived from method and path
warning [ambiguous-name]: GET /things/search: method 'list' already taken on things_admin; using 'list_things'
warning [untyped-response]: GET /things/search: 200 JSON response has no schema (returns Any)
4 warning(s), 7 operations, 8 models
Warning codes: missing-operation-id, duplicate-operation-id, ambiguous-name, untyped-response, multiple-tags,
discriminator. --strict exits with status 1 when anything is reported.
clientsmith diff
Compares the generated API surface (methods with signatures, models with fields, enums, aliases) of two specs:
$ clientsmith diff examples/petstore.yaml examples/petstore_v2.yaml
- method pets.delete
(pet_id: UUID) -> None [DELETE /pets/{petId}]
~ method pets.list
- (*, limit: int | None = None, cursor: str | None = None, status: list[PetStatus] | None = None, x_request_id: UUID | None = None) -> PetPage [GET /pets]
+ (*, limit: int | None = None, cursor: str | None = None, status: list[PetStatus] | None = None, x_request_id: UUID | None = None, color: str | None = None) -> PetPage [GET /pets]
+ method store.get_stats
() -> GetStatsResponse [GET /stats]
+ model GetStatsResponse
~ model Owner
+ field phone: str | None (optional)
~ model Photo
~ field caption: str | None (optional) -> str
~ enum PetStatus
- value 'sold'
7 change(s), 4 breaking
--format json prints machine-readable output; --fail-on-breaking exits 1 if anything was removed, retyped, or a
required field was added, which makes it usable as a CI gate.
Using a generated client
Every call is a plain method; models are Pydantic objects:
page = client.pets.list(limit=2)
print([p.name for p in page.items], page.next_cursor) # ['Misty', 'Rex'] 2
pet = client.pets.get_by_id(uuid) # a Cat or Dog (discriminated on petType)
client.pets.upload_photo(pet.id, ("p.jpg", data, "image/jpeg"), caption="Good boy")
with client.pets.download_photo_stream(pet.id) as chunks:
for chunk in chunks: ...
try:
client.pets.get_by_id(missing)
except NotFoundError as exc: # petstore_client.errors
print(exc.status_code, exc.body.code, exc.body.message)
Async mirrors it exactly: async with AsyncClient(...) as c, await c.pets.list(), async for p in c.pets.list_iter().
End-to-end demo (real output)
examples/mock_server.py is a stdlib http.server implementation of the petstore on port 8180.
examples/demo.py drives the committed generated client against it:
$ python examples/mock_server.py 8180 &
$ python examples/demo.py
first page: ['Misty', 'Rex'] next: 2
all pets: ['Misty', 'Rex', 'Tom', 'Fido', 'Lassie']
available: ['Misty', 'Rex', 'Lassie']
created: Biscuit Dog
photo: 12 bytes, Good boy
streamed: 12 bytes
typed error: 404 not_found - pet 00000000-0000-0000-0000-000000000006 not found
owners: ['Owner 1', 'Owner 2', 'Owner 3', 'Owner 4', 'Owner 5', 'Owner 6', 'Owner 7']
orders via next links: [1, 2, 3, 4, 5]
survived 2x 429: 5 pets
gave up after retries: 429 injected
async pets: ['Misty', 'Rex', 'Tom', 'Fido', 'Lassie']
async orders: [6, 7]
Sending the header X-Mock-Fail: 429:2 makes the mock fail the first two matching requests with a 429, which is how the
retry behavior above is exercised. The API key is demo-key; OAuth2 client credentials are accepted at /oauth/token.
Configuration
clientsmith.toml (pass with --config; see examples/clientsmith.toml):
package = "petstore_client" # same as --package
templates = "my-templates" # same as --templates
[names.operations] # operationId -> method name
sendInquiry = "inquire"
[names.models] # schema name -> class name
Problem = "ApiProblem"
[pagination.listOrders] # override or disable auto-detection
style = "next_link" # cursor | next_link | offset | none
items_field = "results"
next_field = "next"
[retry] # defaults baked into the generated Client
max_retries = 3
backoff_factor = 0.5
Details, including template overrides and every pagination key, are in docs/customization.md.
Architecture
src/clientsmith/
loader.py read YAML/JSON, bundle external $refs into components, reject unsupported specs
parser.py spec -> IR: schemas to type expressions, operations, security, pagination detection
ir.py dataclasses for models, enums, aliases, operations, parameters, schemes
naming.py snake/Pascal conversion, reserved words, collision helpers, method-name derivation
render.py operation -> Python source for resource methods (sync/async, iterators, streaming)
generator.py Jinja rendering, ruff post-processing, file layout, stale-file cleanup
differ.py API surface comparison
cli.py click commands: generate, check, diff
config.py clientsmith.toml
templates/ Jinja templates; core.py.j2 is the runtime shipped inside every generated package
Generated layout:
out/
pyproject.toml README.md
petstore_client/
__init__.py client.py models.py errors.py _core.py py.typed
resources/__init__.py pets.py owners.py orders.py
Design notes and worked examples: docs/design.md. Mapping tables: docs/mapping-rules.md.
Tests
.venv/bin/pytest -q
104 tests cover naming and collision rules, $ref resolution, golden files for every model construct, parameter
serialization styles, generated packages passing ruff check, ruff format --check and mypy --strict
(for the petstore and a deliberately awkward tests/specs/tricky.yaml), regeneration stability, end-to-end calls
against the mock server (sync and async, typed errors, retries with injected 429/503, all three pagination styles,
OAuth2 fetch/refresh, multipart upload, streaming), the diff command and the CLI.
Limitations
- Remote (
http://)$refs, Swagger 2.0, callbacks/webhooks and XML bodies are not supported. - Security: only OAuth2 client credentials is automated; other OAuth2 flows and OpenID Connect are exposed as a bearer
token argument. Per-operation
securityalternatives are honored, but scopes are not negotiated. - A discriminated union is only generated when every member is an object schema; otherwise a plain union is used and
checkwarns. Members get their discriminator property rewritten to aLiteralof the mapped value. - Responses with several different 2xx schemas return the first documented one.
- Pagination is detected only for
GEToperations whose response is an object containing the item array. - Generated packages target Python 3.11+ (they use
Selfanddatetime.UTC).