openframe-protocol-grpc 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ """
2
+ openframe.protocol.grpc
3
+ ==========================
4
+ gRPC binding, both directions, same two-file schema `-http` established:
5
+
6
+ - `bind_service` (`server.py`, needs the `[server]` extra) — wire a
7
+ `QueryHandler`/`CommandHandler`/`UseCase` to a `grpc.aio.Server` through
8
+ the shared error mapper and `RequestContext` builder in
9
+ `openframe.protocol.base`.
10
+ - `GrpcRemoteQueryClient`/`GrpcRemoteCommandClient` (`client.py`, needs
11
+ the `[client]` extra) — call another service built on this platform,
12
+ the outbound counterpart.
13
+
14
+ Unlike `-http`, both extras pull in the same underlying dependency
15
+ (`grpcio`) — there is no separate client-only wire library the way httpx
16
+ is separate from FastAPI. The `[server]`/`[client]` split is kept anyway,
17
+ for the same reason `-http` has it: so a package that only ever calls out
18
+ (never serves) doesn't need to reason about why a server dependency is
19
+ present, even if today that dependency happens to be identical.
20
+
21
+ Lazily imported (PEP 562, `__getattr__` below), matching `-http`'s own
22
+ `__init__.py` exactly — not because the dependency differs here, but
23
+ because the *schema* is the point: every future `openframe-protocol-*`
24
+ package follows this same lazy-import shape regardless of whether a given
25
+ transport's own extras happen to overlap.
26
+
27
+ `generic_pb2`/`generic_pb2_grpc` (the compiled `.proto` stubs) are
28
+ re-exported too, but not lazily — they need `grpcio` either way, the same
29
+ as everything else in this package, so there is no extras boundary for
30
+ them to respect.
31
+
32
+ .. stability: experimental
33
+ """
34
+ from __future__ import annotations
35
+
36
+ from typing import TYPE_CHECKING
37
+
38
+ from openframe.protocol.grpc import generic_pb2, generic_pb2_grpc
39
+
40
+ if TYPE_CHECKING:
41
+ from openframe.protocol.grpc.client import GrpcRemoteCommandClient, GrpcRemoteQueryClient
42
+ from openframe.protocol.grpc.server import ROUTE_METADATA_KEY, bind_service, create_server
43
+
44
+ __all__ = [
45
+ "bind_service",
46
+ "ROUTE_METADATA_KEY",
47
+ "create_server",
48
+ "GrpcRemoteQueryClient",
49
+ "GrpcRemoteCommandClient",
50
+ "generic_pb2",
51
+ "generic_pb2_grpc",
52
+ ]
53
+
54
+ _FROM_SERVER = {"bind_service", "ROUTE_METADATA_KEY", "create_server"}
55
+ _FROM_CLIENT = {"GrpcRemoteQueryClient", "GrpcRemoteCommandClient"}
56
+
57
+
58
+ def __getattr__(name: str):
59
+ if name in _FROM_SERVER:
60
+ from openframe.protocol.grpc import server
61
+
62
+ return getattr(server, name)
63
+ if name in _FROM_CLIENT:
64
+ from openframe.protocol.grpc import client
65
+
66
+ return getattr(client, name)
67
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,121 @@
1
+ """
2
+ openframe/protocol/grpc/client.py
3
+ =====================================
4
+ `GrpcRemoteQueryClient`/`GrpcRemoteCommandClient` — gRPC implementations
5
+ of `openframe.core.remote`'s `RemoteQueryClient`/`RemoteCommandClient`,
6
+ the gRPC counterpart of `-http`'s `HttpRemoteQueryClient`/
7
+ `HttpRemoteCommandClient`.
8
+
9
+ No `RequestSpec`-equivalent needed — simpler than the HTTP client's
10
+ `build_request` hook, not a weaker version of it. gRPC has no path/query/
11
+ body split: a request is just one message (`GenericRequest`), so the
12
+ handler's input value is JSON-encoded into `json_payload` directly, no
13
+ hook required to decide where each piece of it goes.
14
+
15
+ Lifecycle, per draft 8's D10 (same reasoning as `-http`'s client.py):
16
+ no `BasePort`/`PluginRegistry` involvement. Each client owns (or borrows)
17
+ a plain `grpc.aio.Channel` and exposes `aclose()` for a caller's own
18
+ lifespan to manage directly.
19
+
20
+ Dependency order:
21
+ protocol/grpc/client → protocol/base (errors, for the reverse mapping), protocol/grpc (generic_pb2*)
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from typing import Any, Generic, TypeVar
27
+
28
+ import grpc
29
+
30
+ from openframe.core.inbound import RequestContext
31
+ from openframe.protocol.base.errors import ErrorCategory, ErrorEnvelope, raise_for_envelope
32
+ from openframe.protocol.grpc import generic_pb2, generic_pb2_grpc
33
+ from openframe.protocol.grpc.server import ROUTE_METADATA_KEY
34
+
35
+ __all__ = ["GrpcRemoteQueryClient", "GrpcRemoteCommandClient"]
36
+
37
+ TInput = TypeVar("TInput")
38
+ TOutput = TypeVar("TOutput")
39
+
40
+ _STATUS_CODE_TO_CATEGORY: dict[grpc.StatusCode, ErrorCategory] = {
41
+ grpc.StatusCode.NOT_FOUND: ErrorCategory.NOT_FOUND,
42
+ grpc.StatusCode.INVALID_ARGUMENT: ErrorCategory.INVALID,
43
+ grpc.StatusCode.DEADLINE_EXCEEDED: ErrorCategory.TIMEOUT,
44
+ grpc.StatusCode.UNAVAILABLE: ErrorCategory.UNAVAILABLE,
45
+ }
46
+
47
+
48
+ class _GrpcRemoteCallMixin:
49
+ """Shared machinery both client classes below use — not public itself."""
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ target: str | None = None,
55
+ route: str,
56
+ channel: "grpc.aio.Channel | None" = None,
57
+ remote_name: str | None = None,
58
+ ) -> None:
59
+ if channel is None and target is None:
60
+ raise ValueError("Either target or an existing channel must be provided.")
61
+ self._channel = channel if channel is not None else grpc.aio.insecure_channel(target)
62
+ self._owns_channel = channel is None
63
+ self._stub = generic_pb2_grpc.GenericServiceStub(self._channel)
64
+ self._route = route
65
+ self._remote_name = remote_name or f"grpc:{route}"
66
+
67
+ async def aclose(self) -> None:
68
+ """Close the underlying channel — only if this instance created it."""
69
+ if self._owns_channel:
70
+ await self._channel.close()
71
+
72
+ async def _call(self, value: Any, context: RequestContext | None) -> Any:
73
+ metadata: list[tuple[str, str]] = [(ROUTE_METADATA_KEY, self._route)]
74
+ if context is not None:
75
+ metadata.append(("x-correlation-id", context.correlation_id))
76
+
77
+ request = generic_pb2.GenericRequest(json_payload=json.dumps(value))
78
+
79
+ try:
80
+ response = await self._stub.Call(request, metadata=tuple(metadata))
81
+ except grpc.aio.AioRpcError as exc:
82
+ category = _STATUS_CODE_TO_CATEGORY.get(exc.code(), ErrorCategory.INTERNAL)
83
+ details = exc.details() or ""
84
+ try:
85
+ body = json.loads(details)
86
+ error = body.get("error", body)
87
+ except (json.JSONDecodeError, AttributeError):
88
+ error = {}
89
+ envelope = ErrorEnvelope(
90
+ code=error.get("code", "unknown"),
91
+ message=error.get("message", details or f"{self._remote_name} failed with {exc.code()}"),
92
+ retryable=error.get("retryable", False),
93
+ correlation_id=error.get("correlation_id", ""),
94
+ )
95
+ raise_for_envelope(category, envelope, remote_name=self._remote_name)
96
+
97
+ if not response.json_payload:
98
+ return None
99
+ return json.loads(response.json_payload)
100
+
101
+
102
+ class GrpcRemoteQueryClient(_GrpcRemoteCallMixin, Generic[TInput, TOutput]):
103
+ """
104
+ `RemoteQueryClient` over gRPC. Satisfies
105
+ `openframe.core.remote.RemoteQueryClient` structurally.
106
+
107
+ Args: see `_GrpcRemoteCallMixin.__init__`.
108
+ """
109
+
110
+ async def execute(self, query: TInput, context: RequestContext | None = None) -> TOutput:
111
+ return await self._call(query, context)
112
+
113
+
114
+ class GrpcRemoteCommandClient(_GrpcRemoteCallMixin, Generic[TInput]):
115
+ """
116
+ `RemoteCommandClient` over gRPC. Satisfies
117
+ `openframe.core.remote.RemoteCommandClient` structurally.
118
+ """
119
+
120
+ async def execute(self, command: TInput, context: RequestContext | None = None) -> None:
121
+ await self._call(command, context)
@@ -0,0 +1,18 @@
1
+ syntax = "proto3";
2
+ package openframe.protocol.grpc;
3
+
4
+ // One generic message pair instead of one per route — mirrors server.py's
5
+ // own inspect.Signature trick for HTTP: the wire schema doesn't need to
6
+ // know the domain type, only that a JSON-encoded payload travels inside it.
7
+ // The actual domain type is JSON-encoded inside json_payload.
8
+ message GenericRequest {
9
+ string json_payload = 1;
10
+ }
11
+
12
+ message GenericResponse {
13
+ string json_payload = 1;
14
+ }
15
+
16
+ service GenericService {
17
+ rpc Call(GenericRequest) returns (GenericResponse);
18
+ }
@@ -0,0 +1,40 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: generic.proto
5
+ # Protobuf Python Version: 7.35.1
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 7,
15
+ 35,
16
+ 1,
17
+ '',
18
+ 'generic.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+
26
+
27
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rgeneric.proto\x12\x17openframe.protocol.grpc\"&\n\x0eGenericRequest\x12\x14\n\x0cjson_payload\x18\x01 \x01(\t\"\'\n\x0fGenericResponse\x12\x14\n\x0cjson_payload\x18\x01 \x01(\t2k\n\x0eGenericService\x12Y\n\x04\x43\x61ll\x12\'.openframe.protocol.grpc.GenericRequest\x1a(.openframe.protocol.grpc.GenericResponseb\x06proto3')
28
+
29
+ _globals = globals()
30
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
31
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'generic_pb2', _globals)
32
+ if not _descriptor._USE_C_DESCRIPTORS:
33
+ DESCRIPTOR._loaded_options = None
34
+ _globals['_GENERICREQUEST']._serialized_start=42
35
+ _globals['_GENERICREQUEST']._serialized_end=80
36
+ _globals['_GENERICRESPONSE']._serialized_start=82
37
+ _globals['_GENERICRESPONSE']._serialized_end=121
38
+ _globals['_GENERICSERVICE']._serialized_start=123
39
+ _globals['_GENERICSERVICE']._serialized_end=230
40
+ # @@protoc_insertion_point(module_scope)
@@ -0,0 +1,97 @@
1
+ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
2
+ """Client and server classes corresponding to protobuf-defined services."""
3
+ import grpc
4
+ import warnings
5
+
6
+ from openframe.protocol.grpc import generic_pb2 as generic__pb2
7
+
8
+ GRPC_GENERATED_VERSION = '1.84.0'
9
+ GRPC_VERSION = grpc.__version__
10
+ _version_not_supported = False
11
+
12
+ try:
13
+ from grpc._utilities import first_version_is_lower
14
+ _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
15
+ except ImportError:
16
+ _version_not_supported = True
17
+
18
+ if _version_not_supported:
19
+ raise RuntimeError(
20
+ f'The grpc package installed is at version {GRPC_VERSION},'
21
+ + ' but the generated code in generic_pb2_grpc.py depends on'
22
+ + f' grpcio>={GRPC_GENERATED_VERSION}.'
23
+ + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
24
+ + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
25
+ )
26
+
27
+
28
+ class GenericServiceStub:
29
+ """Missing associated documentation comment in .proto file."""
30
+
31
+ def __init__(self, channel):
32
+ """Constructor.
33
+
34
+ Args:
35
+ channel: A grpc.Channel.
36
+ """
37
+ self.Call = channel.unary_unary(
38
+ '/openframe.protocol.grpc.GenericService/Call',
39
+ request_serializer=generic__pb2.GenericRequest.SerializeToString,
40
+ response_deserializer=generic__pb2.GenericResponse.FromString,
41
+ _registered_method=True)
42
+
43
+
44
+ class GenericServiceServicer:
45
+ """Missing associated documentation comment in .proto file."""
46
+
47
+ def Call(self, request, context):
48
+ """Missing associated documentation comment in .proto file."""
49
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
50
+ context.set_details('Method not implemented!')
51
+ raise NotImplementedError('Method not implemented!')
52
+
53
+
54
+ def add_GenericServiceServicer_to_server(servicer, server):
55
+ rpc_method_handlers = {
56
+ 'Call': grpc.unary_unary_rpc_method_handler(
57
+ servicer.Call,
58
+ request_deserializer=generic__pb2.GenericRequest.FromString,
59
+ response_serializer=generic__pb2.GenericResponse.SerializeToString,
60
+ ),
61
+ }
62
+ generic_handler = grpc.method_handlers_generic_handler(
63
+ 'openframe.protocol.grpc.GenericService', rpc_method_handlers)
64
+ server.add_generic_rpc_handlers((generic_handler,))
65
+ server.add_registered_method_handlers('openframe.protocol.grpc.GenericService', rpc_method_handlers)
66
+
67
+
68
+ # This class is part of an EXPERIMENTAL API.
69
+ class GenericService:
70
+ """Missing associated documentation comment in .proto file."""
71
+
72
+ @staticmethod
73
+ def Call(request,
74
+ target,
75
+ options=(),
76
+ channel_credentials=None,
77
+ call_credentials=None,
78
+ insecure=False,
79
+ compression=None,
80
+ wait_for_ready=None,
81
+ timeout=None,
82
+ metadata=None):
83
+ return grpc.experimental.unary_unary(
84
+ request,
85
+ target,
86
+ '/openframe.protocol.grpc.GenericService/Call',
87
+ generic__pb2.GenericRequest.SerializeToString,
88
+ generic__pb2.GenericResponse.FromString,
89
+ options,
90
+ channel_credentials,
91
+ insecure,
92
+ call_credentials,
93
+ compression,
94
+ wait_for_ready,
95
+ timeout,
96
+ metadata,
97
+ _registered_method=True)
@@ -0,0 +1,275 @@
1
+ """
2
+ openframe/protocol/grpc/server.py
3
+ =====================================
4
+ `bind_service` — register a `grpc.aio` servicer method that calls into a
5
+ `UseCase`/`CommandHandler`/`QueryHandler` (`openframe.core.inbound`)
6
+ through the shared binder, the gRPC counterpart of `-http`'s
7
+ `bind_query`/`bind_command`.
8
+
9
+ One generic RPC (`GenericService.Call`) instead of one per route, mirroring
10
+ `-http`'s own `inspect.Signature` trick for avoiding a per-route function
11
+ signature: the wire schema (`GenericRequest`/`GenericResponse`, both just
12
+ `{json_payload: string}`) doesn't need to know the domain type, only that
13
+ a JSON string travels inside it. The actual handler is selected by
14
+ `invocation_metadata()`'s `"x-openframe-route"` key, decoded on the way
15
+ in — the gRPC equivalent of an HTTP path.
16
+
17
+ No global interceptor. A wrapping function around the generated
18
+ servicer's `Call` method achieves the same effect `install_error_handlers`
19
+ does for HTTP — catch, categorize, `context.abort()` — without depending
20
+ on `grpc.aio`'s own interceptor API, which was not spiked and is not
21
+ needed for what this binder does.
22
+
23
+ Spiked first (see `PLAN.md`'s "M4 — gRPC" section): confirmed `grpc.aio`
24
+ works end to end (async server + channel), `context.abort(code, details)`
25
+ propagates to the client as `AioRpcError.code()`/`.details()`, and
26
+ `context.invocation_metadata()` carries key-value pairs exactly like HTTP
27
+ headers — so `build_request_context` (built for a header-like `Mapping`)
28
+ works unchanged here, no gRPC-specific context builder needed.
29
+
30
+ Dependency order:
31
+ protocol/grpc/server → protocol/base (errors, context), protocol/grpc (generic_pb2*)
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import logging
37
+ from contextlib import asynccontextmanager
38
+ from typing import Any, AsyncIterator, Awaitable, Callable
39
+
40
+ import grpc
41
+ from pydantic import ValidationError
42
+
43
+ from openframe.core.exceptions import AdapterConnectionError
44
+ from openframe.core.inbound import RequestContext
45
+ from openframe.core.telemetry import record_error, record_lifecycle_event, setup_telemetry, shutdown_telemetry
46
+ from openframe.protocol.base.context import build_request_context
47
+ from openframe.protocol.base.errors import ErrorCategory, build_envelope, categorize
48
+ from openframe.protocol.grpc import generic_pb2, generic_pb2_grpc
49
+
50
+ __all__ = ["bind_service", "ROUTE_METADATA_KEY", "create_server"]
51
+
52
+ _logger = logging.getLogger(__name__)
53
+
54
+ _CATEGORY_TO_STATUS_CODE: dict[ErrorCategory, grpc.StatusCode] = {
55
+ ErrorCategory.NOT_FOUND: grpc.StatusCode.NOT_FOUND,
56
+ ErrorCategory.INVALID: grpc.StatusCode.INVALID_ARGUMENT,
57
+ ErrorCategory.TIMEOUT: grpc.StatusCode.DEADLINE_EXCEEDED,
58
+ ErrorCategory.UNAVAILABLE: grpc.StatusCode.UNAVAILABLE,
59
+ ErrorCategory.INTERNAL: grpc.StatusCode.INTERNAL,
60
+ }
61
+
62
+ ROUTE_METADATA_KEY = "x-openframe-route"
63
+
64
+
65
+ class _RouteRegisteringServicer(generic_pb2_grpc.GenericServiceServicer):
66
+ """
67
+ One servicer instance per `grpc.aio.Server`, holding every route
68
+ `bind_service` has registered on it so far — the gRPC equivalent of
69
+ an `APIRouter` accumulating routes before `app.include_router()`.
70
+ """
71
+
72
+ def __init__(self) -> None:
73
+ self._routes: dict[str, Callable[[Any, grpc.aio.ServicerContext], Any]] = {}
74
+
75
+ def register(self, route: str, handler: Callable[[Any, grpc.aio.ServicerContext], Any]) -> None:
76
+ self._routes[route] = handler
77
+
78
+ async def Call(
79
+ self, request: "generic_pb2.GenericRequest", context: grpc.aio.ServicerContext
80
+ ) -> "generic_pb2.GenericResponse":
81
+ metadata = dict(context.invocation_metadata())
82
+ route = metadata.get(ROUTE_METADATA_KEY)
83
+ handler = self._routes.get(route)
84
+ if handler is None:
85
+ await context.abort(grpc.StatusCode.NOT_FOUND, f"no route bound for {route!r}")
86
+ return # unreachable: abort() always raises
87
+ return await handler(request, context)
88
+
89
+
90
+ def _get_or_create_servicer(server: grpc.aio.Server) -> _RouteRegisteringServicer:
91
+ """
92
+ Reuse the single `_RouteRegisteringServicer` already added to *server*,
93
+ if `bind_service` has been called on it before — otherwise create and
94
+ register one. Mirrors how multiple `bind_query`/`bind_command` calls
95
+ share one `APIRouter` without each creating its own route table.
96
+ """
97
+ existing = getattr(server, "_openframe_generic_servicer", None)
98
+ if existing is not None:
99
+ return existing
100
+ servicer = _RouteRegisteringServicer()
101
+ generic_pb2_grpc.add_GenericServiceServicer_to_server(servicer, server)
102
+ server._openframe_generic_servicer = servicer # type: ignore[attr-defined]
103
+ return servicer
104
+
105
+
106
+ def bind_service(
107
+ server: grpc.aio.Server,
108
+ route: str,
109
+ *,
110
+ handler_factory: Callable[[], Any],
111
+ not_found_when: Callable[[Any], bool] | None = None,
112
+ ) -> None:
113
+ """
114
+ Register one route's handler on *server*, callable by a client that
115
+ sends `route` in its `x-openframe-route` metadata.
116
+
117
+ Args:
118
+ server: The `grpc.aio.Server` to register onto. Safe to
119
+ call this more than once for the same server —
120
+ each call adds one route to a shared servicer,
121
+ the same relationship `bind_query`/`bind_command`
122
+ have to one `APIRouter`.
123
+ route: Logical route name, e.g. `"get_item"` — the
124
+ gRPC equivalent of an HTTP path. Not a URL
125
+ template; gRPC has no path params, so the
126
+ handler's own input carries whatever an HTTP
127
+ path param would have (see `client.py`).
128
+ handler_factory: Builds a fresh handler instance for this call —
129
+ the gRPC equivalent of a FastAPI dependency.
130
+ Called once per request, no caching, matching
131
+ `handler_dep`'s own per-request lifecycle in
132
+ `-http`'s `server.py`.
133
+ not_found_when: If the handler's result satisfies this, abort
134
+ with `NOT_FOUND` instead of returning it.
135
+ """
136
+ servicer = _get_or_create_servicer(server)
137
+
138
+ async def _handle(
139
+ request: "generic_pb2.GenericRequest", context: grpc.aio.ServicerContext
140
+ ) -> "generic_pb2.GenericResponse":
141
+ handler = handler_factory()
142
+ handler_input = json.loads(request.json_payload) if request.json_payload else None
143
+ metadata = dict(context.invocation_metadata())
144
+ request_context: RequestContext = build_request_context(metadata)
145
+ context.set_trailing_metadata((("x-correlation-id", request_context.correlation_id),))
146
+
147
+ try:
148
+ result = await handler.execute(handler_input, request_context)
149
+ except BaseException as exc:
150
+ await _abort_for_exception(context, exc, request_context.correlation_id)
151
+ return # unreachable: _abort_for_exception always raises
152
+
153
+ if not_found_when is not None and not_found_when(result):
154
+ await context.abort(grpc.StatusCode.NOT_FOUND, "not found")
155
+ return # unreachable
156
+
157
+ return generic_pb2.GenericResponse(json_payload=json.dumps(result))
158
+
159
+ servicer.register(route, _handle)
160
+
161
+
162
+ async def _abort_for_exception(
163
+ context: grpc.aio.ServicerContext, exc: BaseException, correlation_id: str
164
+ ) -> None:
165
+ """
166
+ Map *exc* through the shared `categorize()`/`build_envelope()` and
167
+ `context.abort()` — the gRPC equivalent of `install_error_handlers`'
168
+ exception handlers for HTTP. One function instead of per-exception-
169
+ type handlers, since `context.abort()` is the one call site that
170
+ needs to run regardless of which category the error mapped to.
171
+
172
+ Calls `record_error()` for an INTERNAL-category error, for the same
173
+ reason `install_error_handlers` does: `context.abort()` raises
174
+ immediately and never reaches any outer recorder on its own.
175
+ """
176
+ category = categorize(exc)
177
+ envelope = build_envelope(exc, correlation_id=correlation_id)
178
+ if category is ErrorCategory.INTERNAL:
179
+ record_error(exc if isinstance(exc, Exception) else Exception(str(exc)))
180
+ status_code = _CATEGORY_TO_STATUS_CODE[category]
181
+ details = json.dumps(
182
+ {
183
+ "error": {
184
+ "code": envelope.code,
185
+ "message": envelope.message,
186
+ "retryable": envelope.retryable,
187
+ "correlation_id": envelope.correlation_id,
188
+ }
189
+ }
190
+ )
191
+ await context.abort(status_code, details)
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Server creation/lifecycle — the gRPC counterpart of -http's create_app
196
+ # ---------------------------------------------------------------------------
197
+ #
198
+ # Named `create_server` for naming symmetry with `-http`'s `create_app`, but
199
+ # shaped differently on purpose, not by oversight: FastAPI's app object is
200
+ # just configuration handed off to an external process (uvicorn) that owns
201
+ # the actual accept loop, so `create_app` can hand back a plain `FastAPI`
202
+ # instance and be done. `grpc.aio.Server` has no external process to hand
203
+ # off to — THIS process owns start()/stop() directly, and routes
204
+ # (`bind_service` calls) must be registered before `start()`, not after.
205
+ # An async context manager is the shape that actually fits that lifecycle,
206
+ # not a plain factory function returning an unstarted server.
207
+
208
+
209
+ @asynccontextmanager
210
+ async def create_server(
211
+ *,
212
+ address: str,
213
+ configure: Callable[["grpc.aio.Server"], None],
214
+ on_startup: Callable[[], Awaitable[None]],
215
+ on_shutdown: Callable[[], Awaitable[None]],
216
+ degraded_mode_exceptions: tuple[type[BaseException], ...] = (AdapterConnectionError, ValidationError),
217
+ ) -> AsyncIterator["grpc.aio.Server"]:
218
+ """
219
+ Async context manager yielding a started `grpc.aio.Server`, with the
220
+ same telemetry-setup / degraded-mode-startup / telemetry-shutdown shape
221
+ `-http`'s `create_app` gives FastAPI — the gRPC counterpart of the
222
+ lifespan block every validation-framework HTTP service hand-wrote
223
+ identically.
224
+
225
+ Args:
226
+ address: Passed to `server.add_insecure_port()`, e.g. `"[::]:50051"`.
227
+ configure: Called with the bare `grpc.aio.server()` instance
228
+ before `on_startup`/`start()` — this is where the
229
+ caller's own `bind_service(server, ...)` calls belong,
230
+ since gRPC requires routes registered before start().
231
+ on_startup: Awaited after `configure`, before `add_insecure_port`/
232
+ `start()`. Any exception in `degraded_mode_exceptions`
233
+ is caught and logged as a warning rather than
234
+ preventing the server from starting — the same
235
+ tolerance `create_app` gives a not-yet-ready backend.
236
+ on_shutdown: Awaited after the `with` block exits, before
237
+ `server.stop()`.
238
+ degraded_mode_exceptions: Same default pair `create_app` uses.
239
+
240
+ Usage::
241
+
242
+ async with create_server(
243
+ address="[::]:50051",
244
+ configure=lambda server: bind_service(server, "get_item", handler_factory=get_get_item_handler),
245
+ on_startup=dependencies.initialise,
246
+ on_shutdown=dependencies.shutdown,
247
+ ) as server:
248
+ await server.wait_for_termination()
249
+ """
250
+ setup_telemetry()
251
+ record_lifecycle_event("cold_start")
252
+
253
+ server = grpc.aio.server()
254
+ configure(server)
255
+
256
+ try:
257
+ await on_startup()
258
+ except degraded_mode_exceptions as exc:
259
+ _logger.warning("dependency not ready at startup: %s — running in degraded mode", exc)
260
+
261
+ port = server.add_insecure_port(address)
262
+ # Stashed the same way bind_service's own _get_or_create_servicer already
263
+ # stashes _openframe_generic_servicer on the server — the real bound
264
+ # port matters when address ends in ":0" (an OS-assigned port, the
265
+ # pattern every other conformance test in this repo uses for an
266
+ # isolated, parallel-safe test server). A fixed real address doesn't
267
+ # need this at all; it's here for exactly that ephemeral-port case.
268
+ server._openframe_bound_port = port # type: ignore[attr-defined]
269
+ await server.start()
270
+ try:
271
+ yield server
272
+ finally:
273
+ await on_shutdown()
274
+ await server.stop(None)
275
+ shutdown_telemetry()
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.5
2
+ Name: openframe-protocol-grpc
3
+ Version: 0.2.0
4
+ Summary: OpenFrame Microservice Suite - gRPC server and client binding for openframe.core.inbound/remote.
5
+ Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-protocol
6
+ Project-URL: Documentation, https://furious-meteors.github.io/openframe-protocol/
7
+ Project-URL: Repository, https://github.com/Furious-Meteors/openframe-protocol
8
+ Project-URL: Changelog, https://github.com/Furious-Meteors/openframe-protocol/blob/production/.github/CHANGELOG.md
9
+ Project-URL: Bug Tracker, https://github.com/Furious-Meteors/openframe-protocol/issues
10
+ Author-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
11
+ Maintainer-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
12
+ License: MIT
13
+ Keywords: grpc,hexagonal,microservice,openframe,protocol
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: openframe-core<4,>=3.2.1
26
+ Requires-Dist: openframe-protocol-base<1,>=0.1
27
+ Provides-Extra: client
28
+ Requires-Dist: grpcio>=1.60; extra == 'client'
29
+ Provides-Extra: dev
30
+ Requires-Dist: grpcio-tools>=1.60; extra == 'dev'
31
+ Requires-Dist: grpcio>=1.60; extra == 'dev'
32
+ Requires-Dist: pydantic>=2.0; extra == 'dev'
33
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
34
+ Requires-Dist: pytest-mock>=3.14; extra == 'dev'
35
+ Requires-Dist: pytest>=8.0; extra == 'dev'
36
+ Provides-Extra: server
37
+ Requires-Dist: grpcio>=1.60; extra == 'server'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # openframe-protocol-grpc
41
+
42
+ gRPC server and client binding, the second transport (after HTTP) to prove
43
+ `openframe-protocol-base`'s conformance suites for real. Wires a
44
+ `QueryHandler`/`CommandHandler`/`UseCase` (`openframe.core.inbound`) to a
45
+ `grpc.aio.Server`, and provides `GrpcRemoteQueryClient`/
46
+ `GrpcRemoteCommandClient` for calling another service built on this
47
+ platform — through the same shared error mapper and `RequestContext`
48
+ builder `openframe-protocol-http` uses, not a parallel implementation.
49
+
50
+ One generic RPC (`GenericService.Call`, `{json_payload: string}` in both
51
+ directions) instead of one per route — the domain type travels as JSON
52
+ inside the payload, the gRPC equivalent of `-http`'s dynamic
53
+ `inspect.Signature` trick for avoiding a per-route message type.
54
+
55
+ Install the `[server]` extra for a server, `[client]` extra to call one —
56
+ both pull in `grpcio` (unlike HTTP, there's no separate client-only wire
57
+ library for gRPC, but the extras split is kept for symmetry with every
58
+ other `openframe-protocol-*` package).
59
+
60
+ ## Server-side wiring example
61
+
62
+ ```python
63
+ from openframe.protocol.grpc import bind_service
64
+
65
+ server = grpc.aio.server()
66
+
67
+ bind_service(
68
+ server, "get_item",
69
+ handler_factory=get_get_item_handler,
70
+ not_found_when=lambda item: item is None,
71
+ )
72
+ bind_service(
73
+ server, "create_item",
74
+ handler_factory=get_create_item_handler,
75
+ )
76
+
77
+ server.add_insecure_port("[::]:50051")
78
+ await server.start()
79
+ ```
80
+
81
+ ## Client-side wiring example
82
+
83
+ ```python
84
+ from openframe.protocol.grpc import GrpcRemoteQueryClient
85
+
86
+ get_item = GrpcRemoteQueryClient(target="items-service:50051", route="get_item")
87
+ item = await get_item.execute("abc-123")
88
+ ```
89
+
90
+ ## Status
91
+
92
+ Experimental, M4 of the project's own build plan. Both
93
+ `TransportConformanceTests` and `RemoteClientConformanceTests`
94
+ (`openframe-protocol-base`) pass against this package for real — the
95
+ point of building it: proving the architecture generalizes past HTTP,
96
+ not just adding a second transport for its own sake.
@@ -0,0 +1,9 @@
1
+ openframe/protocol/grpc/__init__.py,sha256=ji4Qc4MtEIXoz8BkhepR_3qXcdxyjatM_QAOteco2CY,2564
2
+ openframe/protocol/grpc/client.py,sha256=4dceAYrgwe3I6EWYa-OTu1rnmTteNPnufPHVB3Xv0Uc,4811
3
+ openframe/protocol/grpc/generic.proto,sha256=gURefnINPc6rtma3hPNjks0VzePEj5QKim1rJCvEqb4,535
4
+ openframe/protocol/grpc/generic_pb2.py,sha256=9P--YG2L8v3TTPaxo_YX8HWGYnmq4Yk319-_X-6En5Q,1699
5
+ openframe/protocol/grpc/generic_pb2_grpc.py,sha256=wbrFGx4ahmYsq4qeFUaojjd4h0Lbf4lr1C9_rxWblBA,3462
6
+ openframe/protocol/grpc/server.py,sha256=Tri13hdCXO5paN_DyX_Fvz61KwqBvyAjMkJuqQOWzfM,12407
7
+ openframe_protocol_grpc-0.2.0.dist-info/METADATA,sha256=AK9yetjaAJNrsgJ93PgOtu3sDhTDxLOtReZ23cJILZI,3913
8
+ openframe_protocol_grpc-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ openframe_protocol_grpc-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any