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.

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.

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:

RequiredTopics and the contract hash

Every generated module exports two module-level constants:

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