protoc plugin to generate chattolib's Connect stubsStatus: proposed
Date: 2026-08-30
Bean: chattolib-zo8b
Supersedes: the regen_connect.py regex post-processor (to be deleted)
Remove the last trace of the connectrpc package from chattolib's build.
Today the runtime is hand-rolled (chattolib/_connect.py) but codegen still
shells out to protoc-gen-connect-python to produce the *_connect.py
stubs, then a regex script strips them down. This design replaces both with a
single, purpose-built protoc plugin that emits exactly the stubs chattolib
needs — an async client and a thin sync wrapper — with no connectrpc
anywhere in the loop.
Goals
protoc --chattolib_out emits the service stubs.chattolib._connect and the sibling *_pb2 module.client.join_room(...), client.post_message(...)) so client.py and the
Bot facade are untouched.connectrpc and pyqwest are absent from both the runtime and the build.Non-goals
.proto parsing — protoc still parses and
validates; the plugin only reads the resulting descriptors.chattolib/_connect.py (exists; small additions)The hand-rolled runtime, unchanged in behavior:
Code (16-value enum), ConnectError, MethodInfo, _BinaryCodec,
google_protobuf_binary_codec().class ConnectClient — __init__(address, *, codec=None, timeout_ms=None),
async execute_unary(*, request, method, headers=None), async close().Addition: class ConnectClientSync — the sync wrapper (see §4).
chattolib/_protoc_plugin.py (new)A Python script that speaks the protoc plugin protocol:
CodeGeneratorRequest (protobuf) from stdin.FileDescriptorProto that contains a service, emits one output
file: <proto path>_connect.py.CodeGeneratorResponse (protobuf) to stdout.It is installed as a console_scripts entry point named
protoc-gen-chattolib, so protoc discovers it on PATH and the
invocation is --chattolib_out=<dir>.
src/chattolib/_pb/google/protobuf/protoc_gen_request_pb2.py and
protoc_gen_response_pb2.py — the two stable protoc descriptor stubs the
plugin needs to parse its stdin/stdout. Generated once from
google/protobuf/*.proto, committed, and not regenerated per-release.
scripts/generate_pb.sh (modified).proto from the pinned CHATTO_REF tag (unchanged).protoc --python_out for the *_pb2.py message files (unchanged).--connect-python_out=... with --chattolib_out=....protoc-gen-connect-python PATH check / [realtime] pin for it.scripts/regen_connect.py — the regex post-processor is obsolete.ConnectClientSync wraps the async client; it does not re-implement the
protocol.
class ConnectClientSync:
def __init__(self, address, *, codec=None, timeout_ms=None):
self._async = ConnectClient(address, codec=codec, timeout_ms=timeout_ms)
self._loop = asyncio.new_event_loop()
def execute_unary(self, *, request, method, headers=None):
self._guard_no_running_loop()
return self._loop.run_until_complete(
self._async.execute_unary(request=request, method=method, headers=headers)
)
def close(self):
self._loop.run_until_complete(self._async.close())
self._loop.close()
def _guard_no_running_loop(self):
try:
asyncio.get_running_loop()
except RuntimeError:
return # no running loop: safe to run_until_complete
raise ChattoError(
"the sync client cannot be used from within a running event loop; "
"use the async ConnectClient in async code"
)
Properties:
MethodInfo, ConnectError, and the
error-envelope parser live only in the async client; the sync path is
delegation. One implementation, two entry points.httpx is already required by the async client.async def raises a clear
ChattoError instead of a nested-loop RuntimeError (decision B).close() closes both.For a service chatto.api.v1.RoomService in rooms.proto, the plugin emits
src/chattolib/_pb/chatto/api/v1/rooms_connect.py:
# Generated by chattolib's protoc plugin (protoc-gen-chattolib). DO NOT EDIT.
# source: chatto/api/v1/rooms.proto
import chatto.api.v1.rooms_pb2 as _pb2
from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo
class RoomServiceClient(ConnectClient):
async def join_room(self, request: _pb2.JoinRoomRequest, *,
headers: dict[str, str] | None = None) -> _pb2.JoinRoomResponse:
return await self.execute_unary(
request=request,
method=MethodInfo(name="JoinRoom",
service_name="chatto.api.v1.RoomService",
input=_pb2.JoinRoomRequest,
output=_pb2.JoinRoomResponse),
headers=headers,
)
# ... one method per RPC ...
class RoomServiceClientSync(ConnectClientSync):
def join_room(self, request: _pb2.JoinRoomRequest, *,
headers: dict[str, str] | None = None) -> _pb2.JoinRoomResponse:
return self.execute_unary(
request=request,
method=MethodInfo(name="JoinRoom",
service_name="chatto.api.v1.RoomService",
input=_pb2.JoinRoomRequest,
output=_pb2.JoinRoomResponse),
headers=headers,
)
Notes:
idempotency_level is omitted from the emitted MethodInfo (the runtime
defaults it; chattolib never reads it).use_get is not generated (POST-only; decision from Q3).build_service_clients still instantiates
only the async *ServiceClient, and no existing code imports *ClientSync,
so client.py, bot.py, and _transport.py require no changes.*_pb2 import uses the proto's own package path (as today), resolved via
the existing sys.path shim in _pb/__init__.py..proto (pinned tag)
│ protoc --proto_path=proto
├──► --python_out → *_pb2.py (message classes; unchanged)
└──► --chattolib_out → protoc-gen-chattolib (our plugin)
reads CodeGeneratorRequest (stdin)
emits *_connect.py (async + sync clients)
via CodeGeneratorResponse (stdout)
chattolib._transport.build_service_clients(base_url)
instantiates the 23 *ServiceClient (async) as today.
CodeGeneratorRequest fixture (a couple of representative
services, incl. one with many methods and one with a oneof-free plain
service) → run the plugin → assert the emitted source imports only from
chattolib._connect/_pb2, defines both client classes, and has one method
per RPC.ChattoError.tests/ suite stays green, and the live
smoke test against the preview server still passes with connectrpc
uninstalled.*_connect.py are committed; a CI check
re-runs the plugin and fails on any diff (catches plugin regressions).| Risk | Mitigation |
|---|---|
| Plugin emits subtly-wrong stubs on a future proto shape | Golden-file CI diff + unit fixtures covering the method shapes we use. |
run_until_complete surprises (e.g. signal handlers, uvloop) |
The guard covers the common case; documented that the sync client is for sync contexts. |
Vendored protoc_gen_*_pb2 drift from a protoc version |
They are stable; pin and regenerate only if protoc changes the codegen schema. |
| Two clients (async+sync) double the stub size | Acceptable; it's generated code, and it's what enables the sync use-case. |
--python_out message generation (we keep
protoc's built-in Python generator — it's not connectrpc-specific).