xtr-remote-event 4.0.2__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.
- xtr_remote_event-4.0.2/LICENSE +22 -0
- xtr_remote_event-4.0.2/PKG-INFO +278 -0
- xtr_remote_event-4.0.2/README.md +253 -0
- xtr_remote_event-4.0.2/pyproject.toml +170 -0
- xtr_remote_event-4.0.2/pyproject.toml.orig +176 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/.agents/skills/xtr-remote-event/SKILL.md +217 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/__init__.py +22 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/bundle/__init__.py +8 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/bundle/_declared_consumers.py +56 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/bundle/remote_event_bundle.py +109 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/bundle/remote_event_config.py +22 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/consumer/__init__.py +8 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/consumer/_built_consumers.py +65 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/consumer/consumer_interface.py +29 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/consumer/consumer_locator.py +59 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/decorator/__init__.py +10 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/decorator/as_remote_event_consumer.py +73 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/exception/__init__.py +15 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/exception/invalid_argument_error.py +15 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/exception/logic_error.py +19 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/exception/parse_error.py +17 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/exception/remote_event_error.py +16 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/messenger/__init__.py +8 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/messenger/consume_remote_event_handler.py +50 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/messenger/consume_remote_event_message.py +39 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/payload_converter_interface.py +38 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/py.typed +0 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/recipe/__init__.py +16 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/recipe/manifest.toml +25 -0
- xtr_remote_event-4.0.2/src/xtr_remote_event/remote_event.py +42 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
Copyright (c) 2022-present Fabien Potencier
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-remote-event
|
|
3
|
+
Version: 4.0.2
|
|
4
|
+
Summary: Events received from another system, as one value type, handed to the consumer registered under their source's name — directly or through a message bus.
|
|
5
|
+
Keywords: remote-event,event,consumer,messenger,asyncio
|
|
6
|
+
Author: Xterr
|
|
7
|
+
Author-email: Xterr <me@xterr.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: typing-extensions>=4.4
|
|
19
|
+
Requires-Dist: xtr-messenger>=4.0,<5
|
|
20
|
+
Requires-Dist: xtr-service-contracts>=4.0,<5
|
|
21
|
+
Requires-Dist: xtr-dependency-injection>=4.0,<5 ; extra == 'di'
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Provides-Extra: di
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
<div align="center">
|
|
27
|
+
|
|
28
|
+
# xtr-remote-event
|
|
29
|
+
|
|
30
|
+
**Events received from another system, as one value type, handed to the consumer registered under their source's name — directly or through a message bus.**
|
|
31
|
+
|
|
32
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
33
|
+
<img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
|
|
34
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
35
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
36
|
+
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Why?
|
|
42
|
+
|
|
43
|
+
An event from elsewhere — a delivery report, a payment notice, a message from a provider —
|
|
44
|
+
arrives in whatever shape its sender chose. Once it is read, though, what the application does
|
|
45
|
+
with it is always the same: hand it to the code that handles events from that sender.
|
|
46
|
+
|
|
47
|
+
This package is that last step, and nothing else: one value type for the event, a one-method
|
|
48
|
+
contract for the consumer, a decorator naming which sources a consumer answers for, and a
|
|
49
|
+
message so the work happens on a worker rather than in the request that received it.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
uv add xtr-remote-event # the event, the consumer contract, the decorator
|
|
55
|
+
uv add "xtr-remote-event[di]" # + the bundle
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
A consumer is a class with one method, declared with the name of the source whose events it
|
|
61
|
+
answers for:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import asyncio
|
|
65
|
+
|
|
66
|
+
from xtr_remote_event import ConsumerLocator, RemoteEvent, as_remote_event_consumer
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@as_remote_event_consumer("acme")
|
|
70
|
+
class AcmeConsumer:
|
|
71
|
+
async def consume(self, event: RemoteEvent) -> None:
|
|
72
|
+
print(f"{event.name} {event.id} {event.payload}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def main() -> None:
|
|
76
|
+
consumers = ConsumerLocator({"acme": AcmeConsumer()})
|
|
77
|
+
consumer = await consumers.get("acme")
|
|
78
|
+
await consumer.consume(RemoteEvent("order.paid", "evt_1", {"total": 4200}))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
asyncio.run(main())
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A `RemoteEvent` is three fields and no behaviour: `name` (the source, and what decides who is
|
|
85
|
+
handed it), `id` (what the sender calls this event) and `payload` (the body as it arrived). It
|
|
86
|
+
is frozen, so no consumer can rebind `name`, `id` or `payload` on the event; `payload` is an
|
|
87
|
+
ordinary dict, though, and the next consumer is handed the same event, so every consumer must treat
|
|
88
|
+
the payload as read-only.
|
|
89
|
+
|
|
90
|
+
## Consumers
|
|
91
|
+
|
|
92
|
+
`as_remote_event_consumer` writes the declaration onto the class itself, so whatever gathers
|
|
93
|
+
consumers finds it where it finds the class — a container's scan sees it, and a test that
|
|
94
|
+
declares one leaves nothing behind. Stack it for a consumer serving several sources:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
@as_remote_event_consumer("stripe")
|
|
98
|
+
@as_remote_event_consumer("stripe-test")
|
|
99
|
+
class PaymentConsumer:
|
|
100
|
+
async def consume(self, event: RemoteEvent) -> None: ...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Read it back with `remote_event_consumers_declared_on(cls)` from `xtr_remote_event.decorator`,
|
|
104
|
+
which returns the names in the order they were written. Only what was written on that class —
|
|
105
|
+
never a base's, so a subclass answers for nothing until it is decorated too.
|
|
106
|
+
|
|
107
|
+
An empty name, or a class with no `consume()`, is refused with `InvalidArgumentError` where the
|
|
108
|
+
declaration is written rather than when the first event arrives.
|
|
109
|
+
|
|
110
|
+
A `ConsumerLocator` hands consumers over by source name. Build it over consumers you already
|
|
111
|
+
have, or over anything implementing `ServiceProviderInterface` — which is what the bundle gives
|
|
112
|
+
it, so each consumer is built with its dependencies only when an event for it first arrives.
|
|
113
|
+
Asking for a source nothing is registered under raises `LogicError`.
|
|
114
|
+
|
|
115
|
+
## Through the message bus
|
|
116
|
+
|
|
117
|
+
`ConsumeRemoteEventMessage(type, event)` is what turns receiving an event into consuming it:
|
|
118
|
+
whatever read the event answers its sender at once, and the work happens wherever the message is
|
|
119
|
+
routed. `ConsumeRemoteEventHandler` looks the consumer up and hands it the event.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from xtr_messenger import HandlersLocator, MessageBusConfig, MessageBusFactory, TransportConfig
|
|
123
|
+
from xtr_remote_event import ConsumerLocator
|
|
124
|
+
from xtr_remote_event.messenger import ConsumeRemoteEventHandler, ConsumeRemoteEventMessage
|
|
125
|
+
|
|
126
|
+
handlers = HandlersLocator()
|
|
127
|
+
_ = handlers.register(
|
|
128
|
+
ConsumeRemoteEventMessage,
|
|
129
|
+
ConsumeRemoteEventHandler(ConsumerLocator({"acme": AcmeConsumer()})),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
bus = MessageBusFactory(
|
|
133
|
+
MessageBusConfig(
|
|
134
|
+
transports={"sync": TransportConfig("sync://")},
|
|
135
|
+
routing={ConsumeRemoteEventMessage: "sync"},
|
|
136
|
+
),
|
|
137
|
+
handlers=handlers,
|
|
138
|
+
).bus()
|
|
139
|
+
|
|
140
|
+
_ = await bus.dispatch(ConsumeRemoteEventMessage("acme", RemoteEvent("order.paid", "evt_1", {})))
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The message's `type` is separate from `event.name`, so whatever read the event can decide which
|
|
144
|
+
consumer it belongs to without rewriting the event. Route the message to `"sync://"` to handle
|
|
145
|
+
it in the dispatching process, or to a broker transport to handle it on a worker; the message
|
|
146
|
+
carries the event rather than an id, because nothing has stored it yet — a consumer that wants
|
|
147
|
+
it kept is the one that stores it.
|
|
148
|
+
|
|
149
|
+
In a wired application the bundle registers the handler, so dispatching the message never meets
|
|
150
|
+
the bus's no-handler error.
|
|
151
|
+
|
|
152
|
+
## Converting payloads
|
|
153
|
+
|
|
154
|
+
`PayloadConverterInterface` is where one sender's vocabulary is translated and nowhere else:
|
|
155
|
+
adding a sender is writing one converter rather than touching whatever dispatches events.
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from collections.abc import Mapping
|
|
159
|
+
|
|
160
|
+
from xtr_remote_event import PayloadConverterInterface, RemoteEvent
|
|
161
|
+
from xtr_remote_event.exception import ParseError
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class AcmeConverter(PayloadConverterInterface):
|
|
165
|
+
def convert(self, payload: Mapping[str, object]) -> RemoteEvent:
|
|
166
|
+
kind = payload.get("kind")
|
|
167
|
+
if not isinstance(kind, str):
|
|
168
|
+
raise ParseError('The payload has no "kind" naming the event.')
|
|
169
|
+
return RemoteEvent(kind, str(payload["id"]), dict(payload))
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
It takes the payload already decoded and is synchronous: reading the bytes, and deciding whether
|
|
173
|
+
they are to be trusted at all, belongs to whatever received them — [xtr-webhook](../xtr-webhook)
|
|
174
|
+
for one.
|
|
175
|
+
|
|
176
|
+
## Use in an application
|
|
177
|
+
|
|
178
|
+
Everything adding this package to an application on
|
|
179
|
+
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
|
|
180
|
+
removing it undoes.
|
|
181
|
+
|
|
182
|
+
- **Install** — `uv add "xtr-remote-event[di]"`. There are no other extras.
|
|
183
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
|
|
184
|
+
`RemoteEventBundle`. There is nothing else for it to write — no configuration module, no
|
|
185
|
+
environment variable, no ignore line. It prints the steps to declare your consumers and to
|
|
186
|
+
route `ConsumeRemoteEventMessage`, neither of which a recipe can make for you.
|
|
187
|
+
- **Activate** — `RemoteEventBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`,
|
|
188
|
+
imported from `xtr_remote_event.bundle` — or nothing to do when
|
|
189
|
+
[xtr-webhook](../xtr-webhook)'s bundle is listed, which requires this one.
|
|
190
|
+
- **Brings along** — the messenger bundle, always: the handler is registered on its bus.
|
|
191
|
+
- **Configure** — nothing. `RemoteEventConfig` takes no value, because which consumer answers
|
|
192
|
+
for which source is declared beside the class that consumes — see
|
|
193
|
+
[Kernel / bundle](#kernel--bundle).
|
|
194
|
+
- **Environment** — nothing.
|
|
195
|
+
- **Ignore** — nothing.
|
|
196
|
+
- **Remove** — drop the `BUNDLES` entry, then `uv remove xtr-remote-event` — unless
|
|
197
|
+
xtr-webhook is installed, which depends on it.
|
|
198
|
+
- **Check** — `debug:bundles` shows `remote_event` as `listed` and `active`.
|
|
199
|
+
|
|
200
|
+
## Kernel / bundle
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
# app/bundles.py
|
|
204
|
+
from xtr_remote_event.bundle import RemoteEventBundle
|
|
205
|
+
|
|
206
|
+
BUNDLES = {RemoteEventBundle: {"all": True}}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
There is no `config/remote_event.py` to write. The bundle finds every class declared with
|
|
210
|
+
`@as_remote_event_consumer` when the container is built, registers each one as a single service
|
|
211
|
+
— once, however many sources it declared, so what it remembers between events does not depend
|
|
212
|
+
on which name the event arrived under — and registers `ConsumeRemoteEventHandler` over a
|
|
213
|
+
`ConsumerLocator` backed by the container.
|
|
214
|
+
|
|
215
|
+
A consumer is a service like any other, so it asks for what it needs:
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
# app/consumer/payment_consumer.py
|
|
219
|
+
from xtr_remote_event import RemoteEvent
|
|
220
|
+
from xtr_remote_event.decorator import as_remote_event_consumer
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@as_remote_event_consumer("acme")
|
|
224
|
+
class PaymentConsumer:
|
|
225
|
+
def __init__(self, orders: OrderRepository) -> None:
|
|
226
|
+
self._orders = orders
|
|
227
|
+
|
|
228
|
+
async def consume(self, event: RemoteEvent) -> None:
|
|
229
|
+
await self._orders.mark_paid(event.payload["order_id"])
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Each is built — with its dependencies — only when an event for its source first arrives. To
|
|
233
|
+
consume on a worker rather than in the process that received the event, route the message in
|
|
234
|
+
the messenger configuration:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
# app/config/messenger.py
|
|
238
|
+
from xtr_dependency_injection import configure, env
|
|
239
|
+
from xtr_messenger import MessageBusConfig, TransportConfig
|
|
240
|
+
from xtr_remote_event.messenger import ConsumeRemoteEventMessage
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@configure
|
|
244
|
+
def messenger() -> MessageBusConfig:
|
|
245
|
+
return MessageBusConfig(
|
|
246
|
+
transports={"events": TransportConfig(env("MESSENGER_DSN"))},
|
|
247
|
+
routing={ConsumeRemoteEventMessage: "events"},
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The zero-config path registers the locator and the handler, declares no consumer and touches no
|
|
252
|
+
I/O.
|
|
253
|
+
|
|
254
|
+
## Errors
|
|
255
|
+
|
|
256
|
+
Everything this library raises derives from `RemoteEventError`.
|
|
257
|
+
|
|
258
|
+
| Error | Raised when |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `InvalidArgumentError` | a value cannot be used — an empty consumer name, a class with no `consume()`; also a `ValueError` |
|
|
261
|
+
| `ParseError` | a converter cannot read a payload as an event — the sender's doing, so tell the sender |
|
|
262
|
+
| `LogicError` | nothing is registered for a source, or what is registered is not a consumer; also a `RuntimeError` |
|
|
263
|
+
|
|
264
|
+
## Development
|
|
265
|
+
|
|
266
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
267
|
+
`packages/xtr-remote-event`; run the commands below from there. The
|
|
268
|
+
`python-xtr-remote-event` repository is a read-only copy, so send issues and pull requests to
|
|
269
|
+
the monorepo.
|
|
270
|
+
|
|
271
|
+
```sh
|
|
272
|
+
uv sync
|
|
273
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## License
|
|
277
|
+
|
|
278
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# xtr-remote-event
|
|
4
|
+
|
|
5
|
+
**Events received from another system, as one value type, handed to the consumer registered under their source's name — directly or through a message bus.**
|
|
6
|
+
|
|
7
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
8
|
+
<img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
|
|
9
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
10
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Why?
|
|
17
|
+
|
|
18
|
+
An event from elsewhere — a delivery report, a payment notice, a message from a provider —
|
|
19
|
+
arrives in whatever shape its sender chose. Once it is read, though, what the application does
|
|
20
|
+
with it is always the same: hand it to the code that handles events from that sender.
|
|
21
|
+
|
|
22
|
+
This package is that last step, and nothing else: one value type for the event, a one-method
|
|
23
|
+
contract for the consumer, a decorator naming which sources a consumer answers for, and a
|
|
24
|
+
message so the work happens on a worker rather than in the request that received it.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
uv add xtr-remote-event # the event, the consumer contract, the decorator
|
|
30
|
+
uv add "xtr-remote-event[di]" # + the bundle
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
A consumer is a class with one method, declared with the name of the source whose events it
|
|
36
|
+
answers for:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
|
|
41
|
+
from xtr_remote_event import ConsumerLocator, RemoteEvent, as_remote_event_consumer
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@as_remote_event_consumer("acme")
|
|
45
|
+
class AcmeConsumer:
|
|
46
|
+
async def consume(self, event: RemoteEvent) -> None:
|
|
47
|
+
print(f"{event.name} {event.id} {event.payload}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def main() -> None:
|
|
51
|
+
consumers = ConsumerLocator({"acme": AcmeConsumer()})
|
|
52
|
+
consumer = await consumers.get("acme")
|
|
53
|
+
await consumer.consume(RemoteEvent("order.paid", "evt_1", {"total": 4200}))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
asyncio.run(main())
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A `RemoteEvent` is three fields and no behaviour: `name` (the source, and what decides who is
|
|
60
|
+
handed it), `id` (what the sender calls this event) and `payload` (the body as it arrived). It
|
|
61
|
+
is frozen, so no consumer can rebind `name`, `id` or `payload` on the event; `payload` is an
|
|
62
|
+
ordinary dict, though, and the next consumer is handed the same event, so every consumer must treat
|
|
63
|
+
the payload as read-only.
|
|
64
|
+
|
|
65
|
+
## Consumers
|
|
66
|
+
|
|
67
|
+
`as_remote_event_consumer` writes the declaration onto the class itself, so whatever gathers
|
|
68
|
+
consumers finds it where it finds the class — a container's scan sees it, and a test that
|
|
69
|
+
declares one leaves nothing behind. Stack it for a consumer serving several sources:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
@as_remote_event_consumer("stripe")
|
|
73
|
+
@as_remote_event_consumer("stripe-test")
|
|
74
|
+
class PaymentConsumer:
|
|
75
|
+
async def consume(self, event: RemoteEvent) -> None: ...
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Read it back with `remote_event_consumers_declared_on(cls)` from `xtr_remote_event.decorator`,
|
|
79
|
+
which returns the names in the order they were written. Only what was written on that class —
|
|
80
|
+
never a base's, so a subclass answers for nothing until it is decorated too.
|
|
81
|
+
|
|
82
|
+
An empty name, or a class with no `consume()`, is refused with `InvalidArgumentError` where the
|
|
83
|
+
declaration is written rather than when the first event arrives.
|
|
84
|
+
|
|
85
|
+
A `ConsumerLocator` hands consumers over by source name. Build it over consumers you already
|
|
86
|
+
have, or over anything implementing `ServiceProviderInterface` — which is what the bundle gives
|
|
87
|
+
it, so each consumer is built with its dependencies only when an event for it first arrives.
|
|
88
|
+
Asking for a source nothing is registered under raises `LogicError`.
|
|
89
|
+
|
|
90
|
+
## Through the message bus
|
|
91
|
+
|
|
92
|
+
`ConsumeRemoteEventMessage(type, event)` is what turns receiving an event into consuming it:
|
|
93
|
+
whatever read the event answers its sender at once, and the work happens wherever the message is
|
|
94
|
+
routed. `ConsumeRemoteEventHandler` looks the consumer up and hands it the event.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from xtr_messenger import HandlersLocator, MessageBusConfig, MessageBusFactory, TransportConfig
|
|
98
|
+
from xtr_remote_event import ConsumerLocator
|
|
99
|
+
from xtr_remote_event.messenger import ConsumeRemoteEventHandler, ConsumeRemoteEventMessage
|
|
100
|
+
|
|
101
|
+
handlers = HandlersLocator()
|
|
102
|
+
_ = handlers.register(
|
|
103
|
+
ConsumeRemoteEventMessage,
|
|
104
|
+
ConsumeRemoteEventHandler(ConsumerLocator({"acme": AcmeConsumer()})),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
bus = MessageBusFactory(
|
|
108
|
+
MessageBusConfig(
|
|
109
|
+
transports={"sync": TransportConfig("sync://")},
|
|
110
|
+
routing={ConsumeRemoteEventMessage: "sync"},
|
|
111
|
+
),
|
|
112
|
+
handlers=handlers,
|
|
113
|
+
).bus()
|
|
114
|
+
|
|
115
|
+
_ = await bus.dispatch(ConsumeRemoteEventMessage("acme", RemoteEvent("order.paid", "evt_1", {})))
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The message's `type` is separate from `event.name`, so whatever read the event can decide which
|
|
119
|
+
consumer it belongs to without rewriting the event. Route the message to `"sync://"` to handle
|
|
120
|
+
it in the dispatching process, or to a broker transport to handle it on a worker; the message
|
|
121
|
+
carries the event rather than an id, because nothing has stored it yet — a consumer that wants
|
|
122
|
+
it kept is the one that stores it.
|
|
123
|
+
|
|
124
|
+
In a wired application the bundle registers the handler, so dispatching the message never meets
|
|
125
|
+
the bus's no-handler error.
|
|
126
|
+
|
|
127
|
+
## Converting payloads
|
|
128
|
+
|
|
129
|
+
`PayloadConverterInterface` is where one sender's vocabulary is translated and nowhere else:
|
|
130
|
+
adding a sender is writing one converter rather than touching whatever dispatches events.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from collections.abc import Mapping
|
|
134
|
+
|
|
135
|
+
from xtr_remote_event import PayloadConverterInterface, RemoteEvent
|
|
136
|
+
from xtr_remote_event.exception import ParseError
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AcmeConverter(PayloadConverterInterface):
|
|
140
|
+
def convert(self, payload: Mapping[str, object]) -> RemoteEvent:
|
|
141
|
+
kind = payload.get("kind")
|
|
142
|
+
if not isinstance(kind, str):
|
|
143
|
+
raise ParseError('The payload has no "kind" naming the event.')
|
|
144
|
+
return RemoteEvent(kind, str(payload["id"]), dict(payload))
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
It takes the payload already decoded and is synchronous: reading the bytes, and deciding whether
|
|
148
|
+
they are to be trusted at all, belongs to whatever received them — [xtr-webhook](../xtr-webhook)
|
|
149
|
+
for one.
|
|
150
|
+
|
|
151
|
+
## Use in an application
|
|
152
|
+
|
|
153
|
+
Everything adding this package to an application on
|
|
154
|
+
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
|
|
155
|
+
removing it undoes.
|
|
156
|
+
|
|
157
|
+
- **Install** — `uv add "xtr-remote-event[di]"`. There are no other extras.
|
|
158
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
|
|
159
|
+
`RemoteEventBundle`. There is nothing else for it to write — no configuration module, no
|
|
160
|
+
environment variable, no ignore line. It prints the steps to declare your consumers and to
|
|
161
|
+
route `ConsumeRemoteEventMessage`, neither of which a recipe can make for you.
|
|
162
|
+
- **Activate** — `RemoteEventBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`,
|
|
163
|
+
imported from `xtr_remote_event.bundle` — or nothing to do when
|
|
164
|
+
[xtr-webhook](../xtr-webhook)'s bundle is listed, which requires this one.
|
|
165
|
+
- **Brings along** — the messenger bundle, always: the handler is registered on its bus.
|
|
166
|
+
- **Configure** — nothing. `RemoteEventConfig` takes no value, because which consumer answers
|
|
167
|
+
for which source is declared beside the class that consumes — see
|
|
168
|
+
[Kernel / bundle](#kernel--bundle).
|
|
169
|
+
- **Environment** — nothing.
|
|
170
|
+
- **Ignore** — nothing.
|
|
171
|
+
- **Remove** — drop the `BUNDLES` entry, then `uv remove xtr-remote-event` — unless
|
|
172
|
+
xtr-webhook is installed, which depends on it.
|
|
173
|
+
- **Check** — `debug:bundles` shows `remote_event` as `listed` and `active`.
|
|
174
|
+
|
|
175
|
+
## Kernel / bundle
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
# app/bundles.py
|
|
179
|
+
from xtr_remote_event.bundle import RemoteEventBundle
|
|
180
|
+
|
|
181
|
+
BUNDLES = {RemoteEventBundle: {"all": True}}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
There is no `config/remote_event.py` to write. The bundle finds every class declared with
|
|
185
|
+
`@as_remote_event_consumer` when the container is built, registers each one as a single service
|
|
186
|
+
— once, however many sources it declared, so what it remembers between events does not depend
|
|
187
|
+
on which name the event arrived under — and registers `ConsumeRemoteEventHandler` over a
|
|
188
|
+
`ConsumerLocator` backed by the container.
|
|
189
|
+
|
|
190
|
+
A consumer is a service like any other, so it asks for what it needs:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
# app/consumer/payment_consumer.py
|
|
194
|
+
from xtr_remote_event import RemoteEvent
|
|
195
|
+
from xtr_remote_event.decorator import as_remote_event_consumer
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@as_remote_event_consumer("acme")
|
|
199
|
+
class PaymentConsumer:
|
|
200
|
+
def __init__(self, orders: OrderRepository) -> None:
|
|
201
|
+
self._orders = orders
|
|
202
|
+
|
|
203
|
+
async def consume(self, event: RemoteEvent) -> None:
|
|
204
|
+
await self._orders.mark_paid(event.payload["order_id"])
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Each is built — with its dependencies — only when an event for its source first arrives. To
|
|
208
|
+
consume on a worker rather than in the process that received the event, route the message in
|
|
209
|
+
the messenger configuration:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
# app/config/messenger.py
|
|
213
|
+
from xtr_dependency_injection import configure, env
|
|
214
|
+
from xtr_messenger import MessageBusConfig, TransportConfig
|
|
215
|
+
from xtr_remote_event.messenger import ConsumeRemoteEventMessage
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@configure
|
|
219
|
+
def messenger() -> MessageBusConfig:
|
|
220
|
+
return MessageBusConfig(
|
|
221
|
+
transports={"events": TransportConfig(env("MESSENGER_DSN"))},
|
|
222
|
+
routing={ConsumeRemoteEventMessage: "events"},
|
|
223
|
+
)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The zero-config path registers the locator and the handler, declares no consumer and touches no
|
|
227
|
+
I/O.
|
|
228
|
+
|
|
229
|
+
## Errors
|
|
230
|
+
|
|
231
|
+
Everything this library raises derives from `RemoteEventError`.
|
|
232
|
+
|
|
233
|
+
| Error | Raised when |
|
|
234
|
+
|---|---|
|
|
235
|
+
| `InvalidArgumentError` | a value cannot be used — an empty consumer name, a class with no `consume()`; also a `ValueError` |
|
|
236
|
+
| `ParseError` | a converter cannot read a payload as an event — the sender's doing, so tell the sender |
|
|
237
|
+
| `LogicError` | nothing is registered for a source, or what is registered is not a consumer; also a `RuntimeError` |
|
|
238
|
+
|
|
239
|
+
## Development
|
|
240
|
+
|
|
241
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
242
|
+
`packages/xtr-remote-event`; run the commands below from there. The
|
|
243
|
+
`python-xtr-remote-event` repository is a read-only copy, so send issues and pull requests to
|
|
244
|
+
the monorepo.
|
|
245
|
+
|
|
246
|
+
```sh
|
|
247
|
+
uv sync
|
|
248
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
MIT — see [LICENSE](LICENSE).
|