austial-microservices 0.5.0__tar.gz

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.
Files changed (24) hide show
  1. austial_microservices-0.5.0/.gitignore +15 -0
  2. austial_microservices-0.5.0/PKG-INFO +22 -0
  3. austial_microservices-0.5.0/pyproject.toml +34 -0
  4. austial_microservices-0.5.0/src/austial/microservices/__init__.py +65 -0
  5. austial_microservices-0.5.0/src/austial/microservices/client.py +71 -0
  6. austial_microservices-0.5.0/src/austial/microservices/decorators.py +55 -0
  7. austial_microservices-0.5.0/src/austial/microservices/factory.py +109 -0
  8. austial_microservices-0.5.0/src/austial/microservices/microservice.py +52 -0
  9. austial_microservices-0.5.0/src/austial/microservices/server.py +172 -0
  10. austial_microservices-0.5.0/src/austial/microservices/transport.py +276 -0
  11. austial_microservices-0.5.0/src/austial/microservices/transports/__init__.py +36 -0
  12. austial_microservices-0.5.0/src/austial/microservices/transports/grpc.py +114 -0
  13. austial_microservices-0.5.0/src/austial/microservices/transports/grpc_proto.py +360 -0
  14. austial_microservices-0.5.0/src/austial/microservices/transports/kafka.py +77 -0
  15. austial_microservices-0.5.0/src/austial/microservices/transports/mqtt.py +61 -0
  16. austial_microservices-0.5.0/src/austial/microservices/transports/nats.py +48 -0
  17. austial_microservices-0.5.0/src/austial/microservices/transports/pubsub.py +177 -0
  18. austial_microservices-0.5.0/src/austial/microservices/transports/rabbitmq.py +61 -0
  19. austial_microservices-0.5.0/src/austial/microservices/transports/redis.py +72 -0
  20. austial_microservices-0.5.0/src/austial/microservices/transports/redis_streams.py +94 -0
  21. austial_microservices-0.5.0/test/grpc_proto_spec.py +220 -0
  22. austial_microservices-0.5.0/test/microservices_spec.py +343 -0
  23. austial_microservices-0.5.0/test/protos/hero.proto +17 -0
  24. austial_microservices-0.5.0/test/transports_spec.py +599 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ *.db
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ .DS_Store
13
+
14
+ .claude/settings.local.json
15
+ coverage.xml
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.5
2
+ Name: austial-microservices
3
+ Version: 0.5.0
4
+ Summary: Message-driven applications: @MessagePattern / @EventPattern handlers served over TCP, in-process, Redis (Pub/Sub and Streams), NATS, Kafka, MQTT, RabbitMQ and gRPC (JSON or protobuf with streaming), plus ClientProxy and hybrid apps -- mirrors @nestjs/microservices.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: austial-core
7
+ Provides-Extra: grpc
8
+ Requires-Dist: grpcio>=1.66; extra == 'grpc'
9
+ Provides-Extra: grpc-proto
10
+ Requires-Dist: grpcio-tools>=1.66; extra == 'grpc-proto'
11
+ Requires-Dist: grpcio>=1.66; extra == 'grpc-proto'
12
+ Requires-Dist: protobuf>=5; extra == 'grpc-proto'
13
+ Provides-Extra: kafka
14
+ Requires-Dist: aiokafka>=0.11; extra == 'kafka'
15
+ Provides-Extra: mqtt
16
+ Requires-Dist: aiomqtt>=2.3; extra == 'mqtt'
17
+ Provides-Extra: nats
18
+ Requires-Dist: nats-py>=2.9; extra == 'nats'
19
+ Provides-Extra: rabbitmq
20
+ Requires-Dist: aio-pika>=9.4; extra == 'rabbitmq'
21
+ Provides-Extra: redis
22
+ Requires-Dist: redis>=5.0; extra == 'redis'
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "austial-microservices"
3
+ dynamic = ["version"]
4
+ description = "Message-driven applications: @MessagePattern / @EventPattern handlers served over TCP, in-process, Redis (Pub/Sub and Streams), NATS, Kafka, MQTT, RabbitMQ and gRPC (JSON or protobuf with streaming), plus ClientProxy and hybrid apps -- mirrors @nestjs/microservices."
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "austial-core",
8
+ ]
9
+
10
+ [project.optional-dependencies]
11
+ # Each broker library is imported lazily and raises an install hint when missing.
12
+ redis = ["redis>=5.0"]
13
+ nats = ["nats-py>=2.9"]
14
+ kafka = ["aiokafka>=0.11"]
15
+ mqtt = ["aiomqtt>=2.3"]
16
+ rabbitmq = ["aio-pika>=9.4"]
17
+ grpc = ["grpcio>=1.66"]
18
+ grpc-proto = ["grpcio>=1.66", "grpcio-tools>=1.66", "protobuf>=5"]
19
+
20
+ [build-system]
21
+ requires = ["hatchling", "hatch-vcs"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/austial"]
26
+
27
+ [tool.uv.sources]
28
+ austial-core = { workspace = true }
29
+
30
+ [tool.hatch.version]
31
+ source = "vcs"
32
+
33
+ [tool.hatch.version.raw-options]
34
+ root = "../.."
@@ -0,0 +1,65 @@
1
+ """``austial.microservices`` -- message-driven services, mirrors ``@nestjs/microservices``.
2
+
3
+ from austial.microservices import (
4
+ MessagePattern, EventPattern, Payload, Ctx, ClientsModule, ClientProxy, ClientOptions,
5
+ MicroserviceOptions, Transport, create_microservice,
6
+ )
7
+ """
8
+
9
+ from austial.microservices.client import ClientProxy, ClientsModule
10
+ from austial.microservices.decorators import Ctx, EventPattern, MessagePattern, Payload
11
+ from austial.microservices.factory import build_client_transport, build_server_transport
12
+ from austial.microservices.microservice import (
13
+ MicroserviceApplication,
14
+ attach_microservice,
15
+ create_microservice,
16
+ )
17
+ from austial.microservices.server import HandlerRegistry, MicroserviceServer, RpcContext, RpcExecutionContext
18
+ from austial.microservices.transport import (
19
+ ClientOptions,
20
+ ClientTransport,
21
+ LocalClient,
22
+ LocalServer,
23
+ MicroserviceOptions,
24
+ RpcError,
25
+ RpcException,
26
+ ServerTransport,
27
+ TcpClient,
28
+ TcpServer,
29
+ Transport,
30
+ pattern_key,
31
+ )
32
+ from austial.microservices.transports.grpc_proto import GrpcMethod
33
+ from austial.microservices.transports.pubsub import JsonCodec
34
+
35
+ __all__ = [
36
+ "ClientOptions",
37
+ "ClientProxy",
38
+ "ClientTransport",
39
+ "ClientsModule",
40
+ "Ctx",
41
+ "EventPattern",
42
+ "GrpcMethod",
43
+ "HandlerRegistry",
44
+ "JsonCodec",
45
+ "LocalClient",
46
+ "LocalServer",
47
+ "MessagePattern",
48
+ "MicroserviceApplication",
49
+ "MicroserviceOptions",
50
+ "MicroserviceServer",
51
+ "Payload",
52
+ "RpcContext",
53
+ "RpcError",
54
+ "RpcException",
55
+ "RpcExecutionContext",
56
+ "ServerTransport",
57
+ "TcpClient",
58
+ "TcpServer",
59
+ "Transport",
60
+ "attach_microservice",
61
+ "build_client_transport",
62
+ "build_server_transport",
63
+ "create_microservice",
64
+ "pattern_key",
65
+ ]
@@ -0,0 +1,71 @@
1
+ """``ClientProxy`` and ``ClientsModule`` -- mirror Nest's: talk to another microservice.
2
+
3
+ ::
4
+
5
+ @Module(imports=[ClientsModule.register([ClientOptions(name="MATH", port=3001)])])
6
+ class AppModule: ...
7
+
8
+ @Injectable()
9
+ class Calculator:
10
+ def __init__(self, math: ClientProxy = Inject("MATH")):
11
+ self.math = math
12
+
13
+ async def total(self, numbers):
14
+ return await self.math.send({"cmd": "sum"}, numbers)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ from collections.abc import Sequence
21
+ from typing import Any
22
+
23
+ from austial.common.decorators.modules import Module
24
+ from austial.core.dynamic_module import DynamicModule
25
+ from austial.microservices.factory import build_client_transport
26
+ from austial.microservices.transport import ClientOptions, ClientTransport, pattern_key
27
+
28
+
29
+ class ClientProxy:
30
+ def __init__(self, options: ClientOptions, transport: ClientTransport | None = None):
31
+ self.options = options
32
+ self.transport = transport or build_client_transport(options)
33
+
34
+ async def connect(self) -> None:
35
+ await self.transport.connect()
36
+
37
+ async def send(self, pattern: Any, data: Any = None, *, timeout: float | None = None) -> Any:
38
+ """Request-response: returns the handler's result, raises ``RpcError`` if it failed or
39
+ ``asyncio.TimeoutError`` if nothing answered in ``timeout`` seconds (default: the client's)."""
40
+ wait = timeout if timeout is not None else self.options.timeout
41
+ return await asyncio.wait_for(self.transport.request(pattern_key(pattern), data), wait)
42
+
43
+ async def emit(self, pattern: Any, data: Any = None) -> None:
44
+ """Fire-and-forget event."""
45
+ await self.transport.emit(pattern_key(pattern), data)
46
+
47
+ def get_service(self, name: str) -> Any:
48
+ """Protobuf gRPC only: a ``.proto`` service client, e.g. ``get_service("HeroService").find_one({})``."""
49
+ getter = getattr(self.transport, "get_service", None)
50
+ if getter is None:
51
+ raise TypeError("get_service() needs Transport.GRPC with extra={'proto_path': ...}")
52
+ return getter(name)
53
+
54
+ async def close(self) -> None:
55
+ await self.transport.close()
56
+
57
+ # lifecycle: connect when the app initialises, close when it shuts down
58
+ async def on_module_init(self) -> None:
59
+ await self.connect()
60
+
61
+ async def on_module_destroy(self) -> None:
62
+ await self.close()
63
+
64
+
65
+ @Module()
66
+ class ClientsModule:
67
+ @staticmethod
68
+ def register(clients: Sequence[ClientOptions]) -> DynamicModule:
69
+ """Registers one :class:`ClientProxy` per entry, injectable as ``Inject(options.name)``."""
70
+ providers = [{"provide": c.name, "useValue": ClientProxy(c)} for c in clients]
71
+ return DynamicModule(module=ClientsModule, providers=providers, exports=[c.name for c in clients])
@@ -0,0 +1,55 @@
1
+ """``@MessagePattern``, ``@EventPattern``, ``@Payload`` and ``@Ctx`` -- mirror ``@nestjs/microservices``.
2
+
3
+ ::
4
+
5
+ @Controller()
6
+ class MathController:
7
+ @MessagePattern({"cmd": "sum"}) # request-response: the return value is the reply
8
+ def accumulate(self, numbers: list[int] = Payload()) -> int:
9
+ return sum(numbers)
10
+
11
+ @EventPattern("user.created") # fire-and-forget: nothing is returned to the sender
12
+ async def on_user_created(self, user: dict = Payload()): ...
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+ from typing import Any
19
+
20
+ from austial.core.metadata import get_metadata, set_metadata
21
+
22
+ MESSAGE_PATTERN_METADATA = "austial:rpc:message_pattern"
23
+ EVENT_PATTERN_METADATA = "austial:rpc:event_pattern"
24
+
25
+
26
+ def MessagePattern(pattern: Any) -> Callable: # noqa: N802
27
+ return set_metadata(MESSAGE_PATTERN_METADATA, pattern)
28
+
29
+
30
+ def EventPattern(pattern: Any) -> Callable: # noqa: N802
31
+ def decorator(fn):
32
+ existing = list(get_metadata(EVENT_PATTERN_METADATA, fn, []))
33
+ set_metadata(EVENT_PATTERN_METADATA, [*existing, pattern])(fn)
34
+ return fn
35
+
36
+ return decorator
37
+
38
+
39
+ class PayloadMarker:
40
+ def __init__(self, key: str | None):
41
+ self.key = key
42
+
43
+
44
+ class CtxMarker:
45
+ pass
46
+
47
+
48
+ def Payload(key: str | None = None) -> Any: # noqa: N802
49
+ """The message data (or, with ``key``, one field of it)."""
50
+ return PayloadMarker(key)
51
+
52
+
53
+ def Ctx() -> Any: # noqa: N802
54
+ """An :class:`RpcContext` with the pattern that matched."""
55
+ return CtxMarker()
@@ -0,0 +1,109 @@
1
+ """Turns ``MicroserviceOptions`` / ``ClientOptions`` into the right transport object."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from austial.microservices.transport import (
8
+ ClientOptions,
9
+ ClientTransport,
10
+ LocalClient,
11
+ LocalServer,
12
+ MicroserviceOptions,
13
+ ServerTransport,
14
+ TcpClient,
15
+ TcpServer,
16
+ Transport,
17
+ )
18
+
19
+
20
+ def _split_host_port(url: str | None, default_host: str, default_port: int) -> tuple[str, int]:
21
+ if not url:
22
+ return default_host, default_port
23
+ host, _, port = url.removeprefix("mqtt://").rpartition(":")
24
+ return (host or url, int(port)) if port.isdigit() else (url, default_port)
25
+
26
+
27
+ def _backend(transport: Transport, url: str | None, group: str, extra: dict[str, Any]) -> Any:
28
+ from austial.microservices.transports import (
29
+ KafkaBackend,
30
+ MqttBackend,
31
+ NatsBackend,
32
+ RabbitMqBackend,
33
+ RedisBackend,
34
+ RedisStreamsBackend,
35
+ )
36
+
37
+ if transport == Transport.REDIS:
38
+ return RedisBackend(url or "redis://localhost:6379", **extra)
39
+ if transport == Transport.REDIS_STREAMS:
40
+ return RedisStreamsBackend(url or "redis://localhost:6379", **extra)
41
+ if transport == Transport.NATS:
42
+ return NatsBackend(url or "nats://localhost:4222", **extra)
43
+ if transport == Transport.KAFKA:
44
+ return KafkaBackend(url or "localhost:9092", group=group, **extra)
45
+ if transport == Transport.MQTT:
46
+ host, port = _split_host_port(url, "localhost", 1883)
47
+ return MqttBackend(host, port, **extra)
48
+ if transport == Transport.RMQ:
49
+ return RabbitMqBackend(url or "amqp://guest:guest@localhost/", **extra)
50
+ raise ValueError(f"Not a broker transport: {transport}")
51
+
52
+
53
+ _BROKERS = {Transport.REDIS, Transport.REDIS_STREAMS, Transport.NATS, Transport.KAFKA, Transport.MQTT, Transport.RMQ}
54
+
55
+
56
+ def build_server_transport(options: MicroserviceOptions) -> ServerTransport:
57
+ if options.transport == Transport.TCP:
58
+ return TcpServer(options.host, options.port)
59
+ if options.transport == Transport.LOCAL:
60
+ return LocalServer(options.name)
61
+ if options.transport in _BROKERS:
62
+ from austial.microservices.transports import PubSubServer
63
+
64
+ # NATS, MQTT, Kafka and Redis Streams share load through the group; RabbitMQ shares by queue
65
+ grouped = options.transport in (Transport.NATS, Transport.MQTT, Transport.REDIS_STREAMS, Transport.KAFKA)
66
+ return PubSubServer(
67
+ _backend(options.transport, options.url, options.group, options.extra),
68
+ prefix=options.prefix,
69
+ group=options.group if grouped else None,
70
+ codec=options.codec,
71
+ )
72
+ if options.transport == Transport.GRPC:
73
+ if "proto_path" in options.extra: # a .proto file: real protobuf services
74
+ from austial.microservices.transports.grpc_proto import GrpcProtoServer
75
+
76
+ return GrpcProtoServer(options.host, options.port, **options.extra)
77
+ from austial.microservices.transports import GrpcServer
78
+
79
+ return GrpcServer(options.host, options.port, **options.extra)
80
+ if options.custom_server is None:
81
+ raise ValueError("Transport.CUSTOM needs custom_server=")
82
+ return options.custom_server
83
+
84
+
85
+ def build_client_transport(options: ClientOptions) -> ClientTransport:
86
+ if options.transport == Transport.TCP:
87
+ return TcpClient(options.host, options.port, options.timeout)
88
+ if options.transport == Transport.LOCAL:
89
+ return LocalClient(options.broker)
90
+ if options.transport in _BROKERS:
91
+ from austial.microservices.transports import PubSubClient
92
+
93
+ return PubSubClient(
94
+ _backend(options.transport, options.url, "austial", options.extra),
95
+ prefix=options.prefix,
96
+ timeout=options.timeout,
97
+ codec=options.codec,
98
+ )
99
+ if options.transport == Transport.GRPC:
100
+ if "proto_path" in options.extra:
101
+ from austial.microservices.transports.grpc_proto import GrpcProtoClient
102
+
103
+ return GrpcProtoClient(options.host, options.port, timeout=options.timeout, **options.extra)
104
+ from austial.microservices.transports import GrpcClient
105
+
106
+ return GrpcClient(options.host, options.port, timeout=options.timeout, **options.extra)
107
+ if options.custom_client is None:
108
+ raise ValueError(f"Client '{options.name}': Transport.CUSTOM needs custom_client=")
109
+ return options.custom_client
@@ -0,0 +1,52 @@
1
+ """Standalone and hybrid microservices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from austial.core.application_context import AustialApplicationContext
8
+ from austial.core.container import Container
9
+ from austial.core.discovery import DiscoveryService
10
+ from austial.core.module_ref import ModuleRef
11
+ from austial.core.module_scanner import scan_module
12
+ from austial.microservices.factory import build_server_transport
13
+ from austial.microservices.server import HandlerRegistry, MicroserviceServer
14
+ from austial.microservices.transport import MicroserviceOptions
15
+
16
+
17
+ def build_server(container: Container, options: MicroserviceOptions) -> MicroserviceServer:
18
+ registry = HandlerRegistry(container.resolve(DiscoveryService), container.resolve(ModuleRef))
19
+ return MicroserviceServer(build_server_transport(options), registry)
20
+
21
+
22
+ class MicroserviceApplication(AustialApplicationContext):
23
+ """What ``create_microservice`` returns: a DI context plus a transport server.
24
+
25
+ ``await ms.listen()`` initialises the app (hooks, async providers) and starts the transport;
26
+ ``await ms.close()`` stops it and runs the shutdown hooks.
27
+ """
28
+
29
+ def __init__(self, container: Container, options: MicroserviceOptions):
30
+ super().__init__(container)
31
+ self.options = options
32
+ self.server = build_server(container, options)
33
+
34
+ async def listen(self) -> None:
35
+ await self.init()
36
+ await self.server.start()
37
+
38
+ async def close(self, signal: str | None = None) -> None:
39
+ await self.server.stop()
40
+ await super().close(signal)
41
+
42
+
43
+ def create_microservice(root_module: type, options: MicroserviceOptions) -> MicroserviceApplication:
44
+ """Mirrors ``NestFactory.createMicroservice()``: a message-driven app with no HTTP server."""
45
+ container = Container()
46
+ scan_module(root_module, container)
47
+ return MicroserviceApplication(container, options)
48
+
49
+
50
+ def attach_microservice(app: Any, options: MicroserviceOptions) -> MicroserviceServer:
51
+ """Hybrid app: serve ``options`` from the same application as HTTP. Used by ``app.connect_microservice()``."""
52
+ return build_server(app._container, options) # noqa: SLF001
@@ -0,0 +1,172 @@
1
+ """Dispatches transport messages to ``@MessagePattern`` / ``@EventPattern`` handlers found on providers and
2
+ controllers, through guards, pipes and interceptors."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import inspect
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from austial.common.guards.base import ExecutionContext
11
+ from austial.common.interceptors.base import CallHandler
12
+ from austial.common.logger import Logger
13
+ from austial.common.pipes.validation_pipe import ArgumentMetadata
14
+ from austial.core.discovery import DiscoveryService
15
+ from austial.core.metadata import GUARDS_METADATA, INTERCEPTORS_METADATA, PIPES_METADATA, get_metadata
16
+ from austial.core.module_ref import ModuleRef
17
+ from austial.microservices.decorators import (
18
+ EVENT_PATTERN_METADATA,
19
+ MESSAGE_PATTERN_METADATA,
20
+ CtxMarker,
21
+ PayloadMarker,
22
+ )
23
+ from austial.microservices.transport import RpcException, ServerTransport, pattern_key
24
+
25
+ _logger = Logger("MicroserviceServer")
26
+
27
+
28
+ @dataclass
29
+ class RpcContext:
30
+ pattern: str
31
+ kind: str # "request" | "event"
32
+
33
+
34
+ class RpcExecutionContext(ExecutionContext):
35
+ """``get_type()`` is ``"rpc"``; ``switch_to_rpc()`` gives the data and the :class:`RpcContext`."""
36
+
37
+ def __init__(self, data: Any, rpc: RpcContext, controller_class: type, handler: Any):
38
+ super().__init__(None, controller_class, handler) # type: ignore[arg-type]
39
+ self._data, self._rpc = data, rpc
40
+
41
+ def get_type(self) -> str:
42
+ return "rpc"
43
+
44
+ def switch_to_rpc(self) -> RpcExecutionContext:
45
+ return self
46
+
47
+ def get_data(self) -> Any:
48
+ return self._data
49
+
50
+ def get_context(self) -> RpcContext:
51
+ return self._rpc
52
+
53
+ def switch_to_http(self) -> ExecutionContext:
54
+ raise NotImplementedError("This is an RPC execution context: use switch_to_rpc().")
55
+
56
+
57
+ async def _maybe(value: Any) -> Any:
58
+ return await value if inspect.isawaitable(value) else value
59
+
60
+
61
+ class HandlerRegistry:
62
+ """All the handlers of one application, discovered from its providers and controllers."""
63
+
64
+ def __init__(self, discovery: DiscoveryService, module_ref: ModuleRef):
65
+ self.discovery, self.module_ref = discovery, module_ref
66
+ self._messages: dict[str, tuple[Any, Any]] = {}
67
+ self._events: dict[str, list[tuple[Any, Any]]] = {}
68
+ self._built = False
69
+
70
+ def build(self) -> None:
71
+ if self._built:
72
+ return
73
+ self._built = True
74
+ for controllers in (False, True):
75
+ for wrapper, name, pattern in self.discovery.get_methods_with_metadata(
76
+ MESSAGE_PATTERN_METADATA, controllers=controllers
77
+ ):
78
+ key = pattern_key(pattern)
79
+ if key in self._messages:
80
+ raise RuntimeError(f"Two handlers for the message pattern {key}")
81
+ self._messages[key] = (wrapper.instance, getattr(wrapper.instance, name))
82
+ for wrapper, name, patterns in self.discovery.get_methods_with_metadata(
83
+ EVENT_PATTERN_METADATA, controllers=controllers
84
+ ):
85
+ for pattern in patterns:
86
+ self._events.setdefault(pattern_key(pattern), []).append(
87
+ (wrapper.instance, getattr(wrapper.instance, name))
88
+ )
89
+
90
+ def patterns(self) -> dict[str, list[str]]:
91
+ """The pattern keys this application handles, for transports that must subscribe to topics up front."""
92
+ self.build()
93
+ return {"request": list(self._messages), "event": list(self._events)}
94
+
95
+ async def handle(self, kind: str, pattern: str, data: Any) -> Any:
96
+ self.build()
97
+ context = RpcContext(pattern, kind)
98
+ if kind == "event":
99
+ for owner, handler in self._events.get(pattern, []):
100
+ try:
101
+ await self._run(owner, handler, data, context)
102
+ except Exception as exc: # noqa: BLE001
103
+ _logger.error(f"Event handler for {pattern} raised {type(exc).__name__}: {exc}")
104
+ return None
105
+ entry = self._messages.get(pattern)
106
+ if entry is None:
107
+ raise RpcException(f"There is no matching message handler for {pattern}")
108
+ owner, handler = entry
109
+ return await self._run(owner, handler, data, context)
110
+
111
+ def _components(self, key: str, owner: Any, handler: Any) -> list[Any]:
112
+ raw = [*get_metadata(key, type(owner), []), *get_metadata(key, handler, [])]
113
+ return [self.module_ref.get(c) if isinstance(c, type) else c for c in raw]
114
+
115
+ async def _run(self, owner: Any, handler: Any, data: Any, rpc: RpcContext) -> Any:
116
+ context = RpcExecutionContext(data, rpc, type(owner), handler)
117
+ for guard in self._components(GUARDS_METADATA, owner, handler):
118
+ if not await _maybe(guard.can_activate(context)):
119
+ raise RpcException("Forbidden resource")
120
+ pipes = self._components(PIPES_METADATA, owner, handler)
121
+ kwargs: dict[str, Any] = {}
122
+ data_used = False
123
+ for name, param in inspect.signature(handler, eval_str=True).parameters.items():
124
+ default = param.default
125
+ if isinstance(default, CtxMarker):
126
+ kwargs[name] = rpc
127
+ continue
128
+ if isinstance(default, PayloadMarker):
129
+ value = data.get(default.key) if default.key and isinstance(data, dict) else data
130
+ elif not data_used and default is inspect.Parameter.empty:
131
+ value = data
132
+ else:
133
+ continue
134
+ data_used = True
135
+ for pipe in pipes:
136
+ value = await _maybe(pipe.transform(value, ArgumentMetadata(type="body", data=name, metatype=None)))
137
+ kwargs[name] = value
138
+
139
+ async def call() -> Any:
140
+ return await _maybe(handler(**kwargs))
141
+
142
+ chain = call
143
+ for interceptor in reversed(self._components(INTERCEPTORS_METADATA, owner, handler)):
144
+ chain = _wrap(interceptor, context, chain)
145
+ return await chain()
146
+
147
+
148
+ def _wrap(interceptor: Any, context: RpcExecutionContext, next_call: Any):
149
+ async def wrapped() -> Any:
150
+ return await interceptor.intercept(context, CallHandler(next_call))
151
+
152
+ return wrapped
153
+
154
+
155
+ class MicroserviceServer:
156
+ """A transport plus the application's handlers. ``await start()`` / ``await stop()``."""
157
+
158
+ def __init__(self, transport: ServerTransport, registry: HandlerRegistry):
159
+ self.transport, self.registry = transport, registry
160
+ self.running = False
161
+
162
+ async def start(self) -> None:
163
+ if self.running:
164
+ return
165
+ self.transport.patterns = self.registry.patterns()
166
+ await self.transport.listen(self.registry.handle)
167
+ self.running = True
168
+
169
+ async def stop(self) -> None:
170
+ if self.running:
171
+ await self.transport.close()
172
+ self.running = False