stainful
The open-source Stainless. Generate an idiomatic Python SDK from an OpenAPI
spec and a stainless.yml — open source, runs locally and in CI, no hosted service.
stainful turns an OpenAPI 3.x spec and a Stainless config into a Python SDK
that reads like it was written by hand — typed models, real error classes,
retries, auto-pagination, streaming, sync and async. It reuses the
stainless.yml format, so if you already have one, you can point stainful at it
as-is.
Migrating from Stainless? Anthropic acquired Stainless and is winding down the hosted SDK generator. stainful is a drop-in continuation path — see
docs/migrating-from-stainless.mdfor the step-by-step (it should be a few minutes).
Features
- 🧬 Typed everything — pydantic v2 models, real discriminated unions from
oneOf - 🔁 Auto-pagination —
for item in client.things.list(): ... - 🛡️ Typed errors —
except RateLimitError:instead of checking status codes - 🔄 Resilient — retries with exponential backoff + jitter,
Retry-After, idempotency keys - 📡 Streaming — typed Server-Sent Events, identical surface in sync and async
- 🎯 Precise optionality —
required,optional, andnullablestay distinct - 🧭 Domain-shaped clients —
client.chat.completions.create(...), not flat stubs - ⚡ Sync + async generated from one model
- 📦 Self-contained output — the generated SDK depends only on
httpx+pydantic - 📑
stainful docs— emit a Stainless-styleapi.mdfrom the same inputs (per-resource sections, Methods lists with verb+path, Mintlify-compatible) - 🧰
stainful mcp— emit a Model Context Protocol server (one tool per HTTP method) so Claude / Cline / mcp-cli can call your API as tools
Quickstart
pip install stainful
# generate an idiomatic Python SDK from your OpenAPI spec + stainless.yml
stainful generate --spec openapi.yml --config stainless.yml --out ./sdk
# OR emit a Stainless-style api.md doc from the same inputs
stainful docs --spec openapi.yml --config stainless.yml --out ./api.md
# OR emit an MCP server inside the generated SDK
stainful mcp --spec openapi.yml --config stainless.yml --out ./sdk/<pkg>/mcp_server.py
The generated SDK feels like an official client:
from onebusaway import OnebusawaySDK
client = OnebusawaySDK(api_key="...") # or set ONEBUSAWAY_API_KEY
agency = client.agency.retrieve("1") # typed, retried, idiomatic
print(agency.data.entry.name)
Streaming, async, and typed errors work the way you'd expect:
import asyncio
from chat import AsyncChatSDK
from chat import RateLimitError
async def main():
client = AsyncChatSDK(api_key="...")
try:
stream = await client.chat.completions.create(
model="m", messages=[{"role": "user", "content": "hi"}], stream=True
)
async for chunk in stream:
print(chunk.delta, end="")
except RateLimitError as e:
print("rate limited:", e.request_id)
asyncio.run(main())
What you get vs. a mechanical generator
# typical OpenAPI generator # stainful
api = DefaultApi(ApiClient(cfg)) client = OnebusawaySDK()
resp = api.agency_agency_id_json_get(id) agency = client.agency.retrieve(id)
# loosely typed, no retries, no error classes, # typed model, retries, typed errors,
# you hand-write the pagination loop # auto-pagination, request id, async twin
How it works
The pipeline is shown above: an OpenAPI spec and stainless.yml are parsed,
resolved, and lowered into an intermediate representation, which the emitter
renders into a Python SDK over a vendored runtime.
The intermediate representation is a fully-resolved, language-agnostic model:
allOf is merged, oneOf becomes a real tagged union, and optionality is
three-valued. The emitter is a thin renderer over a hand-written runtime, so the
idiomatic behavior lives in audited code rather than per-endpoint templates.
How it compares
| OpenAPI Generator | Fern | Stainless | stainful | |
|---|---|---|---|---|
| Open source | ✅ | ✅ | — | ✅ |
| Runs fully locally, no account | ✅ | ✅ | — | ✅ |
Reads the stainless.yml format |
— | — | ✅ | ✅ |
| Idiomatic output (pagination, typed errors, streaming) | — | ✅ | ✅ | ✅ |
stainful's niche: idiomatic, fully-open, and a drop-in for the Stainless config you may already have. Different tools fit different teams — this one is for people who want that workflow without a hosted service.
Project layout
| Path | What |
|---|---|
src/stainful/config/ |
stainless.yml loader with precise, located diagnostics |
src/stainful/openapi/ |
OpenAPI 3.x loader + cycle-safe $ref / allOf resolver |
src/stainful/ir/ |
the intermediate representation |
src/stainful/emit/ |
the Python emitter |
src/stainful/runtime/ |
the hand-written runtime vendored into generated SDKs |
tests/fixtures/ |
conformance fixtures (chat / paginated / multipart / binary / webhooks / …) |
examples/onebusaway/ |
committed dogfood — regenerated SDK is bit-stable; CI guards it |
examples/openai/ |
real-world test: the public openai-openapi spec → mypy-clean SDK |
docs/ |
migration guide and other docs |
Status
v0.4.0. One stainless.yml → SDK + Mintlify-shaped api.md + MCP
server. Verified against the real Stainless-generated SDKs at pinned
SHAs in CI:
- OneBusAway: 29/29 (100%) of Stainless's own
OneBusAway/python-sdktest files import unchanged against stainful's output; generated SDK is mypy-clean; regeneration is byte-stable (the repo dogfoods itself). - OpenAI: the public
openai-openapispec (162 paths, 983 schemas) generates a mypy-clean SDK — seeexamples/openai/anddocs/migrating-from-stainless.mdfor what's verified and what's still on the gap list.
End-to-end behavioral conformance covers: cursor pagination (wire param
config-driven — ?after=<last_id> matches openai), anthropic-shape
bi-directional pagination (before_id ↔ after_id), SSE streaming with
@overload pairs, multipart / file upload, binary download
(audio/mpeg, octet-stream), raw binary request bodies (S3-style PUT),
typed webhook unwrap (Standard Webhooks scheme), rich APIResponse[T]
from with_raw_response.*, typed error-body models
(<pkg>.types.shared.ErrorObject auto-detected from the spec), spec-
specific page class symbols (SyncTokenPage/SyncNextCursorPage/…),
custom_casings, .to_json()/.to_dict() aliases, webhook
<BRAND>_WEBHOOK_SECRET env-var fallback.
118 tests, mypy 0 on 253 generated files, ruff clean, CI green on py3.10–3.12.
Known scope boundary: multi-content request bodies (one operation
declaring multiple requestBody.content types) still pick the first
match — no public Stainless oracle to verify the exact API surface.
Documented in the migration guide.
Roadmap: Python SDK → MCP server from the same model → a second language → docs site. One language done well first.
Contributing
PRs welcome — see CONTRIBUTING.md and the
Code of Conduct.
git clone https://github.com/stainlu/stainful && cd stainful
uv venv && uv pip install -e ".[dev,generated-runtime]"
uv run pytest -q
uv run ruff check src tests
# regenerate the dogfood SDK (the repo dogfoods itself; CI fails if a
# regeneration changes a byte — see examples/onebusaway/):
uv run stainful generate \
--spec examples/onebusaway/openapi.yml \
--config examples/onebusaway/stainless.yml \
--out examples/onebusaway/sdk
License
MIT. The vendored runtime ships inside generated SDKs under the same terms.
Metadata
Release files for stainful 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| stainful-0.6.0.tar.gz | 1.4 MB | Without provenance | Details for stainful-0.6.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| stainful-0.6.0-py3-none-any.whl | Python 3 | none | any | Details for stainful-0.6.0-py3-none-any.whl |
Total release size: 1.5 MB
Release files / stainful-0.6.0.tar.gz
| Download URL | stainful-0.6.0.tar.gz |
|---|---|
| Size | 1.4 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
900464e5db48d918889a83e8f8b81de0ff447596867f3b8d9e3e01940d1a01ff
|
|
BLAKE2b-256 checksum How to use checksums |
b58375fa44ef3131afa735cf73c9bdfc8c4cb5524a28e956330b8b801872bf53
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / stainful-0.6.0-py3-none-any.whl
| Download URL | stainful-0.6.0-py3-none-any.whl |
|---|---|
| Size | 105.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1ff785e4c36cdf196a27cb5422f3c037a1244876e5fd201f98bd45c2f0082f0b
|
|
BLAKE2b-256 checksum How to use checksums |
98a88d2ac2b103a75e0e55ca7adb13cf14a4607704bfb37a629d6b91edf7a116
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for stainful 0.6.0
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.