CLI ยท commit 1e3a7c7

clientsmith

Typed Python SDK generator from OpenAPI specs

Download clientsmith.zip
Quickstart
unzip clientsmith.zip && cd clientsmith
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

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

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