py-mediatR 6.7.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.
- py_mediatr-6.7.0/LICENSE +21 -0
- py_mediatr-6.7.0/PKG-INFO +547 -0
- py_mediatr-6.7.0/README.md +486 -0
- py_mediatr-6.7.0/pyproject.toml +56 -0
- py_mediatr-6.7.0/setup.cfg +4 -0
- py_mediatr-6.7.0/src/py_mediatR/__init__.py +6 -0
- py_mediatr-6.7.0/src/py_mediatR/_config.py +68 -0
- py_mediatr-6.7.0/src/py_mediatR/_typechecks.py +82 -0
- py_mediatr-6.7.0/src/py_mediatR/behaviors.py +657 -0
- py_mediatr-6.7.0/src/py_mediatR/cancellation.py +313 -0
- py_mediatr-6.7.0/src/py_mediatR/coercion.py +159 -0
- py_mediatr-6.7.0/src/py_mediatR/contracts.py +372 -0
- py_mediatr-6.7.0/src/py_mediatR/di.py +537 -0
- py_mediatr-6.7.0/src/py_mediatR/discovery.py +737 -0
- py_mediatr-6.7.0/src/py_mediatR/mediator.py +1588 -0
- py_mediatr-6.7.0/src/py_mediatR/py.typed +0 -0
- py_mediatr-6.7.0/src/py_mediatR/py_mediatR.py +400 -0
- py_mediatr-6.7.0/src/py_mediatR/tracing.py +222 -0
- py_mediatr-6.7.0/src/py_mediatR.egg-info/PKG-INFO +547 -0
- py_mediatr-6.7.0/src/py_mediatR.egg-info/SOURCES.txt +24 -0
- py_mediatr-6.7.0/src/py_mediatR.egg-info/dependency_links.txt +1 -0
- py_mediatr-6.7.0/src/py_mediatR.egg-info/requires.txt +13 -0
- py_mediatr-6.7.0/src/py_mediatR.egg-info/top_level.txt +2 -0
- py_mediatr-6.7.0/src/py_mediatr.py +14 -0
- py_mediatr-6.7.0/tests/test_core.py +448 -0
- py_mediatr-6.7.0/tests/test_v67.py +283 -0
py_mediatr-6.7.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ayhan Öztemel
|
|
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, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-mediatR
|
|
3
|
+
Version: 6.7.0
|
|
4
|
+
Summary: .NET MediatR-inspired high-performance CQRS/Mediator for Python: async pipeline, notifications, streaming, DI (singleton/scoped/transient), CancellationToken, auto-discovery, free-threaded (no-GIL) ready.
|
|
5
|
+
Author-email: Ayhan Öztemel <ayhanoztemel@hotmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Ayhan Öztemel
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/AyhanOztemel/py_mediatR
|
|
29
|
+
Project-URL: Source, https://github.com/AyhanOztemel/py_mediatR
|
|
30
|
+
Project-URL: Issues, https://github.com/AyhanOztemel/py_mediatR/issues
|
|
31
|
+
Project-URL: Changelog, https://github.com/AyhanOztemel/py_mediatR/blob/main/README.md
|
|
32
|
+
Keywords: mediatr,cqrs,mediator,pipeline,di,dependency-injection,cancellation,nogil,free-threaded
|
|
33
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
43
|
+
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
44
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
45
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
46
|
+
Classifier: Typing :: Typed
|
|
47
|
+
Requires-Python: >=3.10
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
License-File: LICENSE
|
|
50
|
+
Provides-Extra: pydantic
|
|
51
|
+
Requires-Dist: pydantic>=1.10; extra == "pydantic"
|
|
52
|
+
Provides-Extra: test
|
|
53
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
54
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
55
|
+
Provides-Extra: dev
|
|
56
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
57
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
58
|
+
Requires-Dist: build; extra == "dev"
|
|
59
|
+
Requires-Dist: twine; extra == "dev"
|
|
60
|
+
Dynamic: license-file
|
|
61
|
+
|
|
62
|
+
# py_mediatR
|
|
63
|
+
|
|
64
|
+
> Türkçe sürüm: [README.tr.md](https://github.com/AyhanOztemel/py_mediatR/blob/main/README.tr.md)
|
|
65
|
+
|
|
66
|
+
High-performance **CQRS / Mediator** implementation for Python, inspired by
|
|
67
|
+
[.NET MediatR](https://github.com/jbogard/MediatR) (partial semantic parity,
|
|
68
|
+
plus extras like built-in caching/retry/transaction behaviors) — zero
|
|
69
|
+
dependencies, free-threaded (no-GIL) ready.
|
|
70
|
+
|
|
71
|
+
**v6.7:** call-chain tracing (`mediator.trace()`), no more silent failures in
|
|
72
|
+
transaction cleanup or the sync-over-async bridge, captive dependencies
|
|
73
|
+
rejected, `Optional[T]` autowiring, `py.typed` shipped. See
|
|
74
|
+
[What's new in 6.7](#whats-new-in-67).
|
|
75
|
+
|
|
76
|
+
**v6.4:** single-event-loop async pipeline, notification errors propagate by
|
|
77
|
+
default, explicit `ExceptionHandlerState`, typed `send() -> TResponse`,
|
|
78
|
+
JSON discovery cache (no pickle), real LRU caching, thread-safe registries.
|
|
79
|
+
|
|
80
|
+
## What's new in 6.7
|
|
81
|
+
|
|
82
|
+
Nothing was removed and no signature became stricter — 6.7 is a drop-in
|
|
83
|
+
replacement for 6.6.
|
|
84
|
+
|
|
85
|
+
### See the call chain
|
|
86
|
+
|
|
87
|
+
A mediator hides who calls what, which makes "why didn't my handler run?" hard
|
|
88
|
+
to answer. `trace()` records one dispatch as a tree:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
with mediator.trace() as flow:
|
|
92
|
+
mediator.send(SearchProducts(keyword="laptop"))
|
|
93
|
+
mediator.send(SearchProducts(keyword="laptop")) # served from cache
|
|
94
|
+
print(flow.render())
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
send(SearchProducts) (0.35 ms)
|
|
99
|
+
└─ behavior: LoggingBehavior (0.29 ms)
|
|
100
|
+
└─ behavior: ValidationBehavior (0.22 ms)
|
|
101
|
+
└─ behavior: CachingBehavior [cache miss] (0.20 ms)
|
|
102
|
+
├─ pre: AuditPreProcessor
|
|
103
|
+
├─ HANDLER: SearchProductsHandler (0.07 ms)
|
|
104
|
+
└─ post: MetricsPostProcessor
|
|
105
|
+
send(SearchProducts) (0.14 ms)
|
|
106
|
+
└─ behavior: LoggingBehavior (0.11 ms)
|
|
107
|
+
└─ behavior: ValidationBehavior (0.06 ms)
|
|
108
|
+
└─ behavior: CachingBehavior [CACHE HIT - handler NOT called]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Retries show up as repeated handler nodes, and an exception is spelled out at
|
|
112
|
+
the node that raised it while ancestors are marked `!! (propagated)`. Tracing is
|
|
113
|
+
off by default and costs one `ContextVar` lookup per step when inactive.
|
|
114
|
+
`flow.steps()` and `flow.find(label)` expose the same data for assertions;
|
|
115
|
+
`render(unicode=False)` forces ASCII glyphs.
|
|
116
|
+
|
|
117
|
+
### Failures are no longer silent
|
|
118
|
+
|
|
119
|
+
- `TransactionBehavior` no longer swallows a failing `rollback()`/`close()`.
|
|
120
|
+
The cleanup error is logged and attached to the original exception via
|
|
121
|
+
`add_note()`, or raised as `TransactionCleanupError` with
|
|
122
|
+
`raise_on_cleanup_failure=True`.
|
|
123
|
+
- Dispatching an async session through the synchronous `send()` from inside a
|
|
124
|
+
running loop is now refused up front with a `TypeError` instead of rolling
|
|
125
|
+
back on the wrong event loop.
|
|
126
|
+
- The sync-over-async bridge joins with a 30s budget
|
|
127
|
+
(`MEDIATR_SYNC_BRIDGE_TIMEOUT`, `<=0` disables) and raises
|
|
128
|
+
`SyncBridgeTimeoutError` rather than freezing the calling thread forever.
|
|
129
|
+
- Fire-and-forget notification tasks are kept strongly referenced, so
|
|
130
|
+
`PARALLEL_NOWAIT` subscribers can no longer be garbage-collected mid-flight.
|
|
131
|
+
- Auto-discovery import errors are logged as warnings on the
|
|
132
|
+
`mediatr.discovery` logger (silence with `MEDIATR_DISCOVERY_WARNINGS=0`).
|
|
133
|
+
|
|
134
|
+
### DI correctness
|
|
135
|
+
|
|
136
|
+
- A singleton consuming a scoped service is now rejected with
|
|
137
|
+
`DIResolutionError` — .NET's captive-dependency rule.
|
|
138
|
+
- `Optional[T]`, `T | None` and `Annotated[...]` constructor hints are
|
|
139
|
+
unwrapped; an optional dependency that cannot be built is injected as `None`.
|
|
140
|
+
|
|
141
|
+
### Packaging
|
|
142
|
+
|
|
143
|
+
- `py.typed` ships, so type checkers use the inline annotations.
|
|
144
|
+
- `import py_mediatr` (all lowercase) also works, for case-sensitive
|
|
145
|
+
filesystems where the normalised distribution name misleads.
|
|
146
|
+
- The implementation was split from one 4 600-line module into a package of
|
|
147
|
+
focused modules. Nothing you import changes — see
|
|
148
|
+
[Package layout](#package-layout).
|
|
149
|
+
|
|
150
|
+
## Features
|
|
151
|
+
|
|
152
|
+
- **Request/Response** — `IRequest[TResponse]` generics, sync `send()` and async `send_async()`
|
|
153
|
+
- **Notifications (pub/sub)** — multiple handlers, ordering, SEQUENTIAL / PARALLEL_WHENALL / PARALLEL_NOWAIT strategies, custom publisher
|
|
154
|
+
- **Pipeline behaviors** — `IPipelineBehavior` middleware + 8 built-ins (Logging, Performance, Validation, Caching, Retry, Transaction, Authorization, Tracing)
|
|
155
|
+
- **Streaming** — `IStreamRequest` + `create_stream()` (`IAsyncEnumerable<T>` equivalent)
|
|
156
|
+
- **Pre/Post processors, exception handlers & actions**
|
|
157
|
+
- **Dependency Injection** — `ServiceContainer` with **singleton / scoped / transient** lifetimes, type-hint based constructor auto-wiring, `ServiceScope` (sync+async), request-scoped mediator, FastAPI bridge
|
|
158
|
+
- **CancellationToken** — `CancellationTokenSource` with `cancel_after`, linked tokens, handler injection (`handle(self, req, cancellation_token=...)`), streaming cancellation
|
|
159
|
+
- **Auto-discovery** — handlers found by scanning the project (with cache), or explicit `@handler` / `@behavior` decorators
|
|
160
|
+
- **Call-chain tracing** — `mediator.trace()` renders behaviors, handler and subscribers as a tree
|
|
161
|
+
- **Free-threaded ready** — safe under Python 3.13t/3.14t (no-GIL)
|
|
162
|
+
|
|
163
|
+
## Install
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
pip install py-mediatR # core (zero dependencies)
|
|
167
|
+
pip install "py-mediatR[pydantic]" # optional pydantic model coercion
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Quick start
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from py_mediatR import Mediator, IRequest
|
|
174
|
+
|
|
175
|
+
class Ping(IRequest):
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
class PingHandler:
|
|
179
|
+
def handle(self, req: Ping) -> str:
|
|
180
|
+
return "pong"
|
|
181
|
+
|
|
182
|
+
mediator = Mediator(auto_discover=False)
|
|
183
|
+
mediator.register_handler(Ping, PingHandler())
|
|
184
|
+
print(mediator.send(Ping())) # pong
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### DI + scoped lifetime (per-request DB session)
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from py_mediatR import Mediator, ServiceContainer, scoped_mediator
|
|
191
|
+
|
|
192
|
+
container = ServiceContainer()
|
|
193
|
+
container.register_singleton(Config)
|
|
194
|
+
container.register_scoped(DbSession) # one per scope/request
|
|
195
|
+
container.register_transient(UserRepository)
|
|
196
|
+
|
|
197
|
+
mediator = Mediator(handler_factory=container)
|
|
198
|
+
with scoped_mediator(mediator, container) as m:
|
|
199
|
+
m.send(CreateUser(name="Ada")) # handlers share the scoped session
|
|
200
|
+
# scope disposed -> session.close()
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### CancellationToken
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from py_mediatR import CancellationTokenSource, OperationCancelledError
|
|
207
|
+
|
|
208
|
+
cts = CancellationTokenSource(cancel_after=2.0) # timeout
|
|
209
|
+
try:
|
|
210
|
+
result = await mediator.send_async(SlowQuery(), cancellation_token=cts.token)
|
|
211
|
+
except OperationCancelledError:
|
|
212
|
+
...
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### FastAPI
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
from py_mediatR import make_fastapi_mediator_dependency
|
|
219
|
+
get_mediator = make_fastapi_mediator_dependency(mediator, container)
|
|
220
|
+
|
|
221
|
+
@app.post("/orders")
|
|
222
|
+
async def create_order(cmd: CreateOrderDto, m=Depends(get_mediator)):
|
|
223
|
+
return await m.send_async(CreateOrder(**cmd.model_dump()))
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## How a handler is found
|
|
227
|
+
|
|
228
|
+
There is **no naming convention**. A class becomes a handler because of the
|
|
229
|
+
*type annotation* on its `handle()` parameter — the class name is irrelevant:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
class GetUser(IRequest):
|
|
233
|
+
user_id: str
|
|
234
|
+
|
|
235
|
+
class ThisNameDoesNotMatter: # not "GetUserHandler" — still found
|
|
236
|
+
def handle(self, req: GetUser): # <- the annotation is the contract
|
|
237
|
+
return {"user_id": req.user_id}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The rules, exactly:
|
|
241
|
+
|
|
242
|
+
| Kind | Detected by |
|
|
243
|
+
|---|---|
|
|
244
|
+
| Request handler | `handle()` has a parameter annotated with an `IRequest` subclass |
|
|
245
|
+
| Stream handler | same, but the annotation is an `IStreamRequest` subclass |
|
|
246
|
+
| Notification handler | `handle()` has a parameter annotated with an `INotification` subclass |
|
|
247
|
+
|
|
248
|
+
Details that bite:
|
|
249
|
+
|
|
250
|
+
- The annotation must be **resolvable** at import time. A wrong or unimportable
|
|
251
|
+
forward reference makes the class invisible rather than raising.
|
|
252
|
+
- Exactly **one** `IRequest` parameter is allowed; two raises `TypeError`.
|
|
253
|
+
- Extra parameters are fine — `cancellation_token` is injected by name.
|
|
254
|
+
- A handler whose `__init__` takes arguments is registered *deferred* and
|
|
255
|
+
resolved through `handler_factory` (your `ServiceContainer`) at dispatch time.
|
|
256
|
+
- One handler per request type; notifications may have many.
|
|
257
|
+
|
|
258
|
+
### Response coercion — the one place naming matters
|
|
259
|
+
|
|
260
|
+
If a handler returns a `dict`, py_mediatR coerces it into a response class
|
|
261
|
+
found **by name in the same module**: `GetUser` → `GetUserResponse`
|
|
262
|
+
(or `GetUserRequest` → `GetUserResponse`). An explicit `-> GetUserResponse`
|
|
263
|
+
return annotation takes priority. With neither, the `dict` is returned as-is.
|
|
264
|
+
|
|
265
|
+
### Discovery scope
|
|
266
|
+
|
|
267
|
+
`Mediator()` scans `project_root` recursively for `*.py`, skipping `venv`,
|
|
268
|
+
`.venv`, `env`, `site-packages`, `__pycache__`, `.git`, `node_modules`, `.tox`,
|
|
269
|
+
`.nox`, `.eggs`, `build`, `dist`, `migrations` and the various `.*_cache` dirs.
|
|
270
|
+
Narrow it with `Mediator(scan_paths=[...])`. Import errors are logged as
|
|
271
|
+
warnings on the `mediatr.discovery` logger — they are never silent.
|
|
272
|
+
|
|
273
|
+
Prefer explicitness on large codebases:
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
mediator = Mediator(auto_discover=False)
|
|
277
|
+
mediator.register_handler(GetUser, GetUserHandler())
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
or decorate and let discovery collect them:
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
from py_mediatR import handler, behavior
|
|
284
|
+
|
|
285
|
+
@handler
|
|
286
|
+
class GetUserHandler:
|
|
287
|
+
def handle(self, req: GetUser): ...
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## The pipeline
|
|
291
|
+
|
|
292
|
+
Every `send()` runs through the same onion. Behaviors are sorted by `order`,
|
|
293
|
+
**smallest first, outermost** — a low `order` starts first and finishes last:
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
send(request)
|
|
297
|
+
└─ behavior (order -100) ← outermost
|
|
298
|
+
└─ behavior (order -50)
|
|
299
|
+
├─ pre-processor ← side effects only
|
|
300
|
+
├─ HANDLER ← your business logic
|
|
301
|
+
└─ post-processor ← sees the response, cannot change it
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
On an exception: every matching `IExceptionAction` observes it (without
|
|
305
|
+
swallowing), then the first matching `IExceptionHandler` may replace it with a
|
|
306
|
+
fallback response.
|
|
307
|
+
|
|
308
|
+
Use `mediator.trace()` to print the actual tree for a real request — that is
|
|
309
|
+
the authoritative answer to "which behavior called what".
|
|
310
|
+
|
|
311
|
+
### Built-in behaviors and their contracts
|
|
312
|
+
|
|
313
|
+
Each built-in reads an attribute off the **request**. If the attribute is
|
|
314
|
+
absent the behavior is a no-op, so a request opts in to exactly what it needs.
|
|
315
|
+
|
|
316
|
+
| Behavior | `order` | Opt-in on the request | Effect |
|
|
317
|
+
|---|---|---|---|
|
|
318
|
+
| `LoggingBehavior` | -100 | — (always) | logs entry/exit |
|
|
319
|
+
| `TracingBehavior` | -95 | — (always) | correlation id per request |
|
|
320
|
+
| `PerformanceBehavior` | -90 | — (always) | warns past a duration threshold |
|
|
321
|
+
| `AuthorizationBehavior` | -85 | `requires_permission` | denies with `UnauthorizedError`; handler never runs |
|
|
322
|
+
| `ValidationBehavior` | -80 | `validate()` method | raises before the handler |
|
|
323
|
+
| `CachingBehavior` | -70 | `cacheable = True` | TTL + LRU; a hit skips the handler entirely |
|
|
324
|
+
| `RetryBehavior` | -60 | — (always) | re-runs on exception, exponential backoff + jitter |
|
|
325
|
+
| `TransactionBehavior` | -50 | `transactional = True` | commit on success, rollback on error |
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
from dataclasses import dataclass
|
|
329
|
+
from py_mediatR import IRequest
|
|
330
|
+
|
|
331
|
+
@dataclass
|
|
332
|
+
class PlaceOrder(IRequest):
|
|
333
|
+
sku: str
|
|
334
|
+
qty: int = 1
|
|
335
|
+
|
|
336
|
+
transactional = True # TransactionBehavior engages
|
|
337
|
+
requires_permission = "orders.write" # AuthorizationBehavior checks this
|
|
338
|
+
|
|
339
|
+
def validate(self) -> None: # ValidationBehavior calls this
|
|
340
|
+
if self.qty < 1:
|
|
341
|
+
raise ValueError("qty must be positive")
|
|
342
|
+
|
|
343
|
+
@dataclass
|
|
344
|
+
class SearchProducts(IRequest):
|
|
345
|
+
keyword: str
|
|
346
|
+
cacheable = True # CachingBehavior stores the response
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Wiring them up — note that `AuthorizationBehavior` and `TransactionBehavior`
|
|
350
|
+
need a callback, so they are constructed, not just listed:
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
from py_mediatR import (
|
|
354
|
+
Mediator, LoggingBehavior, ValidationBehavior, CachingBehavior,
|
|
355
|
+
RetryBehavior, TransactionBehavior, AuthorizationBehavior,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
mediator = Mediator(behaviors=[
|
|
359
|
+
LoggingBehavior(),
|
|
360
|
+
AuthorizationBehavior(lambda req, perm: current_user.has(perm)),
|
|
361
|
+
ValidationBehavior(),
|
|
362
|
+
CachingBehavior(ttl_seconds=60, max_size=1000),
|
|
363
|
+
RetryBehavior(max_attempts=3, delay=0.1, backoff=2.0, jitter=0.05),
|
|
364
|
+
TransactionBehavior(session_factory=lambda: SessionLocal()),
|
|
365
|
+
])
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Writing your own behavior
|
|
369
|
+
|
|
370
|
+
`next_handler()` runs the rest of the chain. Not calling it short-circuits the
|
|
371
|
+
handler entirely — that is exactly how `CachingBehavior` serves a hit:
|
|
372
|
+
|
|
373
|
+
```python
|
|
374
|
+
from py_mediatR import IPipelineBehavior
|
|
375
|
+
|
|
376
|
+
class AuditBehavior(IPipelineBehavior):
|
|
377
|
+
order = -110 # lower than LoggingBehavior -> runs outermost
|
|
378
|
+
applies_to = PlaceOrder # optional; None (default) means every request
|
|
379
|
+
|
|
380
|
+
def handle(self, request, next_handler):
|
|
381
|
+
audit.write(f"-> {type(request).__name__}")
|
|
382
|
+
response = next_handler()
|
|
383
|
+
audit.write(f"<- {type(request).__name__}")
|
|
384
|
+
return response
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`applies_to` narrows a behavior to one request type. In the async pipeline
|
|
388
|
+
`handle()` may be `async def`, and `next_handler()` returns an awaitable.
|
|
389
|
+
|
|
390
|
+
### Pre/post processors
|
|
391
|
+
|
|
392
|
+
Both are side-effect only and support `order` and `applies_to`. A post
|
|
393
|
+
processor *sees* the response but cannot replace it — return a different value
|
|
394
|
+
from a behavior if you need that.
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
from py_mediatR import IRequestPreProcessor, IRequestPostProcessor
|
|
398
|
+
|
|
399
|
+
class AuditPre(IRequestPreProcessor):
|
|
400
|
+
def process(self, request): # may be async def
|
|
401
|
+
audit.write(type(request).__name__)
|
|
402
|
+
|
|
403
|
+
class MetricsPost(IRequestPostProcessor):
|
|
404
|
+
def process(self, request, response): # may be async def
|
|
405
|
+
metrics.increment(type(request).__name__)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### Exception handlers vs actions
|
|
409
|
+
|
|
410
|
+
| | Purpose | Return value |
|
|
411
|
+
|---|---|---|
|
|
412
|
+
| `IExceptionHandler.handle` | replace the error with a fallback response | returning a value swallows the exception; `raise` propagates |
|
|
413
|
+
| `IExceptionAction.execute` | observe only (log, alert, metric) | ignored — the exception keeps propagating |
|
|
414
|
+
|
|
415
|
+
Both filter on `exception_type` (default `Exception`), `applies_to` and
|
|
416
|
+
`order`. All matching actions run; the **first** matching handler that returns
|
|
417
|
+
a value wins.
|
|
418
|
+
|
|
419
|
+
```python
|
|
420
|
+
from py_mediatR import IExceptionHandler, IExceptionAction
|
|
421
|
+
|
|
422
|
+
class InvoiceNotFound(IExceptionHandler):
|
|
423
|
+
exception_type = KeyError
|
|
424
|
+
applies_to = FindInvoice
|
|
425
|
+
def handle(self, request, exc):
|
|
426
|
+
return FindInvoiceResponse(invoice_id=request.invoice_id, found=False)
|
|
427
|
+
|
|
428
|
+
class AlertAction(IExceptionAction):
|
|
429
|
+
exception_type = Exception
|
|
430
|
+
def execute(self, request, exc):
|
|
431
|
+
alerting.notify(f"{type(request).__name__} failed: {exc}")
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Validators (FluentValidation style)
|
|
435
|
+
|
|
436
|
+
Keep validation out of the request when it needs dependencies:
|
|
437
|
+
|
|
438
|
+
```python
|
|
439
|
+
from py_mediatR import IValidator, ValidationBehavior
|
|
440
|
+
|
|
441
|
+
class CreateUserValidator(IValidator):
|
|
442
|
+
applies_to = CreateUser # or a tuple of types; None = all
|
|
443
|
+
def validate(self, request):
|
|
444
|
+
if "@" not in request.email:
|
|
445
|
+
raise ValueError(f"invalid e-mail: {request.email}")
|
|
446
|
+
|
|
447
|
+
mediator = Mediator(behaviors=[ValidationBehavior(validators=[CreateUserValidator()])])
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Matching validators run in `order`, then the request's own `validate()`.
|
|
451
|
+
|
|
452
|
+
### Notifications
|
|
453
|
+
|
|
454
|
+
Multiple subscribers per event, ordered by `order`. Choose how they run:
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
from py_mediatR import PublishStrategy
|
|
458
|
+
|
|
459
|
+
mediator.publish(UserRegistered(user_id="U-1")) # SEQUENTIAL
|
|
460
|
+
await mediator.publish_async(evt, strategy=PublishStrategy.PARALLEL_WHENALL)
|
|
461
|
+
await mediator.publish_async(evt, strategy=PublishStrategy.PARALLEL_NOWAIT)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
| Strategy | Semantics |
|
|
465
|
+
|---|---|
|
|
466
|
+
| `SEQUENTIAL` (default) | one after another, in `order`; first error propagates |
|
|
467
|
+
| `PARALLEL_WHENALL` | concurrent, waits for all |
|
|
468
|
+
| `PARALLEL_NOWAIT` | fire-and-forget; tasks are strongly referenced so they cannot be GC'd |
|
|
469
|
+
|
|
470
|
+
Subscriber errors propagate by default. Pass
|
|
471
|
+
`Mediator(swallow_notification_errors=True)` for the older forgiving behavior.
|
|
472
|
+
With `polymorphic_publish=True`, publishing a subclass also triggers base-type
|
|
473
|
+
subscribers (.NET covariance).
|
|
474
|
+
|
|
475
|
+
### Streaming
|
|
476
|
+
|
|
477
|
+
`create_stream()` yields items as they are produced, and
|
|
478
|
+
`IStreamPipelineBehavior` wraps the generator:
|
|
479
|
+
|
|
480
|
+
```python
|
|
481
|
+
from py_mediatR import IStreamRequest, IStreamPipelineBehavior
|
|
482
|
+
|
|
483
|
+
class StreamOrders(IStreamRequest):
|
|
484
|
+
count: int = 100
|
|
485
|
+
|
|
486
|
+
class StreamOrdersHandler:
|
|
487
|
+
async def handle(self, req: StreamOrders):
|
|
488
|
+
for i in range(req.count):
|
|
489
|
+
yield {"seq": i}
|
|
490
|
+
|
|
491
|
+
class StreamAudit(IStreamPipelineBehavior):
|
|
492
|
+
async def handle(self, request, next_handler):
|
|
493
|
+
async for item in next_handler():
|
|
494
|
+
yield item
|
|
495
|
+
|
|
496
|
+
async for order in mediator.create_stream(StreamOrders(count=10)):
|
|
497
|
+
...
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
## Examples
|
|
501
|
+
|
|
502
|
+
Four runnable apps sharing one layered core live in
|
|
503
|
+
[`examples/`](https://github.com/AyhanOztemel/py_mediatR/blob/main/examples/README.md)
|
|
504
|
+
— console, FastAPI, Flask and Django, each with its own README:
|
|
505
|
+
|
|
506
|
+
- [`examples/console_app`](https://github.com/AyhanOztemel/py_mediatR/blob/main/examples/console_app/README.md)
|
|
507
|
+
— start here: ten steps, each printing its call-chain tree
|
|
508
|
+
- [`examples/fastapi_app`](https://github.com/AyhanOztemel/py_mediatR/blob/main/examples/fastapi_app/README.md)
|
|
509
|
+
— async routes, one DI scope per request
|
|
510
|
+
- [`examples/flask_app`](https://github.com/AyhanOztemel/py_mediatR/blob/main/examples/flask_app/README.md)
|
|
511
|
+
— synchronous routes, explicit scopes
|
|
512
|
+
- [`examples/django_app`](https://github.com/AyhanOztemel/py_mediatR/blob/main/examples/django_app/README.md)
|
|
513
|
+
— views that do not know their handlers
|
|
514
|
+
|
|
515
|
+
The examples are not shipped in the wheel; they live in the repository.
|
|
516
|
+
|
|
517
|
+
## Package layout
|
|
518
|
+
|
|
519
|
+
Import from the top-level package. That is the only supported surface:
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
from py_mediatR import Mediator, IRequest, IPipelineBehavior
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Every name in `__all__` is re-exported there, so the layout below is an
|
|
526
|
+
implementation detail — it is documented to help you read the source, not to
|
|
527
|
+
be imported from.
|
|
528
|
+
|
|
529
|
+
| Module | Holds |
|
|
530
|
+
|---|---|
|
|
531
|
+
| `contracts.py` | `IRequest`, `IResponse`, `INotification`, the `I*` interfaces, `PublishStrategy`, `@handler` / `@behavior` |
|
|
532
|
+
| `mediator.py` | `Mediator`, `ISender` / `IPublisher`, pipeline compilation, `send` / `publish` / `create_stream` |
|
|
533
|
+
| `di.py` | `ServiceContainer`, `ServiceScope`, `scoped_mediator`, `make_fastapi_mediator_dependency` |
|
|
534
|
+
| `discovery.py` | project scanning, type-hint based handler resolution, `discover_handlers` |
|
|
535
|
+
| `behaviors.py` | the eight built-in behaviors |
|
|
536
|
+
| `cancellation.py` | `CancellationToken`, `CancellationTokenSource`, `current_cancellation_token` |
|
|
537
|
+
| `tracing.py` | `FlowNode`, `FlowTrace`, `trace_flow` — what `mediator.trace()` renders |
|
|
538
|
+
| `coercion.py` | dict → dataclass/pydantic coercion, the sync-over-async bridge |
|
|
539
|
+
| `_config.py`, `_typechecks.py` | flags, sentinels, internal type predicates |
|
|
540
|
+
|
|
541
|
+
`py_mediatR.py_mediatR` remains importable and still exposes every name it did
|
|
542
|
+
before the split, so code written against the single-module layout keeps
|
|
543
|
+
working unchanged.
|
|
544
|
+
|
|
545
|
+
## License
|
|
546
|
+
|
|
547
|
+
MIT — Ayhan Öztemel
|