httpxgen
OpenAPI in. Typed async Python out.
httpxgen turns an OpenAPI 3 document into a readable, fully typed async
Python client built for httpx and
Pydantic. The generated source looks like code
you would write yourself: real method signatures, idiomatic names, explicit
request handling, and models your editor understands.
httpxgen openapi.yaml src/payments --package-name payments
There is no runtime framework, reflection, or hidden request machinery. Generate the client, check it into your repository, review it, debug it, and ship it.
Contents
- Why httpxgen?
- Generated package
- Install
- Quick start
- From OpenAPI to Python
- CLI reference
- Automation and CI
- Supported scope
- Development
- License
Why httpxgen?
| You get | Why it matters |
|---|---|
| Plain, generated Python | Read, review, and debug every request without learning a runtime abstraction |
| Precise async methods | operationId becomes an idiomatic method with real path, query, header, and body parameters |
| Pydantic 2 models | Validation, aliases, constraints, recursive types, allOf, and discriminated unions stay useful in Python |
| Native Python types | OpenAPI formats become types such as UUID, datetime, StrEnum, and Literal |
| Explicit error handling | Transport failures, invalid responses, and API errors are separate typed exceptions |
| Flexible transport | Use httpx.AsyncClient or any compatible async client through a structural protocol |
| Deterministic output | Commit generated files and use --check to catch stale clients in CI |
| Tag-based packages | Split a large API into focused clients while generating shared code only once |
httpxgen is a build-time tool. Your generated package depends on Pydantic for
its models, but it does not depend on httpxgen at runtime.
Generated package
A regular specification becomes a self-contained Python package:
openapi.json ──▶ httpxgen ──▶ payments/
├── __init__.py
├── client.py # the public client class
├── models.py
├── exceptions.py # generated client errors
├── http_methods.py # HttpMethods
├── serialization.py # parameter and auth helpers
├── transport.py # structural protocols and file types
└── py.typed
For larger specifications, repeat --tag to create focused subclients. Shared
models and support modules are generated once beside them:
openapi.json ──▶ httpxgen ──▶ api/
├── __init__.py # clients, ApiError, all models
├── shared/ # generated once
│ ├── exceptions.py
│ ├── http_methods.py
│ ├── models.py # only models used by both tags
│ ├── serialization.py
│ └── transport.py
├── payments/
│ ├── client.py
│ └── models.py # models only payments uses
├── invoices/
│ ├── client.py
│ └── models.py
└── py.typed
Install
Requires Python 3.12 or newer.
With uv:
uv tool install httpxgen # standalone CLI
uv add --dev httpxgen # dev dependency of your project
With pip:
pip install httpxgen
Or run it without installing anything:
uvx httpxgen openapi.json src/payments
Quick start
Generate a package from a local OpenAPI JSON or YAML document:
httpxgen openapi.yaml src/payments --package-name payments
Generated 8 file(s) in package src/payments.
import asyncio
import httpx
from payments import (
CardPaymentMethod,
ClientError,
CreateChargeRequest,
Money,
PaymentsClient,
)
async def main() -> None:
async with httpx.AsyncClient() as http_client:
client = PaymentsClient(
http_client,
"https://payments.example.com/api",
credentials={"bearerAuth": "your-token"},
)
page = await client.list_charges(status="succeeded", page_size=50)
for charge in page.items:
print(charge.id, charge.amount.amount_cents, charge.status)
try:
charge = await client.create_charge(
CreateChargeRequest(
amount=Money(amount_cents=4200, currency="EUR"),
payment_method=CardPaymentMethod(...),
)
)
except ClientError as error:
print(f"payment request failed: {error}")
asyncio.run(main())
The package root re-exports the client, models, and exceptions, so application imports stay short and stable.
From OpenAPI to Python
OpenAPI input
/charges:
get:
operationId: listCharges
parameters:
- name: status
in: query
schema: { $ref: "#/components/schemas/ChargeStatus" }
- name: cursor
in: query
schema: { type: string }
- name: page_size
in: query
schema: { type: integer, default: 25, minimum: 1, maximum: 200 }
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/ChargePage" }
Generated client
operationId becomes an idiomatic snake_case method, optional query parameters are only sent when set, and the response is validated into a model:
client.py holds nothing but the imports and the client class — the serialization
helpers live in serialization.py, the error type in exceptions.py:
class ListChargesParams(BaseModel):
status: ChargeStatus | None = None
cursor: str | None = None
page_size: int = Field(25, ge=1, le=200)
class PaymentsClient:
async def list_charges(
self,
status: ChargeStatus | None = None,
cursor: str | None = None,
page_size: int = 25,
*,
timeout: float | None = None,
) -> ChargePage:
path = "/charges"
params = ListChargesParams(
status=status,
cursor=cursor,
page_size=page_size,
)
query: list[tuple[str, str]] = []
if params.status is not None:
query.extend(serialize_query("status", params.status))
if params.cursor is not None:
query.extend(serialize_query("cursor", params.cursor))
query.extend(serialize_query("page_size", params.page_size))
headers = dict(self._headers)
headers.setdefault("Accept", "application/json")
apply_security(self._credentials, [("bearerAuth",)], headers, query, {})
try:
response = await self._client.request(
method=HttpMethods.GET,
url=f"{self._base_url}{path}",
params=query,
headers=headers,
timeout=self._timeout if timeout is None else timeout,
)
except Exception as error:
raise TransportError(error) from error
try:
if response.status_code == 200:
return ChargePage.model_validate(response.json())
raise ApiError(response.status_code, response.text, response=response)
except ClientError:
raise
except Exception as error:
raise ResponseValidationError(
response.status_code, response, error
) from error
Path parameters carry their spec format — format: uuid becomes UUID, not str:
async def get_customer(
self,
customer_id: UUID,
*,
timeout: float | None = None,
) -> Customer:
path = "/customers/{customerId}"
path = path.replace("{customerId}", serialize_path("customerId", customer_id))
headers = dict(self._headers)
headers.setdefault("Accept", "application/json")
response = await self._client.request(
method=HttpMethods.GET,
url=f"{self._base_url}{path}",
headers=headers,
timeout=self._timeout if timeout is None else timeout,
)
if response.status_code == 200:
return Customer.model_validate(response.json())
if response.status_code == 404:
detail = ApiErrorModel.model_validate(response.json())
raise ApiError(response.status_code, response.text, detail, response)
raise ApiError(response.status_code, response.text, response=response)
Request bodies are a single typed body argument, serialized by alias and without None noise:
async def create_charge(
self,
body: CreateChargeRequest,
*,
timeout: float | None = None,
) -> Charge:
...
json_body = TypeAdapter(CreateChargeRequest).dump_python(
body, mode="json", by_alias=True, exclude_none=True
)
...
The same direct shape is used for JSON, form, text, and small binary bodies.
An application/octet-stream body accepts bytes, Iterable[bytes], or
AsyncIterable[bytes] and is passed directly as content=.
Multipart fields are flattened into method arguments. Binary arrays become
repeated parts with the same wire name, while UploadFile carries optional
per-part metadata without reading an open file eagerly:
await client.upload_documents(
documents=[
UploadFile(open("a.pdf", "rb"), filename="a.pdf"),
UploadFile(open("b.pdf", "rb"), filename="b.pdf"),
],
category="contracts",
)
Multipart boundaries remain under the HTTP transport's control. This replaces
the old Pydantic multipart body argument; regenerate and update multipart
call sites to pass fields directly.
Successful binary responses also get a streaming context-manager variant. The buffered method remains available for small responses:
content = await client.download_recording(recording_id)
async with client.stream_download_recording(recording_id) as recording:
async for chunk in recording.iter_bytes(chunk_size=64 * 1024):
await destination.write(chunk)
print(recording.media_type, recording.content_length, recording.filename)
The streaming context owns the individual response only. The generated wrapper treats its injected HTTP client as borrowed, so closing the wrapper does not close a shared application transport:
http_client = httpx.AsyncClient()
async with PaymentsClient(
http_client,
"https://payments.example.com/api",
credentials={"bearerAuth": "your-token"},
) as client:
...
await http_client.aclose()
All operation failures derive from ClientError. TransportError retains its
original cause, ResponseValidationError retains both the response and cause,
and ApiError exposes a parsed detail plus convenient code and message
properties. body and parsed_body remain compatibility aliases for
raw_body and detail.
Generated models
allOf becomes inheritance, oneOf + discriminator becomes a Pydantic discriminated union, string enums become StrEnum, and minimum / maxLength survive as Field(...) constraints:
class ChargeStatus(StrEnum):
PENDING = "pending"
SUCCEEDED = "succeeded"
FAILED = "failed"
REFUNDED = "refunded"
class Money(BaseModel):
amount_cents: int
currency: str = Field(min_length=3, max_length=3)
class CardPaymentMethod(BaseModel):
type: Literal[PaymentMethodType.CARD]
card_number: str
exp_month: int = Field(ge=1, le=12)
exp_year: int
billing_address: Address | None = None
PaymentMethod = Annotated[
CardPaymentMethod | BankTransferPaymentMethod,
Field(discriminator="type"),
]
class BaseEntity(BaseModel):
id: str
created_at: datetime
class Charge(BaseEntity): # allOf: BaseEntity + own properties
amount: Money
status: ChargeStatus
payment_method: PaymentMethod # discriminated at parse time
metadata: dict[str, str] | None = None
Everything is re-exported from the package root, so consumers import from one place:
from payments import ApiError, Charge, ChargeStatus, Money, PaymentsClient
CLI reference
httpxgen OPENAPI OUTPUT [--package-name NAME] [--client-name NAME] [--http-client {protocol,httpx,httpx2}] [--tag TAG] [--schema-tag TAG] [--check]
| Argument | Meaning |
|---|---|
OPENAPI |
OpenAPI JSON or YAML file |
OUTPUT |
target package directory, or the root holding one package per tag when several --tag are given (created if missing) |
--package-name |
complete generated import package; defaults to the output directory name |
--client-name |
exact single-client class name; not available for multi-tag workspaces |
--http-client |
HTTP client annotation: protocol supports both implementations (default), or bind generated code directly to httpx or httpx2 |
--tag TAG |
generate only operations carrying this tag; repeat it for one package per tag |
--schema-tag TAG |
keep schemas referenced by this tag without generating its operations; repeatable |
--check |
write nothing; exit non-zero when the checked-in output is stale |
Carve a focused client out of a large spec:
httpxgen openapi.json src/billing \
--package-name billing \
--tag charges \
--schema-tag webhooks
For a single client, the default class name comes from the final package
segment: generated.browser_worker produces BrowserWorkerClient. Override it
verbatim when the API needs a different public identity:
httpxgen openapi.json src/generated/browser_worker \
--package-name generated.browser_worker \
--client-name ProvisionerClient \
--http-client httpx2
Keep the default protocol when the same generated package should accept both
httpx.AsyncClient and httpx2.AsyncClient without a runtime dependency on
either implementation.
Repeat --tag and you get one client package per tag. The exception hierarchy,
transport types, HttpMethods, and serialization helpers are generated once in
shared/, and every model lands in
the package that uses it — shared/models.py holds only what more than one tag
references. Generated modules import each other absolutely, so the output reads
the same wherever you open it:
httpxgen specs/api.yml src/api --package-name api --tag payments --tag invoices
# src/api/invoices/client.py
from api.invoices.models import CreateInvoiceRequest, Invoice, InvoicePage
from api.shared import AsyncHttpClient, ClientError, HttpMethods, serialize_path
from api.shared.models import ApiErrorModel
from api import ClientError, InvoicesClient, Money, PaymentsClient
Every managed file starts with a # Generated by httpxgen. DO NOT EDIT. header. Files without it are never overwritten — httpxgen aborts instead.
Automation and CI
Generated code is checked in, so a tiny script is usually all the automation you need.
scripts/generate-client.sh:
#!/usr/bin/env sh
set -eu
SPEC_URL="https://payments.example.com/api/openapi.json"
OUT="src/payments"
curl -fsSL "$SPEC_URL" -o openapi.json
uvx httpxgen openapi.json "$OUT" --package-name payments
echo "client regenerated in $OUT"
chmod +x scripts/generate-client.sh
./scripts/generate-client.sh
Use the --check variant in CI so a drifting spec fails the build instead of surprising you at runtime:
# .github/workflows/client.yml
- name: Verify generated client is current
run: |
curl -fsSL "$SPEC_URL" -o openapi.json
uvx httpxgen openapi.json src/payments --package-name payments --check
Generated HTTP client is current.
Or wire it into a Makefile:
.PHONY: client client-check
client:
uvx httpxgen openapi.json src/payments --package-name payments
client-check:
uvx httpxgen openapi.json src/payments --package-name payments --check
Supported scope
httpxgen targets ordinary OpenAPI 3.0 and 3.1 client specifications, not every
JSON Schema feature. It supports JSON/YAML input, local component references,
path/query/header/cookie serialization, JSON/form/multipart/binary request
bodies, JSON/text/binary responses, numeric/default/status-range responses,
typed error bodies, common
security schemes, directional request/response models, inline and recursive
Pydantic models, enums, nullable values, discriminated unions, and practical
allOf inheritance.
Unsupported constructs fail generation where possible. Important remaining limitations are external references, callbacks/webhooks, automatic pagination, and a synchronous client.
Development
uv sync --all-groups
uv run pytest
uv run ruff check . # lint
uv run ruff format . # format
preview/ is generated output, checked in so the effect of a change is visible
in review. Regenerate it whenever generated code changes — CI fails if it is
stale:
./scripts/generate-preview.sh
License
MIT
Metadata
Release files for httpxgen 0.2.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 |
|---|---|---|---|---|
| httpxgen-0.2.0.tar.gz | 113.9 kB | Without provenance | Details for httpxgen-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| httpxgen-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for httpxgen-0.2.0-py3-none-any.whl |
Total release size: 166.9 kB
Release files / httpxgen-0.2.0.tar.gz
| Download URL | httpxgen-0.2.0.tar.gz |
|---|---|
| Size | 113.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
335b0e5b32c440fd70a2298a336b0c4e6f046ac5904be5f25e53fc282c2e151d
|
|
BLAKE2b-256 checksum How to use checksums |
d021fd08e60ffafbeb123f383494eb2b3cdeb3b6594e3195ffaf58fb3af54243
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.2
|
Release files / httpxgen-0.2.0-py3-none-any.whl
| Download URL | httpxgen-0.2.0-py3-none-any.whl |
|---|---|
| Size | 53.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
becf2371f09945bd8f8189b858588f9b5969a424754305896083cfb1b7215390
|
|
BLAKE2b-256 checksum How to use checksums |
5a9ea3b5d48a4abf356c73bd245d552261c8129a0324262da83ad34d5f02b133
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.2
|
Security for httpxgen 0.2.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.