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.
- openframe/protocol/grpc/__init__.py +67 -0
- openframe/protocol/grpc/client.py +121 -0
- openframe/protocol/grpc/generic.proto +18 -0
- openframe/protocol/grpc/generic_pb2.py +40 -0
- openframe/protocol/grpc/generic_pb2_grpc.py +97 -0
- openframe/protocol/grpc/server.py +275 -0
- openframe_protocol_grpc-0.2.0.dist-info/METADATA +96 -0
- openframe_protocol_grpc-0.2.0.dist-info/RECORD +9 -0
- openframe_protocol_grpc-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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,,
|