# 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, `$ref` resolution across files (including cycles), `allOf` merging,
  `oneOf`/`anyOf` to discriminated unions (`Field(discriminator=...)`) or plain unions, 3.0 `nullable` and 3.1 type arrays.
- **Naming**: operationId to `snake_case` methods with the resource name stripped (`listPets` on `pets` becomes
  `client.pets.list()`), collision handling, PascalCase models, `str` Enums, reserved words and non-identifier JSON keys
  escaped with Pydantic aliases, inline schemas named `Parent` + `Property`.
- **Models**: constraints become `Field(ge=..., max_length=..., pattern=...)`, `datetime`/`date`/`UUID` formats,
  required/nullable/default rules, recursive models with `model_rebuild()`.
- **Clients**: `Client` and `AsyncClient`, one resource class per tag, path/query/header/cookie parameters with
  OpenAPI `style`/`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-After` honored, 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.yaml` previews API-surface changes (and flags breaking ones),
  `clientsmith check spec.yaml` lints a spec for generator-relevant problems, `--templates` overrides any Jinja template.

## Install

```bash
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

```bash
.venv/bin/clientsmith generate examples/petstore.yaml -o out/ --package petstore_client
```

```text
Generated 12 files in out/ (10 operations, 15 models)
```

```python
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/`](examples/generated/petstore_client).

## Usage

### `clientsmith generate`

```text
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`

```console
$ 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:

```console
$ 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:

```python
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:

```console
$ 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`](examples/clientsmith.toml)):

```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](docs/customization.md).

## Architecture

```text
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:

```text
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](docs/design.md). Mapping tables: [docs/mapping-rules.md](docs/mapping-rules.md).

## Tests

```bash
.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://`) `$ref`s, 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 `security` alternatives 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
  `check` warns. Members get their discriminator property rewritten to a `Literal` of the mapped value.
- Responses with several different 2xx schemas return the first documented one.
- Pagination is detected only for `GET` operations whose response is an object containing the item array.
- Generated packages target Python 3.11+ (they use `Self` and `datetime.UTC`).
