🦦 OtterAPI
A cute otter that turns OpenAPI & Swagger specs into type-safe, async Python clients — powered by httpx and Pydantic.
OtterAPI is a sleek Python library that transforms OpenAPI specifications into clean, type-safe client code with Pydantic models and httpx-based HTTP clients.
✨ Features
- Type-Safe Code Generation - Generates Pydantic models and fully typed endpoint functions
- Sync & Async Support - Generate both synchronous and asynchronous API clients
- OpenAPI 3.x Support - Full support for OpenAPI 3.0, 3.1, and 3.2 specifications
- Strict or Lenient Validation - Fail fast on malformed specs, or drop stray fields with
--lenient(vendorx-*extensions always preserved) - Module Splitting - Organize large APIs into multiple organized files
- Pagination - Auto-detect or configure offset, cursor, page, and link-header pagination
- DataFrame Conversion - Generate pandas/polars DataFrame methods for list endpoints
- File Export - Generate CSV, TSV, JSONL, and Parquet streaming helpers
- Response Unwrapping - Transparently unwrap envelope-style responses
- Customizable Client - Generated client class with configurable base URL, timeout, and headers
- Environment Variable Support - Use
${VAR}or${VAR:-default}syntax in config files
🚀 Quick Start
Installation
pip install otterapi
Basic Usage
- Create an
otter.ymlconfiguration file:
documents:
- source: https://petstore3.swagger.io/api/v3/openapi.json
output: petstore_client
- Generate the client:
otter generate
- Use the generated code:
from petstore_client import get_pet_by_id, async_get_pet_by_id
# Synchronous usage
pet = get_pet_by_id(pet_id=123)
# Asynchronous usage
import asyncio
pet = asyncio.run(async_get_pet_by_id(pet_id=123))
📝 Configuration
Config File Locations
OtterAPI looks for configuration in this order:
- Path passed via
otter generate -c <path> otter.yamlorotter.ymlin the current directoryotter.jsonin the current directory[tool.otterapi]section inpyproject.tomlOTTER_SOURCEandOTTER_OUTPUTenvironment variables
Config File Formats
YAML (recommended):
documents:
- source: https://api.example.com/openapi.json
output: ./client
pyproject.toml:
[tool.otterapi]
[[tool.otterapi.documents]]
source = "https://api.example.com/openapi.json"
output = "./client"
JSON:
{
"documents": [
{ "source": "https://api.example.com/openapi.json", "output": "./client" }
]
}
Top-Level Options
These sit at the root of your config file, outside of documents:.
| Option | Type | Default | Description |
|---|---|---|---|
documents |
list | — | List of OpenAPI documents to process (required) |
generate_endpoints |
bool | true |
Whether to generate endpoint functions |
format_output |
bool | true |
Format generated code with ruff/black |
validate_output |
bool | true |
Validate generated code: syntax, plus every referenced name resolving to an import or definition |
create_py_typed |
bool | true |
Create py.typed marker files |
lenient |
bool | false |
Drop unrecognized, invalid fields instead of failing (see Strict vs Lenient Validation) |
format_output: true
validate_output: true
create_py_typed: true
documents:
- source: https://api.example.com/openapi.json
output: ./client
format_outputonly controls cosmetic formatting (line wrapping, quote style). The import block of every generated module is computed from the names the module actually references, so generated code never carries unused imports — with or without ruff/black installed.validate_outputchecks the other direction: generation fails rather than emitting a module that references a name nothing imports or defines.
Document Options
Each entry under documents: supports these fields:
| Option | Type | Default | Description |
|---|---|---|---|
source |
string | — | URL or file path to the OpenAPI spec (required) |
output |
string | — | Output directory for generated code (required) |
base_url |
string | from spec | Override the base URL defined in the spec |
models_file |
string | models.py |
Filename for generated models |
endpoints_file |
string | endpoints.py |
Filename for generated endpoints (no-split mode) |
models_import_path |
string | null |
Override the import path used for models in endpoints |
generate_async |
bool | true |
Generate async endpoint functions |
generate_sync |
bool | true |
Generate sync endpoint functions |
client_class_name |
string | from API title | Override the generated client class name |
function_naming |
operation_id | path |
operation_id |
How to name endpoint functions. path derives names from the HTTP method and URL path — use it for specs that reuse one operationId across many paths |
include_paths |
list | null |
Glob patterns — only matching paths are generated |
exclude_paths |
list | null |
Glob patterns — matching paths are skipped (applied after include_paths) |
target_python |
3.10 … 3.14 |
3.10 |
Oldest Python the generated code has to run on. 3.12+ emits PEP 695 type parameters in the runtime helpers |
pydantic_version |
1 | 2 |
2 |
Target Pydantic version for generated models and helpers |
Function Naming
By default, endpoint function names come from each operation's operationId. Some
specs (notably Swashbuckle/.NET output) reuse a single operationId across many
distinct paths, which produces a pile of numerically-suffixed duplicates like
feed_changes, feed_changes_2, … feed_changes_43. Set function_naming: path
to derive names from the HTTP method and URL path instead, giving distinct,
descriptive names:
documents:
- source: https://api.example.com/swagger.json
output: ./client
function_naming: path # get_v1_feed_trial_changes, get_v1_feed_hcp_profile_changes, ...
Path Filtering
documents:
- source: https://api.example.com/openapi.json
output: ./client
include_paths:
- /api/v2/** # only v2 endpoints
exclude_paths:
- /internal/* # skip internal endpoints
- /admin/** # skip admin endpoints
Patterns follow standard glob syntax (* = single segment, ** = any depth).
Target Python Version
Generated code defaults to running on Python 3.10, so the runtime helpers
(_pagination.py, _concurrency.py) declare their generics with module-level
TypeVars:
T = TypeVar('T')
def paginate_offset(..., extract_items: Callable[[Any], list[T]]) -> list[T]:
That is the only spelling that works before 3.12, but a project whose linter is configured for a newer Python flags it as legacy (ruff's UP047). If the generated client only has to run on 3.12+, say so and the same helpers are emitted with PEP 695 type parameters:
documents:
- source: https://api.example.com/openapi.json
output: ./client
target_python: "3.12"
def paginate_offset[T](..., extract_items: Callable[[Any], list[T]]) -> list[T]:
The setting describes the generated code — OtterAPI itself still runs on any supported interpreter, including when it emits 3.12-only syntax.
Environment Variable Support
Any string value in a config file can reference environment variables:
documents:
- source: ${API_SPEC_URL}
output: ${OUTPUT_DIR:-./client}
base_url: ${BASE_URL:-https://api.example.com}
You can also configure a single document entirely via environment variables (no config file needed):
| Variable | Description |
|---|---|
OTTER_SOURCE |
Path or URL to the OpenAPI spec |
OTTER_OUTPUT |
Output directory |
OTTER_BASE_URL |
Base URL override |
OTTER_MODELS_FILE |
Models filename |
OTTER_ENDPOINTS_FILE |
Endpoints filename |
🔎 Strict vs Lenient Validation
By default, OtterAPI validates specs strictly: an unrecognized or structurally-invalid field aborts generation with a clear error, catching malformed specs early.
Some real-world specs (especially auto-generated Swagger 2.0 documents) carry
stray, non-standard keys. Set lenient: true — or pass --lenient — to drop
those fields instead of failing. Each dropped key is reported as a warning.
lenient: true
documents:
- source: https://api.example.com/openapi.json
output: ./client
otter generate --lenient
Vendor extensions are always preserved. OpenAPI specification extensions
(x-*) are kept in both strict and lenient mode. The only exception is the
type-discriminated security-scheme objects (apiKey, http, oauth2,
openIdConnect), which still reject unknown keys so their variant stays
unambiguous.
📦 Module Splitting
For large APIs, OtterAPI can split generated code into multiple organized modules.
Enabling Module Splitting
documents:
- source: https://api.example.com/openapi.json
output: ./client
module_split:
enabled: true
strategy: tag
Splitting Strategies
tag — Split by OpenAPI Tags
module_split:
enabled: true
strategy: tag
min_endpoints: 1
Endpoints tagged ["Users"] go to users.py, ["Orders"] to orders.py, etc.
path — Split by URL Path
module_split:
enabled: true
strategy: path
path_depth: 1
global_strip_prefixes:
- /api/v1
- /api/v2
/api/v1/users/123 → users.py, /api/v1/orders/456 → orders.py.
custom — Explicit Module Mapping
module_split:
enabled: true
strategy: custom
module_map:
users:
- /users
- /users/*
- /users/**
orders:
- /orders/*
health:
- /health
- /ready
hybrid — Combined Strategy (Default)
Tries module_map first, then tags, then path:
module_split:
enabled: true
strategy: hybrid
module_map:
health:
- /health
- /ready
none — All to Fallback
module_split:
enabled: true
strategy: none
fallback_module: api
Module Map Patterns
| Pattern | Matches |
|---|---|
/users |
Exact path only |
/users/* |
One additional segment |
/users/** |
Any depth below /users |
/v?/users |
Single wildcard character |
Nested Module Maps
module_split:
enabled: true
strategy: custom
module_map:
identity:
users:
- /users/*
auth:
- /auth/*
- /login
- /logout
billing:
invoices:
- /invoices/*
payments:
- /payments/*
Advanced Module Definition
module_split:
enabled: true
strategy: custom
module_map:
v2_api:
paths:
- /v2/**
strip_prefix: /v2
description: "API v2 endpoints"
file_name: v2.py # override the generated filename
modules:
users:
paths:
- /users/*
Module Split Options Reference
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable module splitting |
strategy |
string | hybrid |
none, path, tag, hybrid, custom |
fallback_module |
string | common |
Module for unmatched endpoints |
min_endpoints |
int | 2 |
Minimum endpoints per module before consolidating into fallback |
flat_structure |
bool | false |
Flat files instead of nested directories |
path_depth |
int | 1 |
Path segments used for path strategy (1–5) |
global_strip_prefixes |
list | /api, /api/v1, /api/v2, /api/v3 |
Prefixes stripped from all paths before matching |
module_map |
object | {} |
Custom module-to-path mappings |
split_models |
bool | false |
Generate per-module model files instead of one shared models.py |
shared_models_module |
string | _models |
Module name for shared models when split_models is true |
Output Structure
Flat (flat_structure: false, default):
client/
├── __init__.py
├── models.py
├── _client.py
├── client.py
├── users.py
├── orders.py
└── health.py
Nested (flat_structure: false with nested module_map):
client/
├── __init__.py
├── models.py
├── _client.py
├── client.py
├── identity/
│ ├── __init__.py
│ ├── users.py
│ └── auth.py
└── billing/
├── __init__.py
└── invoices.py
📄 Pagination
OtterAPI generates iterator-based pagination methods alongside standard endpoint functions. Paginated endpoints get an _iter suffix variant that yields items one by one, handling page-fetching automatically.
Enabling Pagination
documents:
- source: https://api.example.com/openapi.json
output: ./client
pagination:
enabled: true
Auto-Detection
When auto_detect: true (the default), OtterAPI inspects each endpoint's parameters. If it finds a matching pair for any pagination style, it generates pagination methods automatically — no per-endpoint config needed.
pagination:
enabled: true
auto_detect: true # default
default_style: offset # default; used when auto-detected
Pagination Styles
| Style | Required Parameters | Description |
|---|---|---|
offset |
offset + limit |
Offset/limit (e.g. ?offset=0&limit=100) |
cursor |
cursor + limit |
Cursor-based (e.g. ?cursor=abc&limit=100) |
page |
page + per_page |
Page number (e.g. ?page=2&per_page=50) |
link |
— | RFC 5988 Link header |
Global Pagination Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable pagination generation |
auto_detect |
bool | true |
Auto-detect pagination from parameter names |
default_style |
string | offset |
Fallback style when auto-detecting |
default_page_size |
int | 100 |
Default page size for iteration |
default_data_path |
string | null |
Default JSON path to items array in response |
default_total_path |
string | null |
Default JSON path to total count in response |
default_offset_param |
string | offset |
Param name used for offset detection |
default_limit_param |
string | limit |
Param name used for limit detection |
default_cursor_param |
string | cursor |
Param name used for cursor detection |
default_page_param |
string | page |
Param name used for page detection |
default_per_page_param |
string | per_page |
Param name used for per-page detection |
endpoints |
object | {} |
Per-endpoint overrides |
Per-Endpoint Pagination Options
pagination:
enabled: true
endpoints:
list_users:
style: cursor
cursor_param: next_token
limit_param: max_results
data_path: data.users
total_path: meta.total
next_cursor_path: meta.next_token
default_page_size: 50
max_page_size: 200
get_orders:
enabled: false # disable pagination for this endpoint
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool|null | inherits | Override whether to generate pagination |
style |
string | inherits | offset, cursor, page, link |
offset_param |
string | inherits | Name of offset parameter |
limit_param |
string | inherits | Name of limit parameter |
cursor_param |
string | inherits | Name of cursor parameter |
page_param |
string | inherits | Name of page parameter |
per_page_param |
string | inherits | Name of per-page parameter |
data_path |
string | inherits | JSON path to items array in response |
total_path |
string | inherits | JSON path to total count |
next_cursor_path |
string | null |
JSON path to next cursor value |
total_pages_path |
string | null |
JSON path to total page count |
default_page_size |
int | inherits | Default page size |
max_page_size |
int | null |
Maximum allowed page size |
Usage
from client import list_users_iter, async_list_users_iter
# Iterate all users (handles pagination automatically)
for user in list_users_iter():
print(user.name)
# Async variant
async for user in async_list_users_iter():
print(user.name)
📊 DataFrame Conversion
When enabled, list-returning endpoints get additional methods that return pandas or polars DataFrames directly.
Enabling DataFrame Methods
documents:
- source: https://api.example.com/openapi.json
output: ./client
dataframe:
enabled: true
pandas: true # generate _df methods (default: true)
polars: true # generate _pl methods (default: false)
Generated Methods
| Original | Pandas | Polars |
|---|---|---|
get_users() |
get_users_df() |
get_users_pl() |
async_get_users() |
async_get_users_df() |
async_get_users_pl() |
Usage
from client import list_pets_df, list_pets_pl
pdf = list_pets_df("available")
plf = list_pets_pl("available")
# Override the data extraction path at call time
df = list_pets_df("available", path="response.data.pets")
Nested Response Paths
dataframe:
enabled: true
pandas: true
default_path: data.items # default for all endpoints
endpoints:
get_users:
path: data.users # override for this endpoint
get_analytics:
path: response.events
polars: true
pandas: false
DataFrame Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable DataFrame generation |
pandas |
bool | true |
Generate _df (pandas) methods |
polars |
bool | false |
Generate _pl (polars) methods |
default_path |
string | null |
Default JSON path to extract data |
include_all |
bool | true |
Generate for all list-returning endpoints |
endpoints |
object | {} |
Per-endpoint overrides |
Per-Endpoint DataFrame Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool|null | inherits | Override whether to generate methods |
path |
string | inherits | JSON path to extract data |
pandas |
bool|null | inherits | Override pandas generation |
polars |
bool|null | inherits | Override polars generation |
💾 File Export
When enabled, list-returning endpoints get streaming export helpers for writing responses directly to files (local or remote via UPath).
Enabling Export
documents:
- source: https://api.example.com/openapi.json
output: ./client
export:
enabled: true
formats:
- csv
- jsonl
Parquet support requires the pyarrow extra:
pip install otterapi[parquet]
Export Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable export helper generation |
formats |
list | [csv, jsonl] |
Default formats: csv, tsv, jsonl, parquet |
default_path |
string | null |
Default JSON path to extract list data |
include_all |
bool | true |
Generate helpers for all list-returning endpoints |
batch_size |
int | 1000 |
Batch size used when streaming pages to disk |
endpoints |
object | {} |
Per-endpoint overrides |
Per-Endpoint Export Options
export:
enabled: true
formats: [csv, jsonl]
endpoints:
list_users:
formats: [parquet] # only parquet for this endpoint
path: data.users
get_events:
enabled: false # disable export for this endpoint
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool|null | inherits | Override whether to generate helpers |
path |
string | inherits | JSON path to extract data |
formats |
list | inherits | Override formats for this endpoint |
Usage
from client import export_list_users_csv, export_list_users_parquet
# Export directly to a file
export_list_users_csv("output/users.csv")
export_list_users_parquet("s3://my-bucket/users.parquet") # UPath remote targets work too
🔓 Response Unwrapping
For APIs that wrap all responses in an envelope (e.g. {"data": {...}, "meta": {...}}), response unwrapping makes endpoints return just the inner data automatically.
Enabling Response Unwrap
documents:
- source: https://api.example.com/openapi.json
output: ./client
response_unwrap:
enabled: true
data_path: data # default path (default: "data")
Per-Endpoint Overrides
response_unwrap:
enabled: true
data_path: data
endpoints:
get_user:
data_path: result.user # this endpoint uses a different path
get_raw_data:
enabled: false # don't unwrap this endpoint
Response Unwrap Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable response unwrapping |
data_path |
string | data |
Default JSON path to extract from all responses |
endpoints |
object | {} |
Per-endpoint overrides |
Per-Endpoint Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool|null | inherits | Override whether to unwrap |
data_path |
string | inherits | JSON path for this endpoint |
📖 Using Generated Code
Direct Function Imports
from client import get_user, create_user, list_orders
# Async versions are prefixed with 'async_'
from client import async_get_user, async_create_user, async_list_orders
# Sync
user = get_user(user_id=123)
# Async
import asyncio
async def main():
user = await async_get_user(user_id=123)
asyncio.run(main())
Module-Specific Imports (with splitting)
from client.users import get_user, create_user
from client.orders import list_orders, get_order
Using the Client Class
from client import Client
client = Client(
base_url='https://api.example.com',
timeout=30.0,
headers={'Authorization': 'Bearer your-token'},
)
user = client.get_user(user_id=123)
async def main():
user = await client.async_get_user(user_id=123)
Working with Models
from client.models import User, CreateUserRequest
new_user = CreateUserRequest(name='John Doe', email='john@example.com')
user = create_user(body=new_user)
print(user.id, user.email)
🔐 Authentication
Every scheme under components.securitySchemes becomes a keyword-only
parameter on the generated client, applied automatically to every request.
# spec
components:
securitySchemes:
api_key:
type: apiKey
name: X-API-Key
in: header
bearerAuth:
type: http
scheme: bearer
from client import Client
client = Client(api_key='...', bearer_auth='...')
user = get_user(user_id=123, client=client)
| Scheme | Parameter type | Sent as |
|---|---|---|
apiKey in header |
str |
the header named in the spec |
apiKey in query |
str |
the query parameter named in the spec |
apiKey in cookie |
str |
appended to the Cookie header |
http / bearer |
str |
Authorization: Bearer <token> |
http / basic |
tuple[str, str] |
Authorization: Basic <base64> |
oauth2, openIdConnect |
str |
Authorization: Bearer <token> — you supply an already-obtained token |
Schemes OtterAPI does not recognize are skipped with a warning rather than failing generation.
Credentials from the environment
Omit a credential and the client reads it from <env_prefix>_<SCHEME_NAME>,
upper-cased — so the api_key scheme above falls back to OTTER_API_KEY.
Set env_prefix per document when one project generates several clients:
documents:
- source: https://api.example.com/openapi.json
output: ./client
auth:
env_prefix: MYAPI # -> MYAPI_API_KEY
When a credential already lives in a variable the prefix rule would never
derive, name it outright with env_vars. Keys are scheme names from
components.securitySchemes (the generated parameter name works too), and
only the schemes you name are affected — the rest keep the derived default:
documents:
- source: https://api.example.com/openapi.json
output: ./client
auth:
env_prefix: MYAPI
env_vars:
api_key: STRIPE_SECRET_KEY # this one exactly
# bearerAuth still falls back to MYAPI_BEARER_AUTH
A constructor argument always beats the environment. Basic auth needs two values, so it has no environment fallback at all.
Auth Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Generate credential parameters from securitySchemes |
env_prefix |
string | OTTER |
Prefix for the environment variables consulted when an argument is omitted. Empty string drops the prefix |
env_vars |
map | {} |
Exact environment variable per scheme, keyed by scheme name. Overrides env_prefix for the schemes it names |
Customizing
_apply_auth runs before the _before_request hook, so the hook can still
override or refresh whatever it set — useful for rotating an OAuth token:
class MyClient(Client):
def _before_request(self, request):
if self._token_expired():
request['headers']['Authorization'] = f'Bearer {self._refresh()}'
return request
Set auth.enabled: false to opt out entirely and wire authentication by hand
in the user-owned client.py.
📤 Request Bodies
Bodies are serialized with model_dump(mode='json', by_alias=True, exclude_unset=True): JSON mode so datetime, UUID and Decimal fields
reach the wire as strings rather than raising, and aliases so fields whose
spec name is not a Python identifier keep their wire name.
exclude_unset means only the fields you actually set are sent:
place_order(body=Order(id=7)) # -> {"id": 7}
Most APIs read an explicit null as "clear this field", so omitting the
fields you never touched is the safer default — and it is what makes partial
updates work. If an endpoint genuinely needs those nulls, a PUT that replaces
a whole resource for instance, turn it off:
documents:
- source: https://api.example.com/openapi.json
output: ./client
request_body:
exclude_unset: false
place_order(body=Order(id=7)) # -> {"id": 7, "shipDate": null, "note": null}
Flattening the body into parameters
By default a POST/PUT/PATCH takes the body as one model:
place_order(body=Order(id=7, quantity=2))
Set flatten and the model's fields become parameters instead:
documents:
- source: https://api.example.com/openapi.json
output: ./client
request_body:
flatten: true
place_order(id=7, quantity=2)
Required fields stay positional so you cannot forget them; the rest are
keyword-only and default to NOTSET. Fields inherited through allOf are
included. The request that goes on the wire is identical either way —
flattening changes the signature, not the protocol.
NOTSET vs None
A nullable field has two distinct "empty" states, and one default cannot carry
both. Omitted fields therefore default to NOTSET, which leaves None
meaning what it says:
place_order(id=7) # -> {"id": 7}
place_order(id=7, note=None) # -> {"id": 7, "note": null}
place_order(id=7, note=NOTSET) # -> {"id": 7} (same as omitting)
NOTSET is exported from the generated package, so you can pass it from a
conditional rather than branching on the call itself:
from myclient import NOTSET, place_order
place_order(id=7, note=note if include_note else NOTSET)
It is a single-member enum, so type checkers treat it as a literal: inside the
client, value is not NOTSET narrows int | None | NotSet to int | None.
It is also falsy, so if value: reads as "was anything given".
Two more details worth knowing:
- A body field can collide with a path or query parameter. The parameter
keeps its name and the body field gains a
_bodysuffix, so a spec with both astatusquery param and astatusbody field generatesadd_note(status_body, *, status=None). The payload still usesstatus. - Only object-shaped JSON bodies can flatten. An array body, a bare
string, or a multipart upload has nothing to spread; those keep the single
body=parameter and log why.
Per-path overrides
paths overrides the document setting for matching URL paths, using the same
glob syntax as include_paths. When several patterns match, the longest wins,
so a specific path beats the wildcard covering it:
request_body:
flatten: true
paths:
/reports/**: # a big nested body reads better as a model
flatten: false
/reports/summary: # …except this one
flatten: true
/users/{id}:
exclude_unset: false # this PUT replaces the whole resource
Request Body Options
| Option | Type | Default | Description |
|---|---|---|---|
exclude_unset |
bool | true |
Send only the fields the caller set, rather than an explicit null for every untouched optional field |
flatten |
bool | false |
Spread the body model's fields into individual parameters instead of one body= argument |
paths |
map | {} |
Per-path overrides keyed by a glob over the URL path; longest match wins |
Each path override accepts exclude_unset and flatten; anything omitted
inherits the document setting.
🔧 CLI Reference
# Generate from default config (otter.yml, otter.yaml, or pyproject.toml)
otter generate
# Generate from specific config file
otter generate -c my-config.yml
# Generate without a config file
otter generate -s ./api.yaml -o ./client
# Override the base URL — required when a file-loaded spec's servers are
# relative (e.g. `url: /api/v3`), and applies to every document in a config
otter generate -s ./api.yaml -o ./client -b https://api.example.com
# Tolerate malformed specs (drop unknown/invalid fields with a warning)
otter generate --lenient
# Initialize a new config file
otter init
# Validate configuration
otter validate
Generation is all-or-nothing: if a run fails partway through, the output
directory is restored to exactly what it was before, so a failure never
leaves a half-written client behind. Files you own inside it — client.py
and anything else you keep there — survive both a failed run and a
successful regeneration.
🐍 Programmatic API
from otterapi.codegen import Codegen
from otterapi.config import DocumentConfig, PaginationConfig, ExportConfig
config = DocumentConfig(
source='https://petstore3.swagger.io/api/v3/openapi.json',
output='./client',
pagination=PaginationConfig(enabled=True),
export=ExportConfig(enabled=True, formats=['csv', 'parquet']),
)
Codegen(config).generate()
❗ Error Handling
Generated clients raise a typed exception hierarchy rooted at BaseAPIError:
from my_client import (
Client, list_users,
BaseAPIError, # catches every API error
ClientError, # all 4xx
ServerError, # all 5xx
NotFoundError, # 404
RateLimitError, # 429
)
try:
users = list_users(client=Client())
except NotFoundError as e:
log.warning('not found: %s', e.detail)
except RateLimitError:
backoff_and_retry()
except ServerError:
page_oncall()
except BaseAPIError as e:
log.error('unexpected %d: %s', e.status_code, e.detail)
Mapped status codes: 400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504. Other 4xx/5xx fall through to ClientError/ServerError. Subclass BaseAPIError in your client.py to customize error parsing.
♻️ Regenerating after spec changes
OtterAPI is idempotent for generated files:
models.py,endpoints.py,_client.py,_pagination.py,_export.py,_dataframe.pyare rewritten on every run — never edit them by hand.client.pyis generated once and left alone — this is your customization seam.
🛠 Development
git clone https://github.com/danplischke/otterapi.git
cd otterapi
uv sync
uv run pytest
uv run pytest --cov=otterapi
uv run ruff format .
uv run ruff check .
📄 License
MIT License — see LICENSE for details.
Metadata
Release files for otterapi 0.1.13
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| otterapi-0.1.13.tar.gz | 310.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| otterapi-0.1.13-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 663.9 kB
Release files / otterapi-0.1.13.tar.gz
| Download URL | otterapi-0.1.13.tar.gz |
|---|---|
| Size | 310.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f4152047ad167a20cad8b62dc1448c3cbfa2e38daaac8acb3eec798249592bfe
|
|
BLAKE2b-256 checksum How to use checksums |
f6c167bfaecfa32eb15e38a40570609a5b7afe0f810139e111654c780088863d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 13, 2026.
Transparency logRelease files / otterapi-0.1.13-py3-none-any.whl
| Download URL | otterapi-0.1.13-py3-none-any.whl |
|---|---|
| Size | 353.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
48f78b389c196c954bc5b1434659f062579679e7acc4413cb107ca9ae062edaa
|
|
BLAKE2b-256 checksum How to use checksums |
9eaec76d37c139f1e00d9f8dac4279dea444039b8241d3e96243832d48590302
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 13, 2026.
Transparency log