xtr-remote-event 4.0.2__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.
Files changed (30) hide show
  1. xtr_remote_event/.agents/skills/xtr-remote-event/SKILL.md +217 -0
  2. xtr_remote_event/__init__.py +22 -0
  3. xtr_remote_event/bundle/__init__.py +8 -0
  4. xtr_remote_event/bundle/_declared_consumers.py +56 -0
  5. xtr_remote_event/bundle/remote_event_bundle.py +109 -0
  6. xtr_remote_event/bundle/remote_event_config.py +22 -0
  7. xtr_remote_event/consumer/__init__.py +8 -0
  8. xtr_remote_event/consumer/_built_consumers.py +65 -0
  9. xtr_remote_event/consumer/consumer_interface.py +29 -0
  10. xtr_remote_event/consumer/consumer_locator.py +59 -0
  11. xtr_remote_event/decorator/__init__.py +10 -0
  12. xtr_remote_event/decorator/as_remote_event_consumer.py +73 -0
  13. xtr_remote_event/exception/__init__.py +15 -0
  14. xtr_remote_event/exception/invalid_argument_error.py +15 -0
  15. xtr_remote_event/exception/logic_error.py +19 -0
  16. xtr_remote_event/exception/parse_error.py +17 -0
  17. xtr_remote_event/exception/remote_event_error.py +16 -0
  18. xtr_remote_event/messenger/__init__.py +8 -0
  19. xtr_remote_event/messenger/consume_remote_event_handler.py +50 -0
  20. xtr_remote_event/messenger/consume_remote_event_message.py +39 -0
  21. xtr_remote_event/payload_converter_interface.py +38 -0
  22. xtr_remote_event/py.typed +0 -0
  23. xtr_remote_event/recipe/__init__.py +16 -0
  24. xtr_remote_event/recipe/manifest.toml +25 -0
  25. xtr_remote_event/remote_event.py +42 -0
  26. xtr_remote_event-4.0.2.dist-info/METADATA +278 -0
  27. xtr_remote_event-4.0.2.dist-info/RECORD +30 -0
  28. xtr_remote_event-4.0.2.dist-info/WHEEL +4 -0
  29. xtr_remote_event-4.0.2.dist-info/entry_points.txt +6 -0
  30. xtr_remote_event-4.0.2.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,217 @@
