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.
Files changed (30) hide show
  1. xtr_remote_event-4.0.2/LICENSE +22 -0
  2. xtr_remote_event-4.0.2/PKG-INFO +278 -0
  3. xtr_remote_event-4.0.2/README.md +253 -0
  4. xtr_remote_event-4.0.2/pyproject.toml +170 -0
  5. xtr_remote_event-4.0.2/pyproject.toml.orig +176 -0
  6. xtr_remote_event-4.0.2/src/xtr_remote_event/.agents/skills/xtr-remote-event/SKILL.md +217 -0
  7. xtr_remote_event-4.0.2/src/xtr_remote_event/__init__.py +22 -0
  8. xtr_remote_event-4.0.2/src/xtr_remote_event/bundle/__init__.py +8 -0
  9. xtr_remote_event-4.0.2/src/xtr_remote_event/bundle/_declared_consumers.py +56 -0
  10. xtr_remote_event-4.0.2/src/xtr_remote_event/bundle/remote_event_bundle.py +109 -0
  11. xtr_remote_event-4.0.2/src/xtr_remote_event/bundle/remote_event_config.py +22 -0
  12. xtr_remote_event-4.0.2/src/xtr_remote_event/consumer/__init__.py +8 -0
  13. xtr_remote_event-4.0.2/src/xtr_remote_event/consumer/_built_consumers.py +65 -0
  14. xtr_remote_event-4.0.2/src/xtr_remote_event/consumer/consumer_interface.py +29 -0
  15. xtr_remote_event-4.0.2/src/xtr_remote_event/consumer/consumer_locator.py +59 -0
  16. xtr_remote_event-4.0.2/src/xtr_remote_event/decorator/__init__.py +10 -0
  17. xtr_remote_event-4.0.2/src/xtr_remote_event/decorator/as_remote_event_consumer.py +73 -0
  18. xtr_remote_event-4.0.2/src/xtr_remote_event/exception/__init__.py +15 -0
  19. xtr_remote_event-4.0.2/src/xtr_remote_event/exception/invalid_argument_error.py +15 -0
  20. xtr_remote_event-4.0.2/src/xtr_remote_event/exception/logic_error.py +19 -0
  21. xtr_remote_event-4.0.2/src/xtr_remote_event/exception/parse_error.py +17 -0
  22. xtr_remote_event-4.0.2/src/xtr_remote_event/exception/remote_event_error.py +16 -0
  23. xtr_remote_event-4.0.2/src/xtr_remote_event/messenger/__init__.py +8 -0
  24. xtr_remote_event-4.0.2/src/xtr_remote_event/messenger/consume_remote_event_handler.py +50 -0
  25. xtr_remote_event-4.0.2/src/xtr_remote_event/messenger/consume_remote_event_message.py +39 -0
  26. xtr_remote_event-4.0.2/src/xtr_remote_event/payload_converter_interface.py +38 -0
  27. xtr_remote_event-4.0.2/src/xtr_remote_event/py.typed +0 -0
  28. xtr_remote_event-4.0.2/src/xtr_remote_event/recipe/__init__.py +16 -0
  29. xtr_remote_event-4.0.2/src/xtr_remote_event/recipe/manifest.toml +25 -0
  30. 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).