hexastack-grpc 0.0.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.

Potentially problematic release.


This version of hexastack-grpc might be problematic. Click here for more details.

@@ -0,0 +1,7 @@
1
+ from hexastack_grpc import adapters, domain, infra
2
+
3
+ __all__ = [
4
+ "adapters",
5
+ "domain",
6
+ "infra",
7
+ ]
File without changes
@@ -0,0 +1,46 @@
1
+ from collections.abc import Sequence
2
+
3
+ import grpc
4
+
5
+ from hexastack_grpc.infra.config import HexastackGrpcConfig
6
+
7
+ __all__ = [
8
+ "create_async_grpc_server",
9
+ "run_grpc_server",
10
+ ]
11
+
12
+
13
+ def create_async_grpc_server(
14
+ config: HexastackGrpcConfig,
15
+ interceptors: Sequence[grpc.aio.ServerInterceptor] | None = None,
16
+ ) -> grpc.aio.Server:
17
+ """Create an asynchronous grpc.aio.Server instance.
18
+
19
+ Args:
20
+ config: HexastackGrpcConfig options.
21
+ interceptors: Optional async server interceptors.
22
+
23
+ Returns:
24
+ Configured grpc.aio.Server instance.
25
+ """
26
+ server = grpc.aio.server(interceptors=interceptors or ())
27
+ server.add_insecure_port(f"{config.host}:{config.port}")
28
+ return server
29
+
30
+
31
+ def run_grpc_server(
32
+ server: grpc.Server,
33
+ block: bool = True,
34
+ ) -> None:
35
+ """Start the gRPC server and optionally block the current thread.
36
+
37
+ Args:
38
+ server: Configured grpc.Server instance.
39
+ block: If True, blocks thread with server.wait_for_termination().
40
+ """
41
+ server.start()
42
+ if block:
43
+ try:
44
+ server.wait_for_termination()
45
+ except KeyboardInterrupt:
46
+ server.stop(grace=5.0)
File without changes
@@ -0,0 +1,24 @@
1
+ from hexastack_core.domain.exceptions import HexastackError
2
+
3
+
4
+ class GrpcError(HexastackError):
5
+ """Base exception for all gRPC presentation layer errors.
6
+
7
+ Notes/Architectural Intent:
8
+ Inherits from HexastackError to maintain unified error hierarchy across the framework.
9
+ """
10
+
11
+
12
+ class ServiceRegistrationError(GrpcError):
13
+ """Exception raised when a gRPC servicer fails registration or lacks a valid add_to_server callback."""
14
+
15
+
16
+ class RpcExecutionError(GrpcError):
17
+ """Exception raised when an RPC handler execution fails during dispatch."""
18
+
19
+
20
+ __all__ = [
21
+ "GrpcError",
22
+ "RpcExecutionError",
23
+ "ServiceRegistrationError",
24
+ ]
File without changes
@@ -0,0 +1,64 @@
1
+ from collections.abc import Sequence
2
+ from types import ModuleType
3
+ from typing import Any
4
+
5
+ from hexastack_core.infra.autodiscovery import (
6
+ DiscoveryVisitor,
7
+ scan_modules,
8
+ )
9
+ from hexastack_grpc.infra.decorators import _GRPC_SERVICE_ATTR
10
+ from hexastack_grpc.infra.registries.service import (
11
+ GrpcServiceRegistration,
12
+ GrpcServiceRegistry,
13
+ )
14
+
15
+ __all__ = [
16
+ "autodiscover_grpc_services",
17
+ "create_grpc_visitor",
18
+ ]
19
+
20
+
21
+ def autodiscover_grpc_services(
22
+ packages_to_scan: Sequence[str | ModuleType],
23
+ registry: GrpcServiceRegistry,
24
+ ) -> GrpcServiceRegistry:
25
+ """Discover decorated gRPC servicers from packages.
26
+
27
+ Args:
28
+ packages_to_scan: Sequence of package names or module objects to inspect.
29
+ registry: Target GrpcServiceRegistry instance.
30
+
31
+ Returns:
32
+ The populated GrpcServiceRegistry instance.
33
+ """
34
+ visitor = create_grpc_visitor(registry)
35
+ scan_modules(packages_to_scan, [visitor])
36
+ return registry
37
+
38
+
39
+ def create_grpc_visitor(
40
+ registry: GrpcServiceRegistry,
41
+ ) -> DiscoveryVisitor:
42
+ """Create a DiscoveryVisitor callback for single-pass gRPC service discovery.
43
+
44
+ Notes/Architectural Intent:
45
+ Inspects discovered classes and objects for @grpc_service metadata,
46
+ registering servicers into the supplied service registry during single-pass reflection.
47
+
48
+ Args:
49
+ registry: Target GrpcServiceRegistry instance.
50
+
51
+ Returns:
52
+ DiscoveryVisitor callable accepting (member, module).
53
+ """
54
+
55
+ def visitor(obj: Any, module: ModuleType) -> None:
56
+ meta: GrpcServiceRegistration | None = getattr(obj, _GRPC_SERVICE_ATTR, None)
57
+ if meta is not None:
58
+ registry.register_service(
59
+ servicer=meta.servicer,
60
+ add_to_server_fn=meta.add_to_server_fn,
61
+ service_names=meta.service_names,
62
+ )
63
+
64
+ return visitor
@@ -0,0 +1,113 @@
1
+ from dataclasses import dataclass
2
+
3
+ import grpc
4
+
5
+ from hexastack_core.infra.bootstrap import BootstrapContext
6
+ from hexastack_core.infra.registries.config import ConfigRegistry
7
+ from hexastack_core.ports.bootstrap import BootstrapperPort
8
+ from hexastack_grpc.infra.autodiscovery import create_grpc_visitor
9
+ from hexastack_grpc.infra.config import (
10
+ HexastackGrpcConfig,
11
+ register_grpc_config,
12
+ )
13
+ from hexastack_grpc.infra.decorators import get_grpc_registry
14
+ from hexastack_grpc.infra.interceptors.correlation import (
15
+ CorrelationServerInterceptor,
16
+ )
17
+ from hexastack_grpc.infra.interceptors.exception import (
18
+ ExceptionServerInterceptor,
19
+ )
20
+ from hexastack_grpc.infra.interceptors.logging import (
21
+ LoggingServerInterceptor,
22
+ TimingServerInterceptor,
23
+ )
24
+ from hexastack_grpc.infra.registries.service import GrpcServiceRegistry
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class GrpcBootstrapResult:
29
+ """Dataclass holding initialized gRPC server and configuration."""
30
+
31
+ config: HexastackGrpcConfig
32
+ server: grpc.Server
33
+ registry: GrpcServiceRegistry
34
+
35
+
36
+ class GrpcBootstrapper(BootstrapperPort):
37
+ """Bootstrap extension configuring and building the gRPC server.
38
+
39
+ Notes/Architectural Intent:
40
+ Implements BootstrapperPort with order=40 (executing after CQRS order=20),
41
+ registering the autodiscovery visitor, compiling the gRPC Server with
42
+ telemetry interceptors (correlation, exception mapping, logging, timing),
43
+ and binding it to the DI container.
44
+ """
45
+
46
+ name: str = "grpc"
47
+ order: int = 40
48
+
49
+ def configure(self, context: BootstrapContext) -> None:
50
+ """Phase 2: Register visitor, assemble gRPC server with interceptors.
51
+
52
+ Args:
53
+ context: BootstrapContext containing DI container, config, and properties.
54
+
55
+ Returns:
56
+ None.
57
+ """
58
+ cfg = context.get_config("grpc", HexastackGrpcConfig)
59
+
60
+ registry = get_grpc_registry()
61
+
62
+ # Register visitor for single-pass reflective scanning (Phase 3)
63
+ visitor = create_grpc_visitor(registry)
64
+ context.register_visitor(visitor)
65
+
66
+ # 1. Assemble standard interceptors in order
67
+ interceptors = [
68
+ CorrelationServerInterceptor(),
69
+ ExceptionServerInterceptor(),
70
+ LoggingServerInterceptor(),
71
+ TimingServerInterceptor(),
72
+ ]
73
+
74
+ # 2. Build grpc.Server instance
75
+ server = registry.build_server(
76
+ config=cfg,
77
+ interceptors=interceptors,
78
+ container=context.container,
79
+ )
80
+
81
+ # 3. Register Server and Registry into DI container
82
+ context.container.add_instance(server, declared_class=grpc.Server)
83
+ context.container.add_instance(registry)
84
+
85
+ # 4. Auto-start if configured
86
+ if cfg.auto_start:
87
+ server.start()
88
+
89
+ # 5. Store in context properties
90
+ result = GrpcBootstrapResult(
91
+ config=cfg,
92
+ server=server,
93
+ registry=registry,
94
+ )
95
+ context.properties["grpc_result"] = result
96
+ context.properties["grpc_server"] = server
97
+
98
+ def register_config(self, registry: ConfigRegistry) -> None:
99
+ """Phase 1: Register gRPC configuration schema under 'grpc'.
100
+
101
+ Args:
102
+ registry: Target ConfigRegistry instance.
103
+
104
+ Returns:
105
+ None.
106
+ """
107
+ register_grpc_config(registry)
108
+
109
+
110
+ __all__ = [
111
+ "GrpcBootstrapper",
112
+ "GrpcBootstrapResult",
113
+ ]
@@ -0,0 +1,49 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from hexastack_core.infra.decorators import config_section
4
+ from hexastack_core.infra.registries.config import ConfigRegistry
5
+
6
+
7
+ @config_section("grpc")
8
+ class HexastackGrpcConfig(BaseModel):
9
+ """Configuration schema for Hexastack gRPC server adapter.
10
+
11
+ Notes/Architectural Intent:
12
+ Configures host binding, port, worker thread pools, and reflection support.
13
+ """
14
+
15
+ host: str = Field(
16
+ default="0.0.0.0",
17
+ description="Host interface address to bind the gRPC server to.",
18
+ )
19
+ port: int = Field(
20
+ default=50051,
21
+ description="Port number to listen for incoming gRPC connections.",
22
+ )
23
+ max_workers: int = Field(
24
+ default=10,
25
+ description="Maximum worker threads allocated in the gRPC thread pool executor.",
26
+ )
27
+ enable_reflection: bool = Field(
28
+ default=True,
29
+ description="Enable gRPC Server Reflection protocol for grpcurl and postman discovery.",
30
+ )
31
+ auto_start: bool = Field(
32
+ default=False,
33
+ description="Automatically start the gRPC server in a background thread on bootstrap.",
34
+ )
35
+
36
+
37
+ __all__ = [
38
+ "HexastackGrpcConfig",
39
+ "register_grpc_config",
40
+ ]
41
+
42
+
43
+ def register_grpc_config(registry: ConfigRegistry) -> None:
44
+ """Register gRPC configuration schema under 'grpc'.
45
+
46
+ Args:
47
+ registry: Target ConfigRegistry instance.
48
+ """
49
+ registry.register_config_section("grpc", HexastackGrpcConfig)
@@ -0,0 +1,62 @@
1
+ from collections.abc import Callable, Sequence
2
+ from typing import Any
3
+
4
+ from hexastack_grpc.infra.registries.service import (
5
+ GrpcServiceRegistration,
6
+ GrpcServiceRegistry,
7
+ )
8
+
9
+ _GRPC_SERVICE_ATTR = "__hexastack_grpc_service__"
10
+
11
+ _default_registry = GrpcServiceRegistry()
12
+
13
+
14
+ __all__ = [
15
+ "get_grpc_registry",
16
+ "grpc_service",
17
+ ]
18
+
19
+
20
+ def get_grpc_registry() -> GrpcServiceRegistry:
21
+ """Return the global default GrpcServiceRegistry instance.
22
+
23
+ Returns:
24
+ GrpcServiceRegistry instance.
25
+ """
26
+ return _default_registry
27
+
28
+
29
+ def grpc_service(
30
+ add_to_server_fn: Callable[[Any, Any], None],
31
+ *,
32
+ service_names: Sequence[str] = (),
33
+ ) -> Callable[[Any], Any]:
34
+ """Decorator registering a gRPC servicer class or instance with its protobuf hook.
35
+
36
+ Notes/Architectural Intent:
37
+ Attaches metadata for single-pass module scanning and registers the servicer
38
+ in the default GrpcServiceRegistry.
39
+
40
+ Args:
41
+ add_to_server_fn: Generated protobuf hook (e.g. add_UserServiceServicer_to_server).
42
+ service_names: Optional sequence of full service names for reflection.
43
+
44
+ Returns:
45
+ Decorator function.
46
+ """
47
+
48
+ def decorator(target: Any) -> Any:
49
+ meta = GrpcServiceRegistration(
50
+ servicer=target,
51
+ add_to_server_fn=add_to_server_fn,
52
+ service_names=service_names,
53
+ )
54
+ setattr(target, _GRPC_SERVICE_ATTR, meta)
55
+ _default_registry.register_service(
56
+ servicer=target,
57
+ add_to_server_fn=add_to_server_fn,
58
+ service_names=service_names,
59
+ )
60
+ return target
61
+
62
+ return decorator
@@ -0,0 +1,101 @@
1
+ import inspect
2
+ from typing import Any
3
+
4
+ from rodi import Container
5
+
6
+ from hexastack_core.domain.command import Command
7
+ from hexastack_core.domain.query import Query
8
+ from hexastack_core.utils.inspection import extract_dto_fields
9
+ from hexastack_cqrs.ports.buses import CommandBusPort, QueryBusPort
10
+
11
+ __all__ = [
12
+ "dispatch_rpc_command",
13
+ "dispatch_rpc_command_async",
14
+ "dispatch_rpc_query",
15
+ "dispatch_rpc_query_async",
16
+ ]
17
+
18
+
19
+ def dispatch_rpc_command[C: Command, R](
20
+ request: Any,
21
+ command_cls: type[C],
22
+ container: Container,
23
+ **extra_kwargs: Any,
24
+ ) -> R:
25
+ """Instantiate and dispatch a Command from a gRPC protobuf request.
26
+
27
+ Notes/Architectural Intent:
28
+ Bridges gRPC RPC handlers to CQRS CommandBusPort with automatic field mapping.
29
+
30
+ Args:
31
+ request: Incoming Protobuf request object.
32
+ command_cls: Target Command DTO class.
33
+ container: Active rodi DI Container.
34
+ **extra_kwargs: Additional parameters overriding request fields.
35
+
36
+ Returns:
37
+ Command execution result.
38
+ """
39
+ fields = extract_dto_fields(request, command_cls)
40
+ fields.update(extra_kwargs)
41
+ command = command_cls(**fields)
42
+ bus = container.resolve(CommandBusPort)
43
+ return bus.dispatch(command) # type: ignore[return-value]
44
+
45
+
46
+ async def dispatch_rpc_command_async[C: Command, R](
47
+ request: Any,
48
+ command_cls: type[C],
49
+ container: Container,
50
+ **extra_kwargs: Any,
51
+ ) -> R:
52
+ """Asynchronously instantiate and dispatch a Command from a gRPC request."""
53
+ fields = extract_dto_fields(request, command_cls)
54
+ fields.update(extra_kwargs)
55
+ command = command_cls(**fields)
56
+ bus = container.resolve(CommandBusPort)
57
+ res = bus.dispatch(command)
58
+ if inspect.isawaitable(res):
59
+ res = await res
60
+ return res # type: ignore[return-value]
61
+
62
+
63
+ def dispatch_rpc_query[Q: Query, R](
64
+ request: Any,
65
+ query_cls: type[Q],
66
+ container: Container,
67
+ **extra_kwargs: Any,
68
+ ) -> R:
69
+ """Instantiate and dispatch a Query from a gRPC protobuf request.
70
+
71
+ Args:
72
+ request: Incoming Protobuf request object.
73
+ query_cls: Target Query DTO class.
74
+ container: Active rodi DI Container.
75
+ **extra_kwargs: Additional parameters overriding request fields.
76
+
77
+ Returns:
78
+ Query execution result.
79
+ """
80
+ fields = extract_dto_fields(request, query_cls)
81
+ fields.update(extra_kwargs)
82
+ query = query_cls(**fields)
83
+ bus = container.resolve(QueryBusPort)
84
+ return bus.dispatch(query) # type: ignore[return-value]
85
+
86
+
87
+ async def dispatch_rpc_query_async[Q: Query, R](
88
+ request: Any,
89
+ query_cls: type[Q],
90
+ container: Container,
91
+ **extra_kwargs: Any,
92
+ ) -> R:
93
+ """Asynchronously instantiate and dispatch a Query from a gRPC request."""
94
+ fields = extract_dto_fields(request, query_cls)
95
+ fields.update(extra_kwargs)
96
+ query = query_cls(**fields)
97
+ bus = container.resolve(QueryBusPort)
98
+ res = bus.dispatch(query)
99
+ if inspect.isawaitable(res):
100
+ res = await res
101
+ return res # type: ignore[return-value]
File without changes
@@ -0,0 +1,67 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
4
+ import grpc
5
+
6
+ from hexastack_core.utils.context import (
7
+ correlation_scope,
8
+ new_correlation_id,
9
+ )
10
+ from hexastack_grpc.infra.interceptors.generic import (
11
+ AsyncGenericServerInterceptor,
12
+ GenericServerInterceptor,
13
+ )
14
+
15
+ _CORRELATION_METADATA_KEY = "x-correlation-id"
16
+
17
+
18
+ class CorrelationServerInterceptor(GenericServerInterceptor):
19
+ """Synchronous gRPC Server Interceptor for correlation ID propagation.
20
+
21
+ Notes/Architectural Intent:
22
+ Extracts 'x-correlation-id' from incoming gRPC invocation metadata,
23
+ or generates a fresh UUID4, setting it in ContextVar for the RPC duration.
24
+ """
25
+
26
+ def _handle_unary(
27
+ self,
28
+ request: Any,
29
+ context: grpc.ServicerContext,
30
+ unary_fn: Callable[[Any, grpc.ServicerContext], Any],
31
+ handler_call_details: grpc.HandlerCallDetails,
32
+ ) -> Any:
33
+ """Attach a correlation scope for the duration of the unary RPC call."""
34
+ cid = _extract_cid(handler_call_details.invocation_metadata)
35
+ with correlation_scope(cid):
36
+ return unary_fn(request, context)
37
+
38
+
39
+ class AsyncCorrelationServerInterceptor(AsyncGenericServerInterceptor):
40
+ """Asynchronous gRPC Server Interceptor for correlation ID propagation."""
41
+
42
+ async def _handle_unary_async(
43
+ self,
44
+ request: Any,
45
+ context: grpc.aio.ServicerContext,
46
+ unary_fn: Callable[[Any, grpc.aio.ServicerContext], Any],
47
+ handler_call_details: grpc.HandlerCallDetails,
48
+ ) -> Any:
49
+ """Attach a correlation scope for the duration of the async unary RPC call."""
50
+ cid = _extract_cid(handler_call_details.invocation_metadata)
51
+ with correlation_scope(cid):
52
+ return await unary_fn(request, context)
53
+
54
+
55
+ __all__ = [
56
+ "AsyncCorrelationServerInterceptor",
57
+ "CorrelationServerInterceptor",
58
+ ]
59
+
60
+
61
+ def _extract_cid(metadata: Any) -> str:
62
+ """Extract correlation ID from gRPC invocation metadata or generate fresh UUID."""
63
+ if metadata:
64
+ for key, val in metadata:
65
+ if key.lower() == _CORRELATION_METADATA_KEY:
66
+ return val.decode("utf-8") if isinstance(val, bytes) else str(val)
67
+ return new_correlation_id()
@@ -0,0 +1,78 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
4
+ import grpc
5
+
6
+ from hexastack_core.domain.exceptions import HexastackError
7
+ from hexastack_grpc.infra.interceptors.generic import (
8
+ AsyncGenericServerInterceptor,
9
+ GenericServerInterceptor,
10
+ )
11
+
12
+
13
+ class ExceptionServerInterceptor(GenericServerInterceptor):
14
+ """Synchronous gRPC Server Interceptor mapping domain exceptions to gRPC status codes.
15
+
16
+ Notes/Architectural Intent:
17
+ Intercepts unhandled HexastackError domain exceptions and aborts the RPC
18
+ call with appropriate native gRPC StatusCodes (NOT_FOUND, INVALID_ARGUMENT, etc.).
19
+ """
20
+
21
+ def _handle_unary(
22
+ self,
23
+ request: Any,
24
+ context: grpc.ServicerContext,
25
+ unary_fn: Callable[[Any, grpc.ServicerContext], Any],
26
+ handler_call_details: grpc.HandlerCallDetails,
27
+ ) -> Any:
28
+ """Invoke the unary handler, aborting the RPC on any domain exception."""
29
+ try:
30
+ return unary_fn(request, context)
31
+ except Exception as exc: # noqa: BLE001
32
+ status_code, details = _map_exception_to_status_code(exc)
33
+ context.abort(status_code, details)
34
+
35
+
36
+ class AsyncExceptionServerInterceptor(AsyncGenericServerInterceptor):
37
+ """Asynchronous gRPC Server Interceptor mapping domain exceptions to gRPC status codes."""
38
+
39
+ async def _handle_unary_async(
40
+ self,
41
+ request: Any,
42
+ context: grpc.aio.ServicerContext,
43
+ unary_fn: Callable[[Any, grpc.aio.ServicerContext], Any],
44
+ handler_call_details: grpc.HandlerCallDetails,
45
+ ) -> Any:
46
+ """Invoke the async unary handler, aborting the RPC on any domain exception."""
47
+ try:
48
+ return await unary_fn(request, context)
49
+ except Exception as exc: # noqa: BLE001
50
+ status_code, details = _map_exception_to_status_code(exc)
51
+ await context.abort(status_code, details)
52
+
53
+
54
+ __all__ = [
55
+ "AsyncExceptionServerInterceptor",
56
+ "ExceptionServerInterceptor",
57
+ ]
58
+
59
+
60
+ def _map_exception_to_status_code(exc: Exception) -> tuple[grpc.StatusCode, str]:
61
+ """Map domain exceptions to gRPC status codes and detail messages."""
62
+ exc_name = type(exc).__name__.lower()
63
+ msg = str(exc)
64
+
65
+ if "notfound" in exc_name:
66
+ return grpc.StatusCode.NOT_FOUND, msg
67
+ if "validation" in exc_name or "invalid" in exc_name:
68
+ return grpc.StatusCode.INVALID_ARGUMENT, msg
69
+ if "unauthorized" in exc_name or "authentication" in exc_name:
70
+ return grpc.StatusCode.UNAUTHENTICATED, msg
71
+ if "forbidden" in exc_name or "permission" in exc_name:
72
+ return grpc.StatusCode.PERMISSION_DENIED, msg
73
+ if "conflict" in exc_name or "alreadyexists" in exc_name:
74
+ return grpc.StatusCode.ALREADY_EXISTS, msg
75
+ if isinstance(exc, HexastackError):
76
+ return grpc.StatusCode.INTERNAL, msg
77
+
78
+ return grpc.StatusCode.UNKNOWN, msg
@@ -0,0 +1,180 @@
1
+ """Generic base classes for synchronous and asynchronous gRPC server interceptors.
2
+
3
+ Notes/Architectural Intent:
4
+ Eliminates the repeated handler-resolution, guard, unary extraction, and
5
+ method-handler rebuild boilerplate that is identical across every concrete
6
+ interceptor (correlation, exception, logging, timing, etc.).
7
+
8
+ Subclasses implement a single abstract method:
9
+ - ``_handle_unary`` (sync) or ``_handle_unary_async`` (async)
10
+
11
+ The base class owns the gRPC plumbing; the subclass owns only its
12
+ cross-cutting concern.
13
+
14
+ Supports ``grpc.ServerInterceptor`` (synchronous) and
15
+ ``grpc.aio.ServerInterceptor`` (asynchronous) via two separate hierarchies
16
+ so that typing remains strict and no coroutine/non-coroutine mixing occurs.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from abc import ABC, abstractmethod
22
+ from collections.abc import Callable
23
+ from typing import Any
24
+
25
+ import grpc
26
+
27
+
28
+ class GenericServerInterceptor(grpc.ServerInterceptor, ABC):
29
+ """Abstract base for synchronous unary-unary gRPC server interceptors.
30
+
31
+ Notes/Architectural Intent:
32
+ Owns the repetitive handler-resolution and ``unary_unary`` extraction
33
+ pattern so concrete subclasses implement only their own cross-cutting
34
+ concern inside ``_handle_unary``.
35
+
36
+ The base transparently passes through handlers for non-unary RPC types
37
+ (client-streaming, server-streaming, bidi-streaming) so subclasses do
38
+ not need to handle those cases unless they wish to.
39
+ """
40
+
41
+ @abstractmethod
42
+ def _handle_unary(
43
+ self,
44
+ request: Any,
45
+ context: grpc.ServicerContext,
46
+ unary_fn: Callable[[Any, grpc.ServicerContext], Any],
47
+ handler_call_details: grpc.HandlerCallDetails,
48
+ ) -> Any:
49
+ """Execute the cross-cutting concern around the unary handler invocation.
50
+
51
+ Args:
52
+ request: The deserialized gRPC request message.
53
+ context: The active ``grpc.ServicerContext`` for this RPC call.
54
+ unary_fn: The next-in-chain unary handler callable to invoke.
55
+ handler_call_details: Metadata about the incoming RPC call, including
56
+ method name and invocation metadata.
57
+
58
+ Returns:
59
+ The response returned by ``unary_fn`` (or a substitute value).
60
+
61
+ Raises:
62
+ Exception: Subclasses may raise, suppress, or re-raise as needed.
63
+
64
+ Notes/Architectural Intent:
65
+ Subclasses must call ``unary_fn(request, context)`` to propagate the
66
+ call downstream. They may wrap the call in try/except, prepend or
67
+ postpend logic, or modify context (e.g. abort) before/after.
68
+ """
69
+ ...
70
+
71
+ def intercept_service(
72
+ self,
73
+ continuation: Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler],
74
+ handler_call_details: grpc.HandlerCallDetails,
75
+ ) -> grpc.RpcMethodHandler:
76
+ """Intercept handler resolution, wrapping unary-unary calls.
77
+
78
+ Args:
79
+ continuation: Callable that resolves the next handler in the chain.
80
+ handler_call_details: Metadata about the incoming RPC call.
81
+
82
+ Returns:
83
+ A (possibly wrapped) ``grpc.RpcMethodHandler``.
84
+ """
85
+ handler: Any = continuation(handler_call_details)
86
+ if handler is None:
87
+ return handler
88
+
89
+ unary_fn = getattr(handler, "unary_unary", None)
90
+ if unary_fn is None:
91
+ return handler
92
+
93
+ interceptor = self
94
+
95
+ def _wrapped(request: Any, context: grpc.ServicerContext) -> Any:
96
+ return interceptor._handle_unary(
97
+ request, context, unary_fn, handler_call_details
98
+ )
99
+
100
+ return grpc.unary_unary_rpc_method_handler(
101
+ _wrapped,
102
+ request_deserializer=getattr(handler, "request_deserializer", None),
103
+ response_serializer=getattr(handler, "response_serializer", None),
104
+ )
105
+
106
+
107
+ class AsyncGenericServerInterceptor(grpc.aio.ServerInterceptor, ABC):
108
+ """Abstract base for asynchronous unary-unary gRPC server interceptors.
109
+
110
+ Notes/Architectural Intent:
111
+ Async counterpart to ``GenericServerInterceptor``. Owns the
112
+ ``await continuation(...)`` / ``await context.abort(...)`` plumbing so
113
+ concrete subclasses only implement ``_handle_unary_async``.
114
+ """
115
+
116
+ @abstractmethod
117
+ async def _handle_unary_async(
118
+ self,
119
+ request: Any,
120
+ context: grpc.aio.ServicerContext,
121
+ unary_fn: Callable[[Any, grpc.aio.ServicerContext], Any],
122
+ handler_call_details: grpc.HandlerCallDetails,
123
+ ) -> Any:
124
+ """Execute the cross-cutting concern around the async unary handler invocation.
125
+
126
+ Args:
127
+ request: The deserialized gRPC request message.
128
+ context: The active ``grpc.aio.ServicerContext`` for this RPC call.
129
+ unary_fn: The next-in-chain async unary handler coroutine to invoke.
130
+ handler_call_details: Metadata about the incoming RPC call, including
131
+ method name and invocation metadata.
132
+
133
+ Returns:
134
+ The response returned by ``await unary_fn(request, context)``.
135
+
136
+ Raises:
137
+ Exception: Subclasses may raise, suppress, or re-raise as needed.
138
+ """
139
+ ...
140
+
141
+ async def intercept_service(
142
+ self,
143
+ continuation: Callable[[grpc.HandlerCallDetails], Any],
144
+ handler_call_details: grpc.HandlerCallDetails,
145
+ ) -> Any:
146
+ """Intercept async handler resolution, wrapping unary-unary calls.
147
+
148
+ Args:
149
+ continuation: Async callable that resolves the next handler.
150
+ handler_call_details: Metadata about the incoming RPC call.
151
+
152
+ Returns:
153
+ A (possibly wrapped) RPC method handler.
154
+ """
155
+ handler: Any = await continuation(handler_call_details)
156
+ if handler is None:
157
+ return handler
158
+
159
+ unary_fn = getattr(handler, "unary_unary", None)
160
+ if unary_fn is None:
161
+ return handler
162
+
163
+ interceptor = self
164
+
165
+ async def _wrapped(request: Any, context: grpc.aio.ServicerContext) -> Any:
166
+ return await interceptor._handle_unary_async(
167
+ request, context, unary_fn, handler_call_details
168
+ )
169
+
170
+ return grpc.unary_unary_rpc_method_handler(
171
+ _wrapped,
172
+ request_deserializer=getattr(handler, "request_deserializer", None),
173
+ response_serializer=getattr(handler, "response_serializer", None),
174
+ )
175
+
176
+
177
+ __all__ = [
178
+ "AsyncGenericServerInterceptor",
179
+ "GenericServerInterceptor",
180
+ ]
@@ -0,0 +1,63 @@
1
+ import logging
2
+ import time
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ import grpc
7
+
8
+ from hexastack_grpc.infra.interceptors.generic import GenericServerInterceptor
9
+
10
+ logger = logging.getLogger("hexastack.grpc")
11
+
12
+
13
+ class LoggingServerInterceptor(GenericServerInterceptor):
14
+ """Synchronous gRPC Server Interceptor for structured RPC logging.
15
+
16
+ Notes/Architectural Intent:
17
+ Logs incoming RPC method calls and completion statuses, capturing exceptions
18
+ and logging structured error details.
19
+ """
20
+
21
+ def _handle_unary(
22
+ self,
23
+ request: Any,
24
+ context: grpc.ServicerContext,
25
+ unary_fn: Callable[[Any, grpc.ServicerContext], Any],
26
+ handler_call_details: grpc.HandlerCallDetails,
27
+ ) -> Any:
28
+ """Log the RPC call start, completion, and any errors."""
29
+ method = handler_call_details.method
30
+ logger.info("Handling gRPC call: %s", method)
31
+ try:
32
+ res = unary_fn(request, context)
33
+ logger.info("Completed gRPC call: %s", method)
34
+ return res
35
+ except Exception as e:
36
+ logger.error("Failed gRPC call %s: %s", method, e)
37
+ raise
38
+
39
+
40
+ class TimingServerInterceptor(GenericServerInterceptor):
41
+ """Synchronous gRPC Server Interceptor for measuring RPC execution latency."""
42
+
43
+ def _handle_unary(
44
+ self,
45
+ request: Any,
46
+ context: grpc.ServicerContext,
47
+ unary_fn: Callable[[Any, grpc.ServicerContext], Any],
48
+ handler_call_details: grpc.HandlerCallDetails,
49
+ ) -> Any:
50
+ """Measure and log the elapsed time of the unary RPC call."""
51
+ method = handler_call_details.method
52
+ start = time.perf_counter()
53
+ try:
54
+ return unary_fn(request, context)
55
+ finally:
56
+ duration_ms = (time.perf_counter() - start) * 1000
57
+ logger.debug("RPC %s executed in %.2fms", method, duration_ms)
58
+
59
+
60
+ __all__ = [
61
+ "LoggingServerInterceptor",
62
+ "TimingServerInterceptor",
63
+ ]
File without changes
@@ -0,0 +1,133 @@
1
+ from collections.abc import Callable, Sequence
2
+ from concurrent import futures
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import grpc
7
+ from rodi import Container
8
+
9
+ from hexastack_grpc.domain.exceptions import ServiceRegistrationError
10
+ from hexastack_grpc.infra.config import HexastackGrpcConfig
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class GrpcServiceRegistration:
15
+ """Dataclass holding registered gRPC servicer and its protobuf attachment hook."""
16
+
17
+ servicer: Any
18
+ add_to_server_fn: Callable[[Any, Any], None]
19
+ service_names: Sequence[str] = ()
20
+
21
+
22
+ class GrpcServiceRegistry:
23
+ """Registry maintaining registered gRPC servicers and compiling the server.
24
+
25
+ Notes/Architectural Intent:
26
+ Compiles registered generated protobuf servicers into a unified grpc.Server,
27
+ attaching interceptor pipelines and optional Server Reflection.
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ """Initialize empty gRPC registry."""
32
+ self._services: list[GrpcServiceRegistration] = []
33
+
34
+ def _enable_reflection(
35
+ self,
36
+ server: grpc.Server,
37
+ all_service_names: Sequence[str],
38
+ ) -> None:
39
+ """Enable gRPC Server Reflection protocol if package is present."""
40
+ try:
41
+ from grpc_reflection.v1alpha import reflection
42
+
43
+ names = tuple(all_service_names) + (reflection.SERVICE_NAME,)
44
+ reflection.enable_server_reflection(names, server)
45
+ except ImportError:
46
+ pass
47
+
48
+ def _resolve_servicer_instance(
49
+ self,
50
+ servicer_val: Any,
51
+ container: Container | None,
52
+ ) -> Any:
53
+ """Resolve or instantiate servicer instance."""
54
+ if not isinstance(servicer_val, type):
55
+ return servicer_val
56
+ if container is not None:
57
+ try:
58
+ return container.resolve(servicer_val)
59
+ except Exception: # noqa: BLE001
60
+ return servicer_val()
61
+ return servicer_val()
62
+
63
+ def build_server(
64
+ self,
65
+ config: HexastackGrpcConfig,
66
+ interceptors: Sequence[grpc.ServerInterceptor] | None = None,
67
+ container: Container | None = None,
68
+ ) -> grpc.Server:
69
+ """Construct and populate a grpc.Server instance with registered servicers.
70
+
71
+ Args:
72
+ config: HexastackGrpcConfig options.
73
+ interceptors: Optional sequence of ServerInterceptor instances.
74
+ container: Optional rodi DI Container to resolve servicer class dependencies.
75
+
76
+ Returns:
77
+ Configured grpc.Server instance.
78
+ """
79
+ thread_pool = futures.ThreadPoolExecutor(max_workers=config.max_workers)
80
+ server = grpc.server(
81
+ thread_pool=thread_pool,
82
+ interceptors=interceptors or (),
83
+ )
84
+
85
+ all_service_names: list[str] = []
86
+
87
+ for reg in self._services:
88
+ servicer_instance = self._resolve_servicer_instance(reg.servicer, container)
89
+
90
+ try:
91
+ reg.add_to_server_fn(servicer_instance, server)
92
+ all_service_names.extend(reg.service_names)
93
+ except Exception as e:
94
+ raise ServiceRegistrationError(
95
+ f"Failed to attach {servicer_instance} to gRPC server: {e}"
96
+ ) from e
97
+
98
+ if config.enable_reflection:
99
+ self._enable_reflection(server, all_service_names)
100
+
101
+ server.add_insecure_port(f"{config.host}:{config.port}")
102
+ return server
103
+
104
+ def clear(self) -> None:
105
+ """Clear all registered services (for test isolation)."""
106
+ self._services.clear()
107
+
108
+ def register_service(
109
+ self,
110
+ servicer: Any,
111
+ add_to_server_fn: Callable[[Any, Any], None],
112
+ service_names: Sequence[str] = (),
113
+ ) -> None:
114
+ """Register a gRPC servicer and its generated add_to_server hook.
115
+
116
+ Args:
117
+ servicer: Servicer instance or class.
118
+ add_to_server_fn: Generated protobuf hook (e.g. add_GreeterServicer_to_server).
119
+ service_names: Optional sequence of full service names for reflection.
120
+ """
121
+ reg = GrpcServiceRegistration(
122
+ servicer=servicer,
123
+ add_to_server_fn=add_to_server_fn,
124
+ service_names=service_names,
125
+ )
126
+ if reg not in self._services:
127
+ self._services.append(reg)
128
+
129
+
130
+ __all__ = [
131
+ "GrpcServiceRegistration",
132
+ "GrpcServiceRegistry",
133
+ ]
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.3
2
+ Name: hexastack-grpc
3
+ Version: 0.0.0
4
+ Summary: High-performance gRPC presentation adapter and interceptors for Hexastack
5
+ Author: Richard West
6
+ Author-email: Richard West <dopplereffect.us@gmail.com>
7
+ Requires-Dist: grpcio>=1.68.0
8
+ Requires-Dist: hexastack-core
9
+ Requires-Dist: hexastack-cqrs
10
+ Requires-Dist: protobuf>=5.29.0
11
+ Requires-Dist: grpcio-reflection>=1.68.0 ; extra == 'reflection'
12
+ Requires-Python: >=3.13
13
+ Provides-Extra: reflection
14
+ Description-Content-Type: text/markdown
15
+
16
+ # hexastack-grpc
17
+
18
+ > High-performance gRPC presentation adapter, interceptors, and CQRS service integration for Hexastack.
19
+
20
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
21
+
22
+ ---
23
+
24
+ ## 1. Overview & Capabilities
25
+
26
+ `hexastack-grpc` provides a binary RPC presentation adapter for Hexastack applications:
27
+
28
+ - **Declarative Service Registration (`@grpc_service`)**: Mounts generated protobuf RPC servicers onto the `grpc.Server` with automatic DI resolution.
29
+ - **Cross-Cutting Interceptor Pipeline**:
30
+ - `CorrelationServerInterceptor`: Propagates `x-correlation-id` from incoming metadata into `ContextVar`.
31
+ - `LoggingServerInterceptor`: Structured telemetry for RPC method invocations.
32
+ - `TimingServerInterceptor`: Measures RPC execution latency in milliseconds.
33
+ - **Single-Pass Reflection**: Automatically discovers and registers decorated servicers in Phase 3 module scanning via `create_grpc_visitor`.
34
+ - **gRPC Server Reflection**: Automatically enables gRPC Server Reflection Protocol when `grpcio-reflection` is installed.
35
+
36
+ ---
37
+
38
+ ## 2. Package Anatomy & Key Components
39
+
40
+ ```
41
+ hexastack_grpc/
42
+ ├── domain/ # GrpcError, ServiceRegistrationError, RpcExecutionError
43
+ └── infra/
44
+ ├── bootstrap.py # GrpcBootstrapper (order=40)
45
+ ├── config.py # HexastackGrpcConfig
46
+ ├── decorators.py# @grpc_service
47
+ ├── autodiscovery.py # create_grpc_visitor, autodiscover_grpc_services
48
+ ├── interceptors/# correlation, logging, timing interceptors
49
+ └── registries/ # service.py (GrpcServiceRegistry)
50
+ ```
51
+
52
+ ---
53
+
54
+ ## 3. Monorepo & Sibling Relationships
55
+
56
+ ```mermaid
57
+ graph TD
58
+ subgraph Clients ["gRPC Clients (Microservices, Mobile, Gateway)"]
59
+ CLIENT["gRPC Protobuf Client"]
60
+ end
61
+
62
+ subgraph GrpcAdapter ["hexastack-grpc"]
63
+ SERVER["grpc.Server"]
64
+ INTERCEPTORS["Interceptors (Correlation, Logging, Timing)"]
65
+ REG["GrpcServiceRegistry"]
66
+ SERVICER["CQRS Protobuf Servicer"]
67
+ end
68
+
69
+ subgraph CQRSExecution ["hexastack-cqrs"]
70
+ CBUS["CommandBusPort"]
71
+ QBUS["QueryBusPort"]
72
+ end
73
+
74
+ CLIENT --> SERVER
75
+ SERVER --> INTERCEPTORS
76
+ INTERCEPTORS --> SERVICER
77
+ SERVICER -->|dispatches commands| CBUS
78
+ SERVICER -->|dispatches queries| QBUS
79
+ ```
80
+
81
+ ### Explicit Dependencies (Direct)
82
+ - `hexastack-core`: DI container (`rodi`), configuration registry, context variables.
83
+ - `hexastack-cqrs`: `CommandBusPort` and `QueryBusPort`.
84
+ - `grpcio>=1.68.0`, `protobuf>=5.29.0`.
85
+
86
+ ### Optional Integrations (Extras)
87
+ - `[reflection]`: Installs `grpcio-reflection>=1.68.0` for runtime service reflection tools (`grpcurl`, Postman).
88
+
89
+ ---
90
+
91
+ ## 4. Installation
92
+
93
+ ```bash
94
+ # Standalone installation
95
+ pip install hexastack-grpc
96
+
97
+ # With Server Reflection support
98
+ pip install "hexastack-grpc[reflection]"
99
+
100
+ # Via umbrella package
101
+ pip install "hexastack[grpc]"
102
+ ```
103
+
104
+ ---
105
+
106
+ ## 5. Configuration Reference
107
+
108
+ ```toml
109
+ [hexastack.grpc]
110
+ host = "0.0.0.0"
111
+ port = 50051
112
+ max_workers = 10
113
+ enable_reflection = true
114
+ auto_start = false
115
+ ```
116
+
117
+ ---
118
+
119
+ ## 6. Quickstart Example
120
+
121
+ ```python
122
+ from hexastack_core.infra.bootstrap import bootstrap
123
+ from hexastack_grpc.infra.decorators import grpc_service
124
+
125
+
126
+ # 1. Define Servicer implementing generated protobuf class
127
+ class GreeterServicer:
128
+ def SayHello(self, request, context):
129
+ return HelloReply(message=f"Hello, {request.name}!")
130
+
131
+
132
+ # 2. Register Servicer with generated add hook
133
+ @grpc_service(add_GreeterServicer_to_server)
134
+ class GreeterService(GreeterServicer):
135
+ pass
136
+
137
+
138
+ # 3. Bootstrap Runtime and Start Server
139
+ runtime = bootstrap(packages_to_scan=[__name__])
140
+ grpc_server = runtime.get("grpc_server")
141
+ grpc_server.start()
142
+ ```
@@ -0,0 +1,22 @@
1
+ hexastack_grpc/__init__.py,sha256=xriYulIgr70neOvjzTKco0oP3gAiLEjjL-tv6OYAdnI,109
2
+ hexastack_grpc/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ hexastack_grpc/adapters/server.py,sha256=uRY_E4GEAbEgtPC5rLYTF1V58pqI5m2hK0XGND_aicE,1168
4
+ hexastack_grpc/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ hexastack_grpc/domain/exceptions.py,sha256=ZUJ-TRNFXb2B-z3D7DL0odocsJsP-FfPf9Ngw4ychIU,654
6
+ hexastack_grpc/infra/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ hexastack_grpc/infra/autodiscovery.py,sha256=oxAMcPLvztdQzVt-r2GO6CEAR1dKG1A-bNCQXyZEKeo,1875
8
+ hexastack_grpc/infra/bootstrap.py,sha256=LpvodIFhVUFrwvaKN96kb-pdcokVpWeSGitzgKWonUM,3449
9
+ hexastack_grpc/infra/config.py,sha256=6Mv1LJ1Aq9i3mAuaRbIOCRiug4eIWk-DlNbePkSRQls,1462
10
+ hexastack_grpc/infra/decorators.py,sha256=l2TexzEmSercWrKjUNzhC65XDyxzWQkowBpwL6PPlQ4,1634
11
+ hexastack_grpc/infra/dispatch.py,sha256=lx_LIWXenMbsOHRXS-M2W4UobK0QD2Yt6W92cP31ASM,3045
12
+ hexastack_grpc/infra/interceptors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ hexastack_grpc/infra/interceptors/correlation.py,sha256=EHAW7xDa_D3rJZ9uNi2nS19rqAEG1o4rCiivO9u5DUA,2232
14
+ hexastack_grpc/infra/interceptors/exception.py,sha256=H8w-A72fnaxcqkPJgsxHaqBfVcO7jbqixMGd_ZgG34w,2873
15
+ hexastack_grpc/infra/interceptors/generic.py,sha256=E6J6n5w46mOticlqx_IJUWD4776dK0Ih6af0ko-4eHs,6580
16
+ hexastack_grpc/infra/interceptors/logging.py,sha256=S-prIyNSjC4-izjWh2mYk59yidD1eQG_XoSPWdfe6s8,1992
17
+ hexastack_grpc/infra/registries/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ hexastack_grpc/infra/registries/service.py,sha256=IZRGnROI9Xfd0cTQWCgni6t0TFuwzH1b2De5Nfd-iho,4382
19
+ hexastack_grpc-0.0.0.dist-info/WHEEL,sha256=EmLkUISDECbcUx3FMCYOqokNOJqNp2r0d4mJzjErvvs,80
20
+ hexastack_grpc-0.0.0.dist-info/entry_points.txt,sha256=q8_q1a0lSr9Ldxaq-Zg2z-N4TxFN5XXJI2Eao5OJBOQ,82
21
+ hexastack_grpc-0.0.0.dist-info/METADATA,sha256=oAOEoc0PvLF0W2dbs2TzwfGDDHIo71f2otHhOTWBeZQ,4192
22
+ hexastack_grpc-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [hexastack.bootstrappers]
2
+ grpc = hexastack_grpc.infra.bootstrap:GrpcBootstrapper
3
+