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 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
+ [![PyPI](https://img.shields.io/pypi/v/mediary)](https://pypi.org/project/mediary/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/mediary)](https://pypi.org/project/mediary/)
33
+ [![CI](https://github.com/codeonym-oss/mediary/actions/workflows/ci.yml/badge.svg)](https://github.com/codeonym-oss/mediary/actions/workflows/ci.yml)
34
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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)