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.
- xtr_remote_event/.agents/skills/xtr-remote-event/SKILL.md +217 -0
- xtr_remote_event/__init__.py +22 -0
- xtr_remote_event/bundle/__init__.py +8 -0
- xtr_remote_event/bundle/_declared_consumers.py +56 -0
- xtr_remote_event/bundle/remote_event_bundle.py +109 -0
- xtr_remote_event/bundle/remote_event_config.py +22 -0
- xtr_remote_event/consumer/__init__.py +8 -0
- xtr_remote_event/consumer/_built_consumers.py +65 -0
- xtr_remote_event/consumer/consumer_interface.py +29 -0
- xtr_remote_event/consumer/consumer_locator.py +59 -0
- xtr_remote_event/decorator/__init__.py +10 -0
- xtr_remote_event/decorator/as_remote_event_consumer.py +73 -0
- xtr_remote_event/exception/__init__.py +15 -0
- xtr_remote_event/exception/invalid_argument_error.py +15 -0
- xtr_remote_event/exception/logic_error.py +19 -0
- xtr_remote_event/exception/parse_error.py +17 -0
- xtr_remote_event/exception/remote_event_error.py +16 -0
- xtr_remote_event/messenger/__init__.py +8 -0
- xtr_remote_event/messenger/consume_remote_event_handler.py +50 -0
- xtr_remote_event/messenger/consume_remote_event_message.py +39 -0
- xtr_remote_event/payload_converter_interface.py +38 -0
- xtr_remote_event/py.typed +0 -0
- xtr_remote_event/recipe/__init__.py +16 -0
- xtr_remote_event/recipe/manifest.toml +25 -0
- xtr_remote_event/remote_event.py +42 -0
- xtr_remote_event-4.0.2.dist-info/METADATA +278 -0
- xtr_remote_event-4.0.2.dist-info/RECORD +30 -0
- xtr_remote_event-4.0.2.dist-info/WHEEL +4 -0
- xtr_remote_event-4.0.2.dist-info/entry_points.txt +6 -0
- 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,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"]
|