Skip to content

Commit 6464a92

Browse files
feat(py-client): add protoc-gen-py-client (Python HTTP client generator) (#172)
* feat(py-client): scaffold protoc-gen-py-client plugin Stand up cmd/protoc-gen-py-client and internal/pyclientgen mirroring the tsclientgen layout: one generated _client.py per .proto source, stdlib-only output, dataclasses + IntEnum + Protocol-typed transport. Field rendering, JSON-mapping annotations, and RPC method bodies land in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(py-client): render message dataclasses with JSON-mapping annotations Replace the message scaffold with full @DataClass field rendering plus to_dict / from_dict serialization. Honors int64_encoding, enum_encoding + enum_value, bytes_encoding, timestamp_format, nullable, empty_behavior, unwrap (root + map-value), flatten + flatten_prefix, and oneof discriminator configurations. Adds a Python type-mapping helper module and JSON encode / decode expression builders so each field collapses to one or two lines in the generated to_dict / from_dict. Enum decoding emits a per-enum helper that accepts string (proto name or custom enum_value) or int wire forms and raises on unknown values. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(py-client): implement RPC methods, options, and typed error hierarchy Flesh out the client class with real request/response handling: - path parameter substitution via urllib.parse.quote - query parameter encoding via urlencode(doseq=True), with proper guards for string/bool/numeric/repeated fields - header building from default + per-call + typed service/method header options generated from sebuf.http.service_headers and method_headers annotations - transport invocation through the injectable HttpTransport protocol with per-call timeout fallback - response parsing using each message's generated from_dict - content-type negotiation surface (JSON implemented, proto raises NotImplementedError until a follow-up adds binary protobuf encoding) - SSE streaming methods detected via HttpConfig.stream and emit NotImplementedError pointing at the follow-up issue Replace the error stub with full per-*Error-message exception classes. Each class subclasses ApiError, exposes proto fields as constructor kwargs, and ships a populate() classmethod that builds an instance from a parsed JSON dict. An _ERROR_CLASSES registry indexed by required JSON key set lets the client's _raise_for_status pick the most specific exception for a response. Add a constants.go module with Python type names and well-known type proto-name constants to satisfy goconst, and tighten every switch with the appropriate nolint:exhaustive pragmas where the default branch is intentional. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(py-client): add Phase 2 handoff doc for testing and docs work Generator implementation is complete on this branch (3 commits). This doc hands off the remaining test, demo, docs, and PR-opening work to the next agent. Includes file-by-file pointers to the patterns to mirror, the lint command tuned for go.mod 1.26, a note about the pre-existing openapiv3 test failure on main, and the rationale for not cherry-picking from PR #132. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(py-client): add golden tests + fix WKT/empty-set/pass generator bugs Adds 15 per-feature test protos mirroring tsclientgen's testdata (plus a new errors.proto exercising the per-*Error exception class generation that is unique to py-client) and a golden test harness that also runs `python3 -c "import ast; ast.parse(...)"` on each generated file to catch syntactic regressions a string-compare cannot. Capturing the goldens surfaced four generator bugs, all fixed here: - error.go: empty set literal was emitted as `{}`, which is an empty dict — violated the registry's `set[str]` type and would have crashed if the runtime guard ever fell through. - message.go: empty messages emitted `pass` followed by methods, which is semantically incorrect and noisy. The methods alone keep the class body non-empty. - types.go: Timestamp WKT fields with unix-seconds / unix-millis / date formats were typed as `int` / `str`, but encoding.go always calls `.timestamp()` / `.strftime()`, assuming `datetime`. Aligned on `datetime` for every timestamp_format — the format only affects the wire encoding, not the user-facing type. - message.go: WKT message-kind fields (Timestamp, Duration, FieldMask, Any, Empty, Struct, scalar wrappers) routed through the scalar to_dict path were emitted unconditionally, even though they are always nullable in proto3. Guard them like proto3 `optional` scalars so the encoder never sees a `None` default and raises AttributeError. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(py-client): add helper unit tests + extract repeated string constants Covers the pure helpers that the golden tests exercise only indirectly: - snakeCase: CamelCase → snake_case method-name conversion - headerOptionName: HTTP header → Python kwarg, with keyword-collision escape (X-Class → class_) - escapePyKeyword: hard + soft Python 3.10 keywords - formatPyStringSet: empty input emits set(), not the dict-literal {} - stripOptional, camelToSnake, isInvalidIdentifier: small string utils Also lifts three repeated literals into constants (pyFalse, pyEmptySet, pyListStr) so goconst is happy and there is one place to change the emitted Python idiom for each. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(py-client): preserve original proto enum value names for wire parity The generator was stripping the enum-name prefix and lowercasing each variant ("PRIORITY_HIGH" -> "high"). That was an ergonomic improvement on paper but broke cross-generator wire compatibility: the Go server emits enums via protojson default ("PRIORITY_HIGH"), while _encode_enum_X falls back to IntEnum.name, which the renaming had turned into "high". A Python client talking to a Go server was always going to misparse enum-typed fields. Keep the proto value name verbatim ("PRIORITY_HIGH") so .name and the wire format agree. Users write Priority.PRIORITY_HIGH which is also PEP 8-conformant (UPPER_CASE for enum members). Removes the now-orphaned camelToSnake/isInvalidIdentifier helpers and their unit tests. Verified end-to-end against the python-client-demo Go server: enums round-trip correctly across CRUD, query filtering, and the unwrap response path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(py-client): add examples/python-client-demo end-to-end demo Mirrors examples/ts-client-demo section-by-section so a reader can compare the two client surfaces directly. Shares the proto + Go HTTP server with the TS demo (NoteService — CRUD over Notes with enums, maps, optional fields, headers, query params, validation, unwrap response, and a typed NotFoundError). The Python client demonstrates: - Section 1: NoteServiceClientOptions with typed kwargs for service headers (api_key, tenant_id) and a default_headers escape hatch - Section 2: every HTTP verb (GET/POST/PUT/PATCH/DELETE) with path params, request bodies, and method-level headers via call options - Section 3: query parameter encoding for ListNotes (status/priority/sort/limit/offset) - Section 4: header layering (service options vs call options vs per-call headers dict, and per-call override of a service header) - Section 5: ValidationError parsing on min_len / max_len / missing required header — same buf.validate rules as the TS demo - Section 6: typed NotFoundError exception subclass (not a generic ApiError) chosen by the _ERROR_CLASSES registry from response shape - Section 7: custom HttpTransport injection (logging middleware) and the unwrap response path (NoteList.notes flattens on the wire) Verified end-to-end against the Go server: `make demo` runs the full suite cleanly with no failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(py-client): add docs/python-generation.md, register plugin in README + CLAUDE.md Adds the dedicated Python client reference and wires protoc-gen-py-client into the toolkit overview in README.md and CLAUDE.md (now six plugins, not five). docs/python-generation.md covers: generator output (dataclasses, IntEnum, transport Protocol, error hierarchy, options, client class), transport injection, URL building (path + query params), header layering, ApiError/ValidationError/typed *Error exceptions, every JSON-mapping annotation (with focus on Timestamp/int64/bytes/oneof — same wire format as the Go and TS generators), Python keyword escaping, the SSE NotImplementedError stub, known limitations, and a link to examples/python-client-demo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: remove Python client handoff docs Both planning docs were time-limited handoffs between agents working on this branch (PYTHON_CLIENT_REWRITE.md → the rewrite plan after PR #132 was closed; PY_CLIENT_HANDOFF.md → the Phase 2 testing/docs/PR handoff). The work they tracked is now landed on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(py-client): emit enums before error classes to avoid NameError When an *Error message has an enum-typed field, the generated default expression — code: Reason = Reason.X — is evaluated at class-definition time, so the enum class must already be declared. The previous file ordering emitted writeErrors before the enum loop, raising NameError at import time for any error that referenced an enum. Reorder the file so enums are written before writeErrors. Messages already trailed both blocks and don't need adjustment — message-typed defaults are always None, so forward references in them are safe. Add a regression case to testdata/proto/errors.proto (EventError + RejectionReason) matching the exact shape @yashagarwal-sarwa reported on #172, and upgrade the golden test to actually execute each generated file via importlib (ast.parse only checks syntax, not runtime NameErrors). The new import check also registers the module in sys.modules so @DataClass machinery can resolve string annotations from `from __future__ import annotations`. Reported-by: @yashagarwal-sarwa Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(py-client): add from_dict() to generated *Error classes When an *Error message is embedded as a field on another message, the parent's generated from_dict calls EventError.from_dict(...) — but error classes only had populate() and to_dict(), so the call raised AttributeError at runtime. Add a from_dict classmethod on every *Error class that delegates to populate() with neutral status/body/headers. This keeps the error class shape interchangeable with regular messages for serialization purposes, which is what the parent message's deserializer assumes. Extend errors.proto with EventResult { EventError error } as a regression case matching the exact shape @yashagarwal-sarwa reported on #172. The import test (added in 0166439) catches the AttributeError on the next regen attempt. Reported-by: @yashagarwal-sarwa Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(py-client): TIMESTAMP_FORMAT_DATE decode, flatten wire names, root-map unwrap Three generator bugs surfaced by the new examples/python-encoding-demo end-to-end round-trip against a real Go server: 1. decodeTimestampExpr for TIMESTAMP_FORMAT_DATE returned the raw "YYYY-MM-DD" string instead of a datetime, even though the field type is datetime. Now parses with datetime.strptime so the assigned value matches the declared annotation. 2. The Python flatten encoder iterated nested.to_dict().items() and prefix-tagged each key — which used JSON names (camelCase). The Go HTTP plugin's flatten encoder uses proto names (snake_case), so the Python side emitted `author_zipCode` while the server emits `author_zip_code`, breaking round-trips. Rewritten to emit one wire key per nested field using the field's proto name, with the matching decoder reading those keys directly. Encoder + decoder now agree with the Go server byte for byte. 3. annotations.FindUnwrapField is documented as a list-only helper, but py-client's root-unwrap codepaths called it for messages whose unwrap field is a map. That silently produced empty `to_dict() -> {}` / `from_dict() -> cls()` on every root-map unwrap message. Added a local findRootUnwrapField that doesn't filter on IsList(); kept the shared helper unchanged so other generators stay untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(py-client): add python-encoding-demo + python-errors-demo Two new end-to-end examples that round-trip every protoc-gen-py-client feature (except SSE, tracked as #167) against a real Go server. Each example follows the established repo pattern — single focus, Go server + Python client + `make demo` target. examples/python-encoding-demo (51 assertions) Round-trips every JSON-mapping annotation: enum_value override, timestamp_format (RFC3339/UNIX_S/UNIX_MS/DATE), int64_encoding STRING+NUMBER, bytes_encoding base64+HEX, flatten+flatten_prefix, oneof_config nested + flattened variants, all three unwrap variants (root repeated, root map, map-value), Python keyword field-name escaping (`from`/`class`/`return`), and repeated query parameters. Each annotation lives on its own message because the Go HTTP plugin emits one MarshalJSON method per (message, annotation) and would produce duplicate methods otherwise. Writing this demo surfaced three real generator bugs that were invisible to the golden tests (they pass ast.parse and import-time exec but never check wire-format compatibility with the Go side). Fixes shipped in the preceding commit. examples/python-errors-demo (41 assertions) Covers every error surface: ValidationError parsed from a buf.validate body, registry-based disambiguation across NotFoundError / ConflictError / RateLimitError, and an *Error embedded as a field on a regular response (the BatchCreateItemResult pattern from @yashagarwal-sarwa's #172 — exercises the FieldValidationError. from_dict alias that lives alongside populate()). CLAUDE.md updated to list both examples in the project-structure section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1b08604 commit 6464a92

73 files changed

Lines changed: 14433 additions & 6 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## Project Overview
66

7-
This is `sebuf`, a specialized Go protobuf toolkit for building HTTP APIs. It consists of five complementary protoc plugins that together enable modern, type-safe API development:
7+
This is `sebuf`, a specialized Go protobuf toolkit for building HTTP APIs. It consists of six complementary protoc plugins that together enable modern, type-safe API development:
88

99
- **`protoc-gen-go-http`**: Generates HTTP handlers, routing, request/response binding, and automatic validation
1010
- **`protoc-gen-go-client`**: Generates type-safe Go HTTP clients with functional options pattern
1111
- **`protoc-gen-ts-client`**: Generates TypeScript HTTP clients with full type safety, header helpers, and error handling
1212
- **`protoc-gen-ts-server`**: Generates TypeScript HTTP server handlers using the Web Fetch API (Request/Response), framework-agnostic
13+
- **`protoc-gen-py-client`**: Generates Python HTTP clients (Python 3.10+) with type-safe dataclasses, header helpers, custom-transport injection, and typed proto-error exceptions — stdlib only
1314
- **`protoc-gen-openapiv3`**: Creates comprehensive OpenAPI v3.1 specifications
1415

1516
The toolkit enables developers to build HTTP APIs directly from protobuf definitions without gRPC dependencies, targeting web and mobile API development with built-in request validation.
@@ -23,12 +24,14 @@ The project follows a clean Go protoc plugin architecture with separated concern
2324
- **cmd/protoc-gen-go-client/**: Go HTTP client generator entry point
2425
- **cmd/protoc-gen-ts-client/**: TypeScript HTTP client generator entry point
2526
- **cmd/protoc-gen-ts-server/**: TypeScript HTTP server generator entry point
27+
- **cmd/protoc-gen-py-client/**: Python HTTP client generator entry point
2628
- **cmd/protoc-gen-openapiv3/**: OpenAPI specification generator entry point
2729
- **internal/httpgen/**: HTTP handler generation logic, annotations, and header validation middleware
2830
- **internal/clientgen/**: Go HTTP client generation logic and annotations
2931
- **internal/tscommon/**: Shared TypeScript type mapping and generation (used by ts-client and ts-server)
3032
- **internal/tsclientgen/**: TypeScript HTTP client generation logic
3133
- **internal/tsservergen/**: TypeScript HTTP server generation logic, header validation, route creation
34+
- **internal/pyclientgen/**: Python HTTP client generation logic (dataclasses, IntEnums, transport Protocol, typed *Error exceptions)
3235
- **internal/openapiv3/**: OpenAPI generation logic, type mapping, and header parameter generation
3336
- **proto/sebuf/http/**: HTTP annotation definitions including headers.proto for header validation
3437
- **scripts/**: Test automation and build scripts
@@ -39,9 +42,10 @@ The project follows a clean Go protoc plugin architecture with separated concern
3942
2. **Go HTTP Client Generator** (`internal/clientgen/generator.go:13`): Generates type-safe Go HTTP clients with functional options pattern, automatic request/response marshaling, and error handling
4043
3. **TypeScript HTTP Client Generator** (`internal/tsclientgen/generator.go`): Generates TypeScript HTTP clients with typed interfaces, service/method header helpers, query parameter encoding, path parameter substitution, and structured error handling (ValidationError/ApiError)
4144
4. **TypeScript HTTP Server Generator** (`internal/tsservergen/generator.go`): Generates framework-agnostic TypeScript HTTP server handlers using the Web Fetch API (`Request``Promise<Response>`), with route descriptors, header validation, query/body parsing, and error handling
42-
5. **OpenAPI Generator** (`internal/openapiv3/generator.go:53`): Creates comprehensive OpenAPI v3.1 specifications from protobuf definitions with full header parameter support, generating one file per service for better organization
43-
6. **Shared TypeScript Types** (`internal/tscommon/`): Shared TypeScript type mapping, interface generation, error types, and proto-defined error message collection (messages ending with "Error") used by both ts-client and ts-server generators
44-
7. **HTTP Annotations** (`proto/sebuf/http/annotations.proto`): Custom protobuf extensions for HTTP configuration
45+
5. **Python HTTP Client Generator** (`internal/pyclientgen/generator.go`): Generates Python HTTP clients with @dataclass messages, IntEnum enums, a duck-typed HttpTransport Protocol (UrllibTransport default), typed client/call options, and a per-`*Error`-message exception class hierarchy. Stdlib-only; Python 3.10+
46+
6. **OpenAPI Generator** (`internal/openapiv3/generator.go:53`): Creates comprehensive OpenAPI v3.1 specifications from protobuf definitions with full header parameter support, generating one file per service for better organization
47+
7. **Shared TypeScript Types** (`internal/tscommon/`): Shared TypeScript type mapping, interface generation, error types, and proto-defined error message collection (messages ending with "Error") used by both ts-client and ts-server generators
48+
8. **HTTP Annotations** (`proto/sebuf/http/annotations.proto`): Custom protobuf extensions for HTTP configuration
4549
5. **Header Validation** (`proto/sebuf/http/headers.proto`): Protobuf definitions for service and method-level header validation
4650
6. **Validation System**: Automatic request body validation via buf.validate/protovalidate and header validation middleware
4751

@@ -765,15 +769,20 @@ The repository contains:
765769
- **cmd/protoc-gen-go-client/**: Go HTTP client plugin entry point
766770
- **cmd/protoc-gen-ts-client/**: TypeScript HTTP client plugin entry point
767771
- **cmd/protoc-gen-ts-server/**: TypeScript HTTP server plugin entry point
772+
- **cmd/protoc-gen-py-client/**: Python HTTP client plugin entry point
768773
- **cmd/protoc-gen-openapiv3/**: OpenAPI generation plugin entry point
769-
- **internal/annotations/**: Shared annotation parsing used by all 5 generators (unwrap, query params, headers, JSON mapping)
774+
- **internal/annotations/**: Shared annotation parsing used by all 6 generators (unwrap, query params, headers, JSON mapping)
770775
- **internal/httpgen/**: HTTP handler generation logic and tests
771776
- **internal/clientgen/**: Go HTTP client generation logic and tests
772777
- **internal/tscommon/**: Shared TypeScript type mapping and generation (interfaces, enums, error types)
773778
- **internal/tsclientgen/**: TypeScript HTTP client generation logic and tests
774779
- **internal/tsservergen/**: TypeScript HTTP server generation logic and tests
780+
- **internal/pyclientgen/**: Python HTTP client generation logic and tests (golden tests + helper unit tests)
775781
- **internal/openapiv3/**: OpenAPI generation logic and comprehensive test suite
776782
- **examples/ts-client-demo/**: End-to-end TypeScript client example with NoteService CRUD API
783+
- **examples/python-client-demo/**: End-to-end Python client example sharing the same Go HTTP server as ts-client-demo
784+
- **examples/python-encoding-demo/**: Python client end-to-end test of every JSON-mapping annotation (timestamp_format, int64_encoding, bytes_encoding, enum_value, oneof_config, flatten, all 3 unwrap variants, Python keyword field, repeated query params)
785+
- **examples/python-errors-demo/**: Python client end-to-end test of every error surface (ValidationError, registry-based disambiguation across multiple typed *Error subclasses, *Error embedded as a field on a regular message)
777786
- **scripts/run_tests.sh**: Advanced test runner with coverage analysis and reporting
778787

779788
## Acknowledgments & Ecosystem

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,15 @@ This starts a working HTTP API with JSON endpoints and OpenAPI docs - all genera
2929

3030
## What you get
3131

32-
**Five generators from one `.proto` file:**
32+
**Six generators from one `.proto` file:**
3333

3434
| Generator | Output |
3535
|-----------|--------|
3636
| `protoc-gen-go-http` | Go HTTP servers with routing, request binding, validation, and error handling |
3737
| `protoc-gen-go-client` | Go HTTP clients with type safety, header helpers, and per-call options |
3838
| `protoc-gen-ts-client` | TypeScript HTTP clients with type safety, header helpers, and per-call options |
3939
| `protoc-gen-ts-server` | TypeScript HTTP servers with routing, request binding, validation, and error handling — runs on Node, Deno, Bun, Cloudflare Workers |
40+
| `protoc-gen-py-client` | Python HTTP clients with type safety, header helpers, custom-transport injection, and typed proto-error exceptions — stdlib only (Python 3.10+) |
4041
| `protoc-gen-openapiv3` | OpenAPI v3.1 specs that stay in sync with your code, one file per service |
4142

4243
**Validation and error handling — built in, not bolted on:**
@@ -138,6 +139,7 @@ go install github.com/SebastienMelki/sebuf/cmd/protoc-gen-go-client@latest
138139
go install github.com/SebastienMelki/sebuf/cmd/protoc-gen-openapiv3@latest
139140
go install github.com/SebastienMelki/sebuf/cmd/protoc-gen-ts-client@latest
140141
go install github.com/SebastienMelki/sebuf/cmd/protoc-gen-ts-server@latest
142+
go install github.com/SebastienMelki/sebuf/cmd/protoc-gen-py-client@latest
141143

142144
# Try the complete example
143145
cd examples/simple-api && make demo

cmd/protoc-gen-py-client/main.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package main
2+
3+
import (
4+
"google.golang.org/protobuf/compiler/protogen"
5+
"google.golang.org/protobuf/types/pluginpb"
6+
7+
"github.com/SebastienMelki/sebuf/internal/pyclientgen"
8+
)
9+
10+
func main() {
11+
options := protogen.Options{}
12+
13+
options.Run(func(plugin *protogen.Plugin) error {
14+
plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)
15+
gen := pyclientgen.New(plugin)
16+
return gen.Generate()
17+
})
18+
}

0 commit comments

Comments
 (0)