benzene-codegen-client — generated clients from a Contract Document
Generates a typed, topic-scoped Python client SDK from a Benzene Contract Document
({Service}.spec.json) — the language-neutral file any Benzene service (.NET, Go, TypeScript, or
Python) derives from its handler registry and serves at /benzene/spec
(contract-document.md).
A Python consumer of any Benzene service gets a typed client from that one file — no .NET SDK, no
hand-written DTOs, no manual topic-string typos.
Distribution: benzene-codegen-client (depends on benzene-core; a build-time/CLI dependency,
not a runtime one).
pip install benzene-codegen-client
This is a code generator, not a library your service imports at runtime. The code it emits
depends on nothing but benzene.core.clients.MessageSender and benzene.results.Result — the
generator package itself (its OpenAPI-schema walking) never ships inside a generated client or a
deployed service. The contract hash it embeds is computed by benzene-core's contract_hash — the
same function a service uses for the contractHash it serves.
Quick start
benzene-codegen service --spec payments.spec.json --service Payments --out payments_client.py
benzene-codegen topic --spec payments.spec.json --topic payments:capture --out payments_capture_client.py
from benzene.core import MessageSender
from payments_client import CapturePayment, create_payments_client
def configure_services(services, config):
services.try_add_singleton(
PaymentsClient,
lambda scope: create_payments_client(scope.get_service(MessageSender)),
)
# elsewhere:
result = await payments_client.capture_payments(CapturePayment(order_id="o1", amount=42.42, currency="GBP"))
if result.is_successful:
payment = result.payload # a PaymentDto
Wiring in this port is import-driven, not reflective — import the generated module and register its factory in your composition root, as above. There is no scan that finds a generated client on its own.
The two output shapes
| Command | Shape | components.schemas |
events[] |
Use when |
|---|---|---|---|---|
benzene-codegen service |
One class, one method per in-scope topic | Whole document's catalogue (unnarrowed) | Passed through, unused | You call several topics on one service |
benzene-codegen topic |
One self-contained module for exactly one topic | Narrowed to just that topic's reachable schemas (the schema closure, spec §5.3) | Dropped — a single request/response topic carries no events | You depend on one topic and want the smallest possible, most decoupled client |
Both shapes are pinned by the spec (contract-document.md §5): which topics are in scope, which schemas a topic-client narrows down to, and the embedded contract hash are not this port's choice to make differently from any other language's generator. Method naming and file layout are this port's choice (§5.5) — see Naming below.
Reading the document
The generator reads a Contract Document whose schemaDialect is "openapi-3.0" and rejects any
other — including a document with no schemaDialect, the openapi/info shape producers wrote
before spec-0.3.0 (contract-document §1). It ignores any benzene: entry (a producer must not list
one), reads requests, events and sends, and treats an absent response as "no payload": the
generated method returns Result[None]. A nullable wrapper {"type", "nullable": true, "allOf": [{"$ref": T}]} types as T | None.
Topic scoping
By default, service covers every requests[] topic in the document.
--topics a,b,c— an explicit include-list (§5.2). Only these topics are in scope;events,sendsandcomponentspass through unnarrowed.- Naming a topic that isn't in the document is a fail-loud error (non-zero exit), listing both the topic(s) that weren't found and the document's actual topics.
benzene-codegen topic --topic <t> generates the topic-scoped client of §5.3: that one entry, no
events or sends, and the component closure its request and response reach.
Types
Every components.schemas entry becomes a stdlib @dataclass — not a pydantic model, so a
generated client adds no runtime dependency (pydantic appears in this port only as the optional
benzene-pydantic validation adapter). Generated dataclasses are meant to be used with the
existing benzene.core.mapping serialization idiom (to_jsonable/to_request), which already
camelCases on write and case-/separator-folds on read — so a generated dataclass needs no
per-field metadata to round-trip against a .NET/Go/TypeScript peer.
- Every field gets a default (
""/0/0.0/False/an empty collection/None), regardless of the schema'srequiredlist. This is a deliberate simplification: Python doesn't enforce "required" on a dataclass at runtime any more than it enforces the wire contract does (that'sto_request's job, not the type's), and giving every field a default means anallOf-composed subclass never runs into dataclass's required-before-optional field-ordering rule, however deep the inheritance. allOf(one$refbranch as a base, inline branches as own properties) becomes real Python inheritance — a subclass of the referenced dataclass.- A bare
oneOfwith no properties of its own (a pure union) becomes aUnion[...]type alias, not a dataclass — it has no fields to hold. AoneOfunion-member type site elsewhere is typed the same way. formatis not consulted (date-time,uuid,int64, …) — only the schema's declaredtype.benzene.core.mappingwritesdatetime/date/UUID/Decimalvalues in their standard JSON forms, butto_requestdoes not parse them back (a generated dataclass field would receive the wire string), so typing a fieldUUIDwould claim a type it never holds. Ifto_requestgrows that parsing, the type builder can follow. Seetypes.py's module docstring for the full reasoning (including why this is not the same divergence as the .NET reference'sformatheuristics).
Naming
Method naming and file layout are explicitly out of the spec's conformance scope (contract-document.md §5.5) — every language port picks its own idiom. This port's default:
- Service-client method name — reversed topic segments,
snake_case:payments:capture→capture_payments(translates the .NET reference'sTopicReversedMethodName, which Pascal-cases and concatenates instead). - Topic-client class/module identifier — segments in original order,
snake_case:payments:capture→payments_capture(translatesTopicMethodName). - Schema name → class name —
PascalCase(CSharpNameFormatter's direct analog). Namespace/module option — .NET'sClientSdkOptions.Namespace(used exactly, no magic suffix) has no Python equivalent to name inside generated source (Python has no C#-style namespace): the idiomatic translation is that--out <path>is used exactly as given, with no suffix appended — wherever you put the file is its import path.
RequiredTopics and the contract hash
Every generated module exports two module-level constants:
REQUIRED_TOPICS: tuple[str, ...]— every topic the client's method(s) call. There's no reflective startup-validation hook in this port to wire this into automatically (unlike .NET's[OutboundRoutingContract]) — a service that wants to fail loud at boot on a missing outbound route reads this constant itself in its own composition root. Imperatively-wired ports failing loud at construction, rather than at a reflective startup scan, is the accepted answer here.CONTRACT_HASH— the spec-pinnedcontractHash(contract-document.md §6):"sha256:" + lowercase-hex(sha256(JCS(P)))over the projectionPof the client's own scope (requestsandevents, dereferenced, annotations stripped, sorted), recomputed bybenzene-core'scontract_hashrather than copied. A default service-level client's hash equals the servedcontractHash— that is the drift check. An include-list client's or a topic client's hash is comparable only with the same projection recomputed over the provider's current document (§6.4).
Registration (no DI container to hook into)
This port has no reflective DI container convention the way .NET's
Add{Service}ServiceClient() extension has one — benzene.core.dependencies.Container is a
hand-rolled, composition-root-style container (add_singleton/try_add_singleton), not a
reflection-scanned one. The idiomatic equivalent this generator emits is a plain factory
function, create_{name}_client(sender), that a caller wires into its own
BenzeneStartUp.configure_services exactly like any other collaborator:
services.try_add_singleton(
PaymentsClient, lambda scope: create_payments_client(scope.get_service(MessageSender))
)
Regenerating as part of your build (Phase 6)
There's no universal Python build hook (no dotnet build-style MSBuild target every Python project
shares) — regeneration is a script you run and commit the output of, gated by CI re-checking it's
clean. This repo's own dogfood example (examples/orders_payments_client/) is the pattern:
# examples/orders_payments_client/generate.py
from benzene.codegen_client import generate_topic_client, parse_document
document = parse_document(json.loads(spec_path.read_text()))
generated = generate_topic_client(document, topic="payments:capture")
out_path.write_text(generated.source)
python examples/orders_payments_client/generate.py
git diff --exit-code -- examples/orders_payments_client/generated # CI fails if regeneration drifted
Adopt the same two-line shape in a make regenerate / nox session / hatch script in your own
service, and add the matching git diff --exit-code line as a required CI check — that's the
"regeneration stays clean" gate this port offers, honestly, with no framework magic behind it.
Conformance
conformance/contract-document-cases.json and conformance/contract-hash-cases.json (vendored from
the spec repo,
pinned at conformance/SPEC_VERSION) are run by
tests/conformance_runner.py's run_contract_document_cases() / run_contract_hash_cases(), and
surfaced individually under pytest by tests/test_conformance.py. Passing them is what "client-
generation conformant" means for this port (contract-document.md §7).
See also
- Packages & adoption levels — where this sits in the stack (a build-time-only leaf, nothing depends on it).
examples/orders_payments_client/— the dogfood example: a real, .NET-producedpayments.spec.json, a generated topic-client forpayments:capture, wired toFakeMessageSenderand tested end to end.- contract-document.md — the normative spec this generator implements.