mediary 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mediary-0.1.0/LICENSE +21 -0
- mediary-0.1.0/PKG-INFO +366 -0
- mediary-0.1.0/README.md +338 -0
- mediary-0.1.0/pyproject.toml +149 -0
- mediary-0.1.0/pyproject.toml.orig +106 -0
- mediary-0.1.0/src/mediary/__init__.py +54 -0
- mediary-0.1.0/src/mediary/_behaviors.py +233 -0
- mediary-0.1.0/src/mediary/_errors.py +103 -0
- mediary-0.1.0/src/mediary/_handlers.py +188 -0
- mediary-0.1.0/src/mediary/_markers.py +174 -0
- mediary-0.1.0/src/mediary/_mediator.py +231 -0
- mediary-0.1.0/src/mediary/_publishing.py +53 -0
- mediary-0.1.0/src/mediary/_resolving.py +130 -0
- mediary-0.1.0/src/mediary/_retryable.py +58 -0
- mediary-0.1.0/src/mediary/_scan.py +51 -0
- mediary-0.1.0/src/mediary/behaviors.py +190 -0
- mediary-0.1.0/src/mediary/cqrs.py +102 -0
- mediary-0.1.0/src/mediary/kinds.py +21 -0
- mediary-0.1.0/src/mediary/py.typed +0 -0
- mediary-0.1.0/src/mediary/pytest_plugin.py +15 -0
- mediary-0.1.0/src/mediary/testing.py +102 -0
mediary-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 codeonym-oss
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
16
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
18
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
19
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
20
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
|
21
|
+
OR OTHER DEALINGS IN THE SOFTWARE.
|
mediary-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mediary
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed, decorator-driven mediator + CQRS for Python — handlers, pipelines and notifications discovered by package scan.
|
|
5
|
+
Keywords: mediator,cqrs,asyncio,pipeline,middleware,command,query,notification,dependency-injection
|
|
6
|
+
Author: codeonym-oss
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Project-URL: Homepage, https://github.com/codeonym-oss/mediary
|
|
24
|
+
Project-URL: Repository, https://github.com/codeonym-oss/mediary
|
|
25
|
+
Project-URL: Issues, https://github.com/codeonym-oss/mediary/issues
|
|
26
|
+
Project-URL: Changelog, https://github.com/codeonym-oss/mediary/blob/main/CHANGELOG.md
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# mediary
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/mediary/)
|
|
32
|
+
[](https://pypi.org/project/mediary/)
|
|
33
|
+
[](https://github.com/codeonym-oss/mediary/actions/workflows/ci.yml)
|
|
34
|
+
[](https://github.com/codeonym-oss/mediary/blob/main/LICENSE)
|
|
35
|
+
|
|
36
|
+
Typed, decorator-driven mediator + CQRS for Python — handlers, pipelines and notifications discovered by package scan.
|
|
37
|
+
|
|
38
|
+
- **Decorate, don't register.** Mark requests with `@request` and handlers with `@handler`; one `mediator.scan("app")` wires the whole package.
|
|
39
|
+
- **Typed end to end.** `await mediator.send(GetUser(1))` is typed as `User`; handlers are plain classes or functions, matched structurally.
|
|
40
|
+
- **Pipelines.** Behaviors (middleware) wrap handlers, targeted by type, Protocol or kind, and ordered. Logging, retry and timeout ship ready-made.
|
|
41
|
+
- **Notifications**, with sequential or concurrent publishing.
|
|
42
|
+
- **CQRS pack.** `@command`, `@query`, `@event`, and senders that can only send one kind.
|
|
43
|
+
- **Pluggable DI**, **testing helpers** and a **pytest fixture**. Zero dependencies, asyncio only, Python 3.11+.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
pip install mediary # or: uv add mediary
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quickstart
|
|
52
|
+
|
|
53
|
+
Declare a request, what it returns, and its handler:
|
|
54
|
+
|
|
55
|
+
<!-- file: shop/orders.py -->
|
|
56
|
+
```python
|
|
57
|
+
from dataclasses import dataclass
|
|
58
|
+
|
|
59
|
+
from mediary import Returns, handler, request
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@request
|
|
63
|
+
@dataclass
|
|
64
|
+
class PlaceOrder(Returns[int]):
|
|
65
|
+
item: str
|
|
66
|
+
quantity: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@handler
|
|
70
|
+
class PlaceOrderHandler:
|
|
71
|
+
async def handle(self, request: PlaceOrder) -> int:
|
|
72
|
+
return 42 # the new order's id
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Scan the package once at startup, then send requests from anywhere:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from mediary import Mediator
|
|
79
|
+
from shop.orders import PlaceOrder
|
|
80
|
+
|
|
81
|
+
mediator = Mediator()
|
|
82
|
+
mediator.scan("shop") # imports shop and its submodules, registers each @handler
|
|
83
|
+
|
|
84
|
+
order_id = await mediator.send(PlaceOrder("book", 2)) # typed as int
|
|
85
|
+
assert order_id == 42
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The examples use top-level `await`, as in `python -m asyncio`; in an app they live inside `async def`s. Every example in this README runs in CI.
|
|
89
|
+
|
|
90
|
+
A handler serves the request its parameter is hinted with, or the one named with `@handler(PlaceOrder)`. Each request has exactly one handler. Scanning is all or nothing: it reports every problem it finds — a missing hint, a duplicate handler, a module that fails to import — in one `ScanError`, and registers nothing.
|
|
91
|
+
|
|
92
|
+
Prefer explicit wiring? `mediator.register(PlaceOrder, PlaceOrderHandler)` does the same for one handler, and never needs a decorator.
|
|
93
|
+
|
|
94
|
+
## Function handlers and dependency injection
|
|
95
|
+
|
|
96
|
+
A handler can be an async function. Its parameters after the request are dependencies, resolved by type hint on every call:
|
|
97
|
+
|
|
98
|
+
<!-- file: shop/stock.py -->
|
|
99
|
+
```python
|
|
100
|
+
from dataclasses import dataclass
|
|
101
|
+
|
|
102
|
+
from mediary import Returns, handler, request
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Inventory:
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
self.counts = {"book": 3}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@request
|
|
111
|
+
@dataclass
|
|
112
|
+
class CheckStock(Returns[int]):
|
|
113
|
+
item: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@handler
|
|
117
|
+
async def check_stock(request: CheckStock, inventory: Inventory) -> int:
|
|
118
|
+
return inventory.counts.get(request.item, 0)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Handler classes and dependencies come from the mediator's resolver, which calls `cls()` by default. Plug in any DI container by adapting it to one method, `resolve(cls)`, which may be sync or async:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from shop.stock import CheckStock, Inventory
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ContainerResolver:
|
|
128
|
+
def __init__(self) -> None:
|
|
129
|
+
self.singletons = {Inventory: Inventory()}
|
|
130
|
+
|
|
131
|
+
def resolve(self, cls):
|
|
132
|
+
return self.singletons.get(cls) or cls()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
mediator = Mediator(resolver=ContainerResolver())
|
|
136
|
+
mediator.scan("shop")
|
|
137
|
+
assert await mediator.send(CheckStock("book")) == 3
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A class handler is resolved for every send, unless it is decorated `@handler(lifetime="singleton")`.
|
|
141
|
+
|
|
142
|
+
## Behaviors
|
|
143
|
+
|
|
144
|
+
Behaviors wrap handlers like middleware: each gets the message and `next`, and can act before and after it, change the result, or skip the handler.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from mediary import Next, behavior
|
|
148
|
+
|
|
149
|
+
calls = []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@behavior(order=-10) # lower orders run further out
|
|
153
|
+
async def trace(request: object, next: Next[object]) -> object:
|
|
154
|
+
calls.append(f"-> {type(request).__name__}")
|
|
155
|
+
result = await next()
|
|
156
|
+
calls.append(f"<- {result}")
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@behavior
|
|
161
|
+
async def double_orders(request: PlaceOrder, next: Next[int]) -> int:
|
|
162
|
+
return 2 * await next()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
mediator = Mediator()
|
|
166
|
+
mediator.scan("shop")
|
|
167
|
+
mediator.use(trace) # scan finds decorated behaviors too; `use` adds them by hand
|
|
168
|
+
mediator.use(double_orders)
|
|
169
|
+
|
|
170
|
+
assert await mediator.send(PlaceOrder("book", 1)) == 84
|
|
171
|
+
assert await mediator.send(CheckStock("book")) == 3 # double_orders only wraps PlaceOrder
|
|
172
|
+
assert calls == ["-> PlaceOrder", "<- 84", "-> CheckStock", "<- 3"]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The hint on the request parameter picks what a behavior wraps: `object` for everything, a class for it and its subclasses, a `Protocol` for every request with those members, or a union. `kinds={"request"}` narrows it to kinds of message, and lower `order`s run further out (ties are broken by name).
|
|
176
|
+
|
|
177
|
+
Three ready-made behaviors cover production basics. They are never scanned; add them configured:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from mediary.behaviors import LoggingBehavior, RetryBehavior, TimeoutBehavior
|
|
181
|
+
|
|
182
|
+
mediator.use(LoggingBehavior(), order=-100) # start, completion, failure, slowness
|
|
183
|
+
mediator.use(TimeoutBehavior(seconds=5), order=-50) # HandlerTimeout when it's too slow
|
|
184
|
+
mediator.use(RetryBehavior(max_retries=3), kinds={"request"}) # backoff with jitter
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`RetryBehavior` retries only transient errors — those whose class is marked `@retryable`, like every `TransientError` — so a bug never runs twice:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from mediary import TransientError, retryable
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class GatewayUnavailable(TransientError): # retried
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@retryable
|
|
198
|
+
class StorageError(Exception): # retried, and so are its subclasses
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class CardDeclined(Exception): # fails at once
|
|
203
|
+
pass
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Errors you can't decorate, such as `ConnectionError`, can be listed: `RetryBehavior(retry_on=(ConnectionError,))`.
|
|
207
|
+
|
|
208
|
+
## Notifications
|
|
209
|
+
|
|
210
|
+
A notification goes to every one of its handlers — zero or more — in order of their names:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from dataclasses import dataclass
|
|
214
|
+
|
|
215
|
+
from mediary import Concurrent, notification
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@notification
|
|
219
|
+
@dataclass
|
|
220
|
+
class OrderPlaced:
|
|
221
|
+
order_id: int
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
emails = []
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def email_customer(event: OrderPlaced) -> None:
|
|
228
|
+
emails.append(f"order {event.order_id} confirmed")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def update_stats(event: OrderPlaced) -> None:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
mediator = Mediator()
|
|
236
|
+
mediator.register(OrderPlaced, email_customer)
|
|
237
|
+
mediator.register(OrderPlaced, update_stats)
|
|
238
|
+
|
|
239
|
+
await mediator.publish(OrderPlaced(42)) # one handler after the other
|
|
240
|
+
await mediator.publish(OrderPlaced(42), strategy=Concurrent()) # all at once
|
|
241
|
+
assert emails == ["order 42 confirmed"] * 2
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`Concurrent` runs every handler even when some fail, then raises their errors together in an `ExceptionGroup`. Pass `Mediator(publish_strategy=...)` to change the default, or write your own strategy.
|
|
245
|
+
|
|
246
|
+
## CQRS
|
|
247
|
+
|
|
248
|
+
`mediary.cqrs` speaks the language of CQRS: commands change state, queries read it, and events announce what happened.
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
from dataclasses import dataclass
|
|
252
|
+
|
|
253
|
+
from mediary.cqrs import Command, Query, QuerySender, command, query
|
|
254
|
+
|
|
255
|
+
names = {}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@command
|
|
259
|
+
@dataclass
|
|
260
|
+
class RenameUser(Command[None]):
|
|
261
|
+
user_id: int
|
|
262
|
+
name: str
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@query
|
|
266
|
+
@dataclass
|
|
267
|
+
class GetUserName(Query[str]):
|
|
268
|
+
user_id: int
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def rename_user(command: RenameUser) -> None:
|
|
272
|
+
names[command.user_id] = command.name
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def get_user_name(query: GetUserName) -> str:
|
|
276
|
+
return names[query.user_id]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
mediator = Mediator()
|
|
280
|
+
mediator.register(RenameUser, rename_user)
|
|
281
|
+
mediator.register(GetUserName, get_user_name)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def profile_page(queries: QuerySender, user_id: int) -> str:
|
|
285
|
+
# A QuerySender can't send commands: type checkers reject `queries.send(RenameUser(...))`.
|
|
286
|
+
return f"<h1>{await queries.send(GetUserName(user_id))}</h1>"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
await mediator.send(RenameUser(1, "Ada"))
|
|
290
|
+
assert await profile_page(mediator, 1) == "<h1>Ada</h1>"
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Each command and query has exactly one handler, and a query handler annotated to return `None` is rejected. Behaviors can target `kinds={"command"}`, `{"query"}` or `{"event"}`.
|
|
294
|
+
|
|
295
|
+
The pack is built only on the public `mediary.kinds` API, which you can use to define kinds of your own, with rules their handlers must follow:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
from mediary import Returns
|
|
299
|
+
from mediary.kinds import HandlerInfo, define_kind
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def returns_something(info: HandlerInfo) -> str | None:
|
|
303
|
+
if info.returns is type(None):
|
|
304
|
+
return "a report must return its rows"
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
report = define_kind("report", dispatch="send", rules=[returns_something])
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@report
|
|
312
|
+
class SalesByMonth(Returns[list[int]]):
|
|
313
|
+
pass
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## Testing
|
|
317
|
+
|
|
318
|
+
`mediary.testing.RecordingMediator` is a `Mediator` that records what it sends and publishes, and can answer requests with stubs. With mediary installed, pytest provides a fresh one as the `mediator` fixture:
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
from mediary.testing import RecordingMediator
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def place_and_announce(mediator: Mediator, item: str) -> None:
|
|
325
|
+
order_id = await mediator.send(PlaceOrder(item, 1))
|
|
326
|
+
await mediator.publish(OrderPlaced(order_id))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
async def test_placing_an_order_announces_it(mediator: RecordingMediator) -> None:
|
|
330
|
+
mediator.stub(PlaceOrder, 7)
|
|
331
|
+
|
|
332
|
+
await place_and_announce(mediator, "book")
|
|
333
|
+
|
|
334
|
+
assert mediator.sent_of(PlaceOrder) == [PlaceOrder("book", 1)]
|
|
335
|
+
assert mediator.published_of(OrderPlaced) == [OrderPlaced(7)]
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Stubs stand in for handlers — `mediator.stub(PlaceOrder, raises=CardDeclined())` fails instead — and behaviors still wrap them. Every mediator is isolated, so tests never share registrations.
|
|
339
|
+
|
|
340
|
+
## Why not register by hand?
|
|
341
|
+
|
|
342
|
+
Most mediator libraries have you register each request with its handler, and each pipeline step, in one central place that every feature has to edit. With mediary:
|
|
343
|
+
|
|
344
|
+
| | Manual registration | mediary |
|
|
345
|
+
|---|---|---|
|
|
346
|
+
| Adding a feature | write the handler, then edit the registry | write the handler |
|
|
347
|
+
| Wiring mistakes | found at the first send | found at startup, all together, by `scan` |
|
|
348
|
+
| Handler shape | inherit a base class | any class or function, checked structurally |
|
|
349
|
+
| Middleware scope | runs for everything, filters itself | declares what it wraps by type, Protocol or kind |
|
|
350
|
+
|
|
351
|
+
`register` and `use` are still there when you want explicit wiring, as in libraries and tests.
|
|
352
|
+
|
|
353
|
+
## Development
|
|
354
|
+
|
|
355
|
+
Requires [uv](https://docs.astral.sh/uv/). See [CONTRIBUTING.md](https://github.com/codeonym-oss/mediary/blob/main/CONTRIBUTING.md) for the conventions.
|
|
356
|
+
|
|
357
|
+
```sh
|
|
358
|
+
uv sync # create .venv with dev tools
|
|
359
|
+
uv run pre-commit install # lint, format and typecheck on commit
|
|
360
|
+
uv run pytest # tests + coverage gate (95%)
|
|
361
|
+
uv run pyright # strict type checking
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
## License
|
|
365
|
+
|
|
366
|
+
[MIT](https://github.com/codeonym-oss/mediary/blob/main/LICENSE)
|