1
+ ---
2
+ name: xtr-remote-event
3
+ description: How to hand an event that came from another system to the code that handles it with xtr-remote-event — one RemoteEvent value type, a one-method consumer declared with as_remote_event_consumer, a ConsumerLocator that finds it by source name, and ConsumeRemoteEventMessage so the work happens on a worker. Use when a delivery report, a payment notice, a provider callback or any inbound event needs routing to the code that handles that sender; when writing or declaring a consumer; when translating a sender's payload with a PayloadConverterInterface; when adding RemoteEventBundle to an application; or when a webhook read by xtr-webhook has to reach a consumer.
4
+ ---
5
+
6
+ # xtr-remote-event
7
+
8
+ An event from elsewhere arrives in whatever shape its sender chose. Once it is read, what the
9
+ application does with it is always the same: hand it to the code that handles events from that
10
+ sender. This package is that last step and nothing else.
11
+
12
+ ## Quick reference
13
+
14
+ - `RemoteEvent(name, id, payload)` — frozen, three fields, no behaviour. `name` is the source
15
+ and decides who is handed it; `payload` is a `dict[str, object]` as it arrived.
16
+ - `@as_remote_event_consumer("<source>")` on a class with `async def consume(self, event)`.
17
+ Stack it for several sources.
18
+ - `ConsumerLocator({"<source>": consumer})` → `await locator.get("<source>")`. Also takes a
19
+ `ServiceProviderInterface`, which is what the bundle gives it.
20
+ - `ConsumeRemoteEventMessage(type, event)` + `ConsumeRemoteEventHandler(locator)` move the work
21
+ onto a worker through [xtr-messenger](https://github.com/xterr/python-xtr).
22
+ - `PayloadConverterInterface.convert(payload)` → `RemoteEvent`. Synchronous, takes the payload
23
+ already decoded.
24
+ - In an application: activate `RemoteEventBundle`, declare consumers where they live, route
25
+ `ConsumeRemoteEventMessage`. There is no configuration module.
26
+ - Everything derives from `RemoteEventError`.
27
+
28
+ ## Declare a consumer
29
+
30
+ ```python
31
+ from xtr_remote_event import RemoteEvent, as_remote_event_consumer
32
+
33
+
34
+ @as_remote_event_consumer("acme")
35
+ class AcmeConsumer:
36
+ async def consume(self, event: RemoteEvent) -> None:
37
+ await do_something_with(event.payload)
38
+ ```
39
+
40
+ - The declaration is written onto the class, so whatever gathers consumers finds it where it
41
+ finds the class — a container's scan sees it, and a test that declares one leaves nothing
42
+ behind for the next.
43
+ - Stack the decorator for a consumer answering for several sources. The names are recorded from
44
+ the one nearest the class outwards, so reading them back reads as it was written.
45
+ - One method, returning nothing: a consumer is the end of the line, not a step in a chain.
46
+ - Async, because consuming is where the work is — a row written, a mail sent, a call back.
47
+ - An empty name, or a class with no `consume()`, raises `InvalidArgumentError` where the
48
+ declaration is written, not when the first event arrives.
49
+ - Read it back with `remote_event_consumers_declared_on(cls)` from `xtr_remote_event.decorator`.
50
+ Only what was written on that class: a subclass answers for nothing until it is decorated too.
51
+
52
+ ## Find the consumer of a source
53
+
54
+ ```python
55
+ from xtr_remote_event import ConsumerLocator
56
+
57
+ consumers = ConsumerLocator({"acme": AcmeConsumer(), "stripe": PaymentConsumer()})
58
+
59
+ consumer = await consumers.get("acme")
60
+ await consumer.consume(event)
61
+ ```
62
+
63
+ `consumers.has(name)` asks first; `provided_services()` reports what it covers. A source nothing
64
+ is registered under raises `LogicError` with
65
+ `Unable to find a consumer for message of type "<name>".`
66
+
67
+ Pass a `ServiceProviderInterface` instead of a mapping — the bundle passes a container-backed
68
+ one — and each consumer is built, with its dependencies, when an event for it first arrives.
69
+
70
+ ## Consume on a worker
71
+
72
+ ```python
73
+ from xtr_messenger import HandlersLocator, MessageBusConfig, MessageBusFactory, TransportConfig
74
+ from xtr_remote_event import ConsumerLocator
75
+ from xtr_remote_event.messenger import ConsumeRemoteEventHandler, ConsumeRemoteEventMessage
76
+
77
+ handlers = HandlersLocator()
78
+ _ = handlers.register(
79
+ ConsumeRemoteEventMessage,
80
+ ConsumeRemoteEventHandler(ConsumerLocator({"acme": AcmeConsumer()})),
81
+ )
82
+
83
+ bus = MessageBusFactory(
84
+ MessageBusConfig(
85
+ transports={"sync": TransportConfig("sync://")},
86
+ routing={ConsumeRemoteEventMessage: "sync"},
87
+ ),
88
+ handlers=handlers,
89
+ ).bus()
90
+
91
+ _ = await bus.dispatch(ConsumeRemoteEventMessage("acme", event))
92
+ ```
93
+
94
+ - The message is declared `"xtr_remote_event.consume.v1"`, so it keeps its name across a deploy.
95
+ - `type` is separate from `event.name`, so a payload converter can decide which consumer an
96
+ event belongs to without rewriting the event it read.
97
+ - It carries the event rather than an id, because nothing has stored it. A consumer that wants
98
+ it kept is the one that stores it.
99
+ - `"sync://"` handles in the dispatching process; a broker transport hands it to a worker.
100
+ - In a wired application the bundle registers the handler, so you never register it yourself.
101
+
102
+ ## Translate a sender's payload
103
+
104
+ ```python
105
+ from collections.abc import Mapping
106
+
107
+ from xtr_remote_event import PayloadConverterInterface, RemoteEvent
108
+ from xtr_remote_event.exception import ParseError
109
+
110
+
111
+ class AcmeConverter(PayloadConverterInterface):
112
+ def convert(self, payload: Mapping[str, object]) -> RemoteEvent:
113
+ kind = payload.get("kind")
114
+ if not isinstance(kind, str):
115
+ raise ParseError('The payload has no "kind" naming the event.')
116
+ return RemoteEvent(kind, str(payload["id"]), dict(payload))
117
+ ```
118
+
119
+ This is where a sender's vocabulary is translated and nowhere else: adding a sender is writing
120
+ one converter rather than touching whatever dispatches events. It takes the payload already
121
+ decoded — reading the bytes, and deciding whether they are to be trusted at all, belongs to
122
+ whatever received them — and it is synchronous, because translating a mapping reaches for
123
+ nothing.
124
+
125
+ ## Testing
126
+
127
+ A consumer is a class with one method, so test it directly:
128
+
129
+ ```python
130
+ import pytest
131
+ from xtr_remote_event import ConsumerLocator, RemoteEvent
132
+
133
+
134
+ @pytest.mark.anyio
135
+ async def test_a_paid_order_is_marked_paid() -> None:
136
+ consumer = AcmeConsumer(orders)
137
+
138
+ await consumer.consume(RemoteEvent("order.paid", "evt_1", {"order_id": "7"}))
139
+
140
+ assert await orders.is_paid("7")
141
+ ```
142
+
143
+ - `RemoteEvent` is a frozen dataclass, so two with the same three fields compare equal — assert
144
+ on the whole event rather than field by field.
145
+ - For the handler, build a `ConsumerLocator` over a recording consumer and assert what it was
146
+ handed. No bus is needed: the handler is callable.
147
+ - A missing consumer is testable without a container:
148
+ `pytest.raises(LogicError)` on `locator.get("absent")`.
149
+ - Declaring a consumer in a test leaves nothing behind, because the declaration lives on the
150
+ class.
151
+
152
+ ## Use in an application
153
+
154
+ `uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists
155
+ `RemoteEventBundle`. That is all a recipe can do here; it prints the two steps below it cannot.
156
+
157
+ 1. **Install** — `uv add "xtr-remote-event[di]"`. There are no other extras.
158
+ 2. **Activate** — `RemoteEventBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`
159
+ (`from xtr_remote_event.bundle import RemoteEventBundle`). Nothing to do when xtr-webhook's
160
+ bundle is listed, which requires this one.
161
+ 3. **Brings along** — the messenger bundle, always: the handler is registered on its bus.
162
+ 4. **Configure** — nothing. `RemoteEventConfig` takes no value, because which consumer answers
163
+ for which source is declared beside the class that consumes.
164
+ 5. **Environment** — nothing. **Ignore** — nothing.
165
+ 6. **Use** — declare consumers where they live; each is a service, so it asks for what it needs:
166
+
167
+ ```python
168
+ # app/consumer/payment_consumer.py
169
+ from xtr_remote_event import RemoteEvent
170
+ from xtr_remote_event.decorator import as_remote_event_consumer
171
+
172
+
173
+ @as_remote_event_consumer("acme")
174
+ class PaymentConsumer:
175
+ def __init__(self, orders: OrderRepository) -> None:
176
+ self._orders = orders
177
+
178
+ async def consume(self, event: RemoteEvent) -> None:
179
+ await self._orders.mark_paid(event.payload["order_id"])
180
+ ```
181
+
182
+ A class declared for two sources is registered once, so what it remembers between events does
183
+ not depend on which name the event arrived under.
184
+ 7. **Consume elsewhere** — route `ConsumeRemoteEventMessage` to a transport in
185
+ `<app>/config/messenger.py` to handle it on a worker; left unrouted it is handled in the
186
+ process that dispatched it.
187
+ 8. **Check** — `debug:bundles` shows `remote_event` as `listed` and `active`.
188
+ 9. **Remove** — drop the `BUNDLES` entry, then `uv remove xtr-remote-event` — unless xtr-webhook
189
+ is installed, which depends on it.
190
+
191
+ ## Errors
192
+
193
+ Everything derives from `RemoteEventError`.
194
+
195
+ | Error | Raised when | Also a |
196
+ | --- | --- | --- |
197
+ | `InvalidArgumentError` | a value cannot be used — an empty consumer name, a class with no `consume()` | `ValueError` |
198
+ | `ParseError` | a converter cannot read a payload as an event | `InvalidArgumentError` |
199
+ | `LogicError` | nothing is registered for a source, or what is registered is not a consumer | `RuntimeError` |
200
+
201
+ A `ParseError` is the sender's doing, so the answer is to tell the sender. A `LogicError` is
202
+ wiring the application got wrong, and is said where the event would otherwise be lost.
203
+
204
+ ## Do not
205
+
206
+ - Do not mutate a `RemoteEvent` or its `payload` in a consumer: the event is a record of
207
+ something that already happened, and the next consumer is given the same one. Copy it first.
208
+ - Do not inject the container into a consumer or into the handler. The handler takes a
209
+ `ConsumerLocator`, which answers the only question it has.
210
+ - Do not keep a table of source names in the application: declare each consumer beside the class
211
+ that consumes, which is the one place that cannot drift.
212
+ - Do not rely on a decorated base class to declare its subclasses — it does not, deliberately,
213
+ or every derived class would answer for its parent's sources.
214
+ - Do not put a provider's own vocabulary in a consumer. Translate in a
215
+ `PayloadConverterInterface` and keep the consumer about what the application does.
216
+ - Do not expect `event.name` and a message's `type` to be the same string; they are separate on
217
+ purpose.
@@ -0,0 +1,22 @@
1
+ """Events received from another system, handed to the consumer registered under their name.
2
+
3
+ An event from elsewhere — a delivery report, a payment notice, a message from a provider — is
4
+ one value type here: the name of where it came from, an id, and the payload as it arrived. A
5
+ consumer is registered under that name, and the event reaches it directly or through a message
6
+ bus.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .consumer import ConsumerInterface, ConsumerLocator
12
+ from .decorator import as_remote_event_consumer
13
+ from .payload_converter_interface import PayloadConverterInterface
14
+ from .remote_event import RemoteEvent
15
+
16
+ __all__ = [
17
+ "ConsumerInterface",
18
+ "ConsumerLocator",
19
+ "PayloadConverterInterface",
20
+ "RemoteEvent",
21
+ "as_remote_event_consumer",
22
+ ]
@@ -0,0 +1,8 @@
1
+ """The xtr-dependency-injection bundle for xtr-remote-event."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .remote_event_bundle import RemoteEventBundle
6
+ from .remote_event_config import RemoteEventConfig
7
+
8
+ __all__ = ["RemoteEventBundle", "RemoteEventConfig"]
@@ -0,0 +1,56 @@
1
+ """What a kernel's scan found declared: the class answering for each source name."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from xtr_remote_event.exception import LogicError
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import ItemsView
11
+
12
+ __all__ = ["DeclaredConsumers"]
13
+
14
+
15
+ @final
16
+ class DeclaredConsumers:
17
+ """The consumer classes one kernel found, by the name each answers for.
18
+
19
+ One kernel's own, never a registry shared by the process: two kernels in
20
+ one test run declare into one of these each, so a consumer scanned by both
21
+ is a service of both and a clash in one is not a clash in the other.
22
+ """
23
+
24
+ __slots__ = ("_by_name", "_classes")
25
+
26
+ def __init__(self) -> None:
27
+ """Start with nothing declared."""
28
+ self._by_name: dict[str, type[object]] = {}
29
+ self._classes: set[type[object]] = set()
30
+
31
+ def add(self, name: str, consumer: type[object]) -> bool:
32
+ """Record ``consumer`` as the consumer of the source ``name``.
33
+
34
+ Returns whether ``consumer`` is new here, so the caller registers it as
35
+ a service once however many names it answers for — one instance serving
36
+ every source it declared, rather than one per name.
37
+
38
+ Raises:
39
+ LogicError: If another class already answers for ``name``. Which
40
+ of the two should have it is a choice only the application can
41
+ make, and either answer silently loses the events of the other.
42
+ """
43
+ claimed = self._by_name.get(name)
44
+ if claimed is not None and claimed is not consumer:
45
+ raise LogicError(
46
+ f'The remote event consumer name "{name}" is declared by both '
47
+ f"{claimed.__qualname__} and {consumer.__qualname__}."
48
+ )
49
+ self._by_name[name] = consumer
50
+ is_new = consumer not in self._classes
51
+ self._classes.add(consumer)
52
+ return is_new
53
+
54
+ def items(self) -> ItemsView[str, type[object]]:
55
+ """Return each source name with the class that answers for it."""
56
+ return self._by_name.items()
@@ -0,0 +1,109 @@
1
+ """The xtr-remote-event bundle: declared consumers, reachable by the name of their source.
2
+
3
+ An application listing :class:`RemoteEventBundle` — which brings
4
+ ``MessengerBundle`` with it — gets every class declared with
5
+ ``@as_remote_event_consumer`` built by the container with its dependencies,
6
+ and a ``ConsumeRemoteEventMessage`` dispatched on the bus handed to whichever
7
+ of them answers for its source. The handler is registered with it, so a wired
8
+ application never meets ``NoHandlerForMessageError`` for that message.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Final, final
14
+
15
+ from typing_extensions import override
16
+ from xtr_dependency_injection import (
17
+ Bundle,
18
+ ContainerBuilder,
19
+ ServiceConfigurator,
20
+ ServiceLocator,
21
+ as_bundle,
22
+ named_factory,
23
+ required_bundle,
24
+ )
25
+ from xtr_messenger.bundle import MessengerBundle
26
+
27
+ from xtr_remote_event.consumer.consumer_interface import ConsumerInterface
28
+ from xtr_remote_event.consumer.consumer_locator import ConsumerLocator
29
+ from xtr_remote_event.decorator import remote_event_consumers_declared_on
30
+ from xtr_remote_event.exception import LogicError
31
+
32
+ from ._declared_consumers import DeclaredConsumers
33
+ from .remote_event_config import RemoteEventConfig
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Callable, Coroutine
37
+
38
+ __all__ = ["RemoteEventBundle"]
39
+
40
+ _CONSUMER_LOCATOR: Final = "remote_event_consumers"
41
+
42
+ _NO_CONTAINER: Final = "the remote event bundle has no container; it is used before it booted"
43
+
44
+ _HANDLER_MODULE: Final = "xtr_remote_event.messenger.consume_remote_event_handler"
45
+
46
+
47
+ @final
48
+ @required_bundle(MessengerBundle)
49
+ @as_bundle("remote_event", config=RemoteEventConfig)
50
+ class RemoteEventBundle(Bundle[RemoteEventConfig]):
51
+ """Wires declared consumers to the container, and the handler to the message bus."""
52
+
53
+ def __init__(self) -> None:
54
+ """Start with nothing declared."""
55
+ self._declared = DeclaredConsumers()
56
+
57
+ @override
58
+ def build(self, builder: ContainerBuilder) -> None:
59
+ """Collect every declared consumer, and register each class as one service.
60
+
61
+ Unqualified and once, however many sources a class declared: a
62
+ consumer answering for two of them is one instance answering for both,
63
+ so what it remembers between events does not depend on which name the
64
+ event arrived under.
65
+ """
66
+ declared = self._declared
67
+
68
+ def register(obj: object, name: str, services: ServiceConfigurator) -> None:
69
+ if isinstance(obj, type) and declared.add(name, obj):
70
+ _ = services.set(obj)
71
+
72
+ builder.register_attribute_for_autoconfiguration(
73
+ remote_event_consumers_declared_on, register
74
+ )
75
+
76
+ @override
77
+ def load_extension(
78
+ self,
79
+ config: RemoteEventConfig,
80
+ services: ServiceConfigurator,
81
+ builder: ContainerBuilder,
82
+ ) -> None:
83
+ """Register the consumer locator, and the handler that looks consumers up in it."""
84
+ del config, builder
85
+ _ = services.set(named_factory(_consumer_locator(self, self._declared), _CONSUMER_LOCATOR))
86
+ services.load(_HANDLER_MODULE)
87
+
88
+
89
+ def _consumer_locator(
90
+ bundle: RemoteEventBundle, declared: DeclaredConsumers
91
+ ) -> Callable[[], Coroutine[None, None, ConsumerLocator]]:
92
+ """Return a factory serving the container's consumers through a :class:`ServiceLocator`.
93
+
94
+ The locator is what the handler is given, so the handler never holds the
95
+ container, and a consumer is built — with its dependencies — only when an
96
+ event for its source first arrives.
97
+ """
98
+
99
+ async def build() -> ConsumerLocator:
100
+ container = bundle.container
101
+ if container is None:
102
+ raise LogicError(_NO_CONTAINER)
103
+ return ConsumerLocator(
104
+ ServiceLocator[ConsumerInterface](
105
+ container, {name: (cls, None) for name, cls in declared.items()}
106
+ )
107
+ )
108
+
109
+ return build
@@ -0,0 +1,22 @@
1
+ """Configuration for the remote event bundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import final
7
+
8
+ __all__ = ["RemoteEventConfig"]
9
+
10
+
11
+ @final
12
+ @dataclass(frozen=True, slots=True)
13
+ class RemoteEventConfig:
14
+ """What the remote event bundle builds from: nothing.
15
+
16
+ Which consumer answers for which source is declared beside each class
17
+ with ``as_remote_event_consumer``, where the code that consumes is — never
18
+ in a table an application keeps in step with it by hand. That leaves this
19
+ bundle with no value to take, so the type exists only to give the bundle
20
+ the shape every bundle has, and to leave somewhere for a setting to go if
21
+ one is ever needed.
22
+ """
@@ -0,0 +1,8 @@
1
+ """What an event from another system is handed to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .consumer_interface import ConsumerInterface
6
+ from .consumer_locator import ConsumerLocator
7
+
8
+ __all__ = ["ConsumerInterface", "ConsumerLocator"]
@@ -0,0 +1,65 @@
1
+ """Consumers already built, handed over by name."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import MappingProxyType
6
+ from typing import TYPE_CHECKING, final
7
+
8
+ from typing_extensions import override
9
+ from xtr_service_contracts import ServiceProviderInterface
10
+
11
+ from xtr_remote_event.exception import LogicError
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Hashable, Mapping
15
+
16
+ from .consumer_interface import ConsumerInterface
17
+
18
+ __all__ = ["BuiltConsumers", "missing_consumer"]
19
+
20
+
21
+ def missing_consumer(name: Hashable) -> LogicError:
22
+ """Return the error for an event of ``name`` that nothing is registered to consume.
23
+
24
+ One sentence, written once, because both ways of reaching a consumer end
25
+ at it: a mapping that never had the name, and a container's locator that
26
+ was never told about it. A consumer arriving under one name and being
27
+ asked for under another is the same mistake either way.
28
+ """
29
+ return LogicError(f'Unable to find a consumer for message of type "{name}".')
30
+
31
+
32
+ @final
33
+ class BuiltConsumers(ServiceProviderInterface["ConsumerInterface"]):
34
+ """Hands over consumers from a mapping — the locator for code with no container."""
35
+
36
+ __slots__ = ("_consumers",)
37
+
38
+ def __init__(self, consumers: Mapping[str, ConsumerInterface]) -> None:
39
+ """Hand over ``consumers``, by name.
40
+
41
+ The mapping is copied: what it holds is fixed once a locator is built,
42
+ so a caller still writing to its own dict cannot change who answers
43
+ for a source halfway through a worker's run.
44
+ """
45
+ self._consumers = dict(consumers)
46
+
47
+ @override
48
+ async def get(self, name: Hashable, /) -> ConsumerInterface:
49
+ """Return the consumer registered under ``name``.
50
+
51
+ Raises:
52
+ LogicError: If there is none.
53
+ """
54
+ consumer = self._consumers.get(str(name))
55
+ if consumer is None:
56
+ raise missing_consumer(name)
57
+ return consumer
58
+
59
+ @override
60
+ def has(self, name: Hashable, /) -> bool:
61
+ return str(name) in self._consumers
62
+
63
+ @override
64
+ def provided_services(self) -> Mapping[Hashable, type[object]]:
65
+ return MappingProxyType({name: type(c) for name, c in self._consumers.items()})
@@ -0,0 +1,29 @@
1
+ """What an event from another system is handed to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
6
+
7
+ if TYPE_CHECKING:
8
+ from xtr_remote_event.remote_event import RemoteEvent
9
+
10
+ __all__ = ["ConsumerInterface"]
11
+
12
+
13
+ @runtime_checkable
14
+ class ConsumerInterface(Protocol):
15
+ """Does whatever this application does with the events of a source.
16
+
17
+ One method, returning nothing: a consumer is the end of the line, not a
18
+ step in a chain. Which sources it answers for is not its business either —
19
+ that is declared beside the class with ``as_remote_event_consumer``, so one
20
+ consumer can serve several sources, and a source can be moved to another
21
+ consumer without either of them changing.
22
+
23
+ Async because consuming is where the work is: a row written, a mail sent,
24
+ a call back to the sender.
25
+ """
26
+
27
+ async def consume(self, event: RemoteEvent) -> None:
28
+ """Do what this application does with ``event``."""
29
+ ...
@@ -0,0 +1,59 @@
1
+ """Consumers by the name of the source they answer for."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import TYPE_CHECKING, final
7
+
8
+ from typing_extensions import override
9
+ from xtr_service_contracts import ServiceProviderInterface
10
+
11
+ from ._built_consumers import BuiltConsumers, missing_consumer
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Hashable
15
+
16
+ from .consumer_interface import ConsumerInterface
17
+
18
+ __all__ = ["ConsumerLocator"]
19
+
20
+
21
+ @final
22
+ class ConsumerLocator(ServiceProviderInterface["ConsumerInterface"]):
23
+ """Hands over the consumer of a source, by the name the source is known under.
24
+
25
+ Over consumers already built, or over a container's locator that builds
26
+ each one — with its dependencies — when an event for it first arrives.
27
+ The handler takes this rather than either, so where the consumers come
28
+ from is the application's business and not the handler's.
29
+ """
30
+
31
+ __slots__ = ("_consumers",)
32
+
33
+ def __init__(
34
+ self,
35
+ consumers: Mapping[str, ConsumerInterface] | ServiceProviderInterface[ConsumerInterface],
36
+ ) -> None:
37
+ """Hand over ``consumers``, by name."""
38
+ self._consumers: ServiceProviderInterface[ConsumerInterface] = (
39
+ BuiltConsumers(consumers) if isinstance(consumers, Mapping) else consumers
40
+ )
41
+
42
+ @override
43
+ async def get(self, name: Hashable, /) -> ConsumerInterface:
44
+ """Return the consumer of the source ``name``.
45
+
46
+ Raises:
47
+ LogicError: If there is none.
48
+ """
49
+ if not self._consumers.has(name):
50
+ raise missing_consumer(name)
51
+ return await self._consumers.get(name)
52
+
53
+ @override
54
+ def has(self, name: Hashable, /) -> bool:
55
+ return self._consumers.has(name)
56
+
57
+ @override
58
+ def provided_services(self) -> Mapping[Hashable, type[object]]:
59
+ return self._consumers.provided_services()
@@ -0,0 +1,10 @@
1
+ """The declarations an application writes beside its consumers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .as_remote_event_consumer import (
6
+ as_remote_event_consumer,
7
+ remote_event_consumers_declared_on,
8
+ )
9
+
10
+ __all__ = ["as_remote_event_consumer", "remote_event_consumers_declared_on"]