python-wrap 0.2.1__py3-none-any.whl

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.
@@ -0,0 +1,385 @@
1
+ """Developer tools: deprecation warnings, mock data and decorator composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ import warnings
8
+ from collections.abc import Awaitable, Callable, Iterable
9
+ from typing import Any, TypeVar, cast, overload
10
+
11
+ from python_wrap import _core
12
+ from python_wrap._core import P, R
13
+
14
+ F = TypeVar("F", bound=Callable[..., Any])
15
+ T = TypeVar("T")
16
+
17
+ __all__ = ["compose", "deprecate", "mock_data", "no_debug", "wrap_methods"]
18
+
19
+
20
+ def _mark(obj: T, text: str) -> T:
21
+ """Set the PEP 702 style ``__deprecated__`` attribute."""
22
+ obj.__deprecated__ = text # type: ignore[attr-defined]
23
+ return obj
24
+
25
+
26
+ def _message_for(
27
+ target: Any,
28
+ message: str | None,
29
+ since: str | None,
30
+ removed_in: str | None,
31
+ alternative: str | None,
32
+ ) -> str:
33
+ """Return an explicit message, or build one from the parts that were given."""
34
+ if message is not None:
35
+ return message
36
+ func = target.__func__ if isinstance(target, (staticmethod, classmethod)) else target
37
+ text = f"{_core.qualname(func)} is deprecated"
38
+ if since:
39
+ text += f" since {since}"
40
+ if removed_in:
41
+ text += f" and will be removed in {removed_in}"
42
+ if alternative:
43
+ text += f"; use {alternative} instead"
44
+ return text + "."
45
+
46
+
47
+ def _wrap_class(cls: type, text: str, category: type[Warning]) -> type:
48
+ """Wrap ``__init__`` (or ``__new__``) in place, so the class stays the same object.
49
+
50
+ ``__new__`` is wrapped instead when the class has no ``__init__`` of its own but
51
+ a custom ``__new__`` (``float`` subclasses, ``NamedTuple``...): installing an
52
+ ``__init__`` there would make ``object.__init__`` reject the arguments.
53
+ """
54
+ current_new: Any = cls.__new__
55
+ use_new = "__init__" not in cls.__dict__ and current_new is not object.__new__
56
+ if use_new:
57
+ original_new = current_new
58
+
59
+ def warned_new(klass: Any, *args: Any, **kwargs: Any) -> Any:
60
+ warnings.warn(text, category, stacklevel=2)
61
+ return original_new(klass, *args, **kwargs)
62
+
63
+ warned_new.__name__ = "__new__"
64
+ warned_new.__qualname__ = f"{cls.__qualname__}.__new__"
65
+ cls.__new__ = staticmethod(warned_new) # type: ignore[method-assign,assignment]
66
+ return _mark(cls, text)
67
+
68
+ original_init: Any = cls.__init__ # type: ignore[misc]
69
+
70
+ @functools.wraps(original_init)
71
+ def warned_init(self: Any, *args: Any, **kwargs: Any) -> None:
72
+ warnings.warn(text, category, stacklevel=2)
73
+ original_init(self, *args, **kwargs)
74
+
75
+ cls.__init__ = warned_init # type: ignore[misc]
76
+ return _mark(cls, text)
77
+
78
+
79
+ def _wrap_callable(func: Any, text: str, category: type[Warning]) -> Any:
80
+ """Wrap a plain or async function so each call (or await) warns."""
81
+ if _core.is_async(func):
82
+
83
+ @functools.wraps(func)
84
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
85
+ warnings.warn(text, category, stacklevel=2)
86
+ return await func(*args, **kwargs)
87
+
88
+ return _mark(async_wrapper, text)
89
+
90
+ @functools.wraps(func)
91
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
92
+ warnings.warn(text, category, stacklevel=2)
93
+ return func(*args, **kwargs)
94
+
95
+ return _mark(wrapper, text)
96
+
97
+
98
+ def _wrap(target: Any, text: str, category: type[Warning]) -> Any:
99
+ """Dispatch on the kind of object being deprecated."""
100
+ if isinstance(target, (staticmethod, classmethod)):
101
+ # Keep the descriptor type so the method still binds the same way.
102
+ return type(target)(_wrap(target.__func__, text, category))
103
+ if isinstance(target, type):
104
+ return _wrap_class(target, text, category)
105
+ return _wrap_callable(target, text, category)
106
+
107
+
108
+ @overload
109
+ def deprecate(target: F, /) -> F: ...
110
+ @overload
111
+ def deprecate(
112
+ message: str | None = None,
113
+ *,
114
+ since: str | None = None,
115
+ removed_in: str | None = None,
116
+ alternative: str | None = None,
117
+ category: type[Warning] = DeprecationWarning,
118
+ ) -> Callable[[F], F]: ...
119
+ def deprecate(
120
+ message: str | Callable[..., Any] | None = None,
121
+ *,
122
+ since: str | None = None,
123
+ removed_in: str | None = None,
124
+ alternative: str | None = None,
125
+ category: type[Warning] = DeprecationWarning,
126
+ ) -> Any:
127
+ """Mark a function, method, or class as deprecated.
128
+
129
+ Every call (or, for ``async def``, every await) emits a warning that points at
130
+ the caller's line. Classes are changed in place: their ``__init__`` warns, and
131
+ they stay classes, so ``isinstance`` keeps working.
132
+
133
+ Works bare, with a message (the v0.1 form), or with structured parts. The parts
134
+ are combined into ``"<name> is deprecated since ... and will be removed in ...;
135
+ use ... instead."``. An explicit ``message`` replaces that generated text.
136
+
137
+ Args:
138
+ message: Full warning text. Replaces the generated text when given.
139
+ since: Version in which the deprecation started.
140
+ removed_in: Version in which the target will be removed.
141
+ alternative: Name of the replacement to use instead.
142
+ category: Warning class to emit.
143
+
144
+ Returns:
145
+ The decorated object when used bare, otherwise a decorator.
146
+
147
+ Example:
148
+ ```python
149
+ @deprecate(since="0.2", removed_in="1.0", alternative="new_func")
150
+ def old_func() -> None: ...
151
+
152
+ @deprecate("Use Widget2 instead")
153
+ class Widget: ...
154
+ ```
155
+ """
156
+ if callable(message):
157
+ text = _message_for(message, None, since, removed_in, alternative)
158
+ return _wrap(message, text, category)
159
+
160
+ def decorate(target: Any) -> Any:
161
+ text = _message_for(target, message, since, removed_in, alternative)
162
+ return _wrap(target, text, category)
163
+
164
+ return decorate
165
+
166
+
167
+ def mock_data(data: Any, *, enabled: bool | Callable[[], bool] = True) -> Callable[[F], F]:
168
+ """Replace a function's result with fixed data, for tests and local runs.
169
+
170
+ When enabled, the wrapped function is not called and ``data`` is returned. If
171
+ ``data`` is callable it is called on each call and its result is returned, so use
172
+ ``lambda: fn`` to return a callable itself. When disabled, the real function runs.
173
+
174
+ Args:
175
+ data: Value to return, or a zero-argument callable producing it.
176
+ enabled: ``True``/``False``, or a zero-argument callable that returns one.
177
+ It is evaluated on every call, so it can read an environment variable.
178
+
179
+ Returns:
180
+ A decorator that works on plain and ``async def`` functions.
181
+
182
+ Example:
183
+ ```python
184
+ @mock_data({"id": 1}, enabled=lambda: os.environ.get("MOCK") == "1")
185
+ def fetch_user(user_id: int) -> dict[str, int]: ...
186
+ ```
187
+ """
188
+
189
+ def decorate(func: Callable[P, R]) -> Callable[P, R]:
190
+ if _core.is_async(func):
191
+ async_func = cast("Callable[P, Awaitable[Any]]", func)
192
+
193
+ @functools.wraps(func)
194
+ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
195
+ if _is_enabled(enabled):
196
+ return _mock_value(data)
197
+ return await async_func(*args, **kwargs)
198
+
199
+ return cast("Callable[P, R]", async_wrapper)
200
+
201
+ @functools.wraps(func)
202
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
203
+ if _is_enabled(enabled):
204
+ return _mock_value(data)
205
+ return func(*args, **kwargs)
206
+
207
+ return cast("Callable[P, R]", wrapper)
208
+
209
+ return cast("Callable[[F], F]", decorate)
210
+
211
+
212
+ def _is_enabled(enabled: bool | Callable[[], bool]) -> bool:
213
+ return enabled() if callable(enabled) else enabled
214
+
215
+
216
+ def _mock_value(data: Any) -> Any:
217
+ return data() if callable(data) else data
218
+
219
+
220
+ @overload
221
+ def no_debug(func: F, /) -> F: ...
222
+ @overload
223
+ def no_debug(func: None = None, /) -> Callable[[F], F]: ...
224
+ def no_debug(func: Callable[..., Any] | None = None, /) -> Any:
225
+ """Deprecated no-op, kept only so v0.1 code keeps importing.
226
+
227
+ It returns the function unchanged and emits a ``DeprecationWarning`` when applied.
228
+ It will be removed in 0.3. To hide debug output, configure logging levels instead.
229
+
230
+ Args:
231
+ func: The function to return unchanged. Omit it to get a decorator.
232
+
233
+ Returns:
234
+ ``func`` itself, or an identity decorator when ``func`` is omitted.
235
+
236
+ Example:
237
+ ```python
238
+ @no_debug # warns: removed in 0.3, use logging levels instead
239
+ def compute() -> int:
240
+ return 1
241
+ ```
242
+ """
243
+ warnings.warn(
244
+ "no_debug() does nothing and will be removed in 0.3; "
245
+ "configure logging levels instead (see logging.Logger.setLevel).",
246
+ DeprecationWarning,
247
+ stacklevel=2,
248
+ )
249
+ if func is None:
250
+
251
+ def decorate(target: F) -> F:
252
+ return target
253
+
254
+ return decorate
255
+ return func
256
+
257
+
258
+ def compose(*decorators: Callable[[Any], Any]) -> Callable[[F], F]:
259
+ """Combine decorators into one, applied as if stacked from top to bottom.
260
+
261
+ ``compose(a, b, c)(f)`` is the same as ``a(b(c(f)))``. With no decorators it
262
+ returns the function unchanged.
263
+
264
+ Args:
265
+ *decorators: Decorators to combine, outermost first.
266
+
267
+ Returns:
268
+ A single decorator that applies all of them.
269
+
270
+ Raises:
271
+ TypeError: If any argument is not callable.
272
+
273
+ Example:
274
+ ```python
275
+ @compose(deprecate("old api"), mock_data(7))
276
+ def value() -> int:
277
+ return 1
278
+ ```
279
+ """
280
+ for index, decorator in enumerate(decorators):
281
+ if not callable(decorator):
282
+ raise TypeError(
283
+ f"compose() arguments must be callable; argument {index} is {decorator!r}"
284
+ )
285
+
286
+ def decorate(func: F) -> F:
287
+ result: Any = func
288
+ for decorator in reversed(decorators):
289
+ result = decorator(result)
290
+ return cast(F, result)
291
+
292
+ return decorate
293
+
294
+
295
+ C = TypeVar("C", bound=type)
296
+
297
+
298
+ def _is_dunder(name: str) -> bool:
299
+ return name.startswith("__") and name.endswith("__")
300
+
301
+
302
+ def wrap_methods(
303
+ *decorators: Callable[[Any], Any],
304
+ include: Iterable[str] | Callable[[str], bool] | None = None,
305
+ exclude: Iterable[str] | Callable[[str], bool] | None = None,
306
+ include_private: bool = False,
307
+ ) -> Callable[[C], C]:
308
+ """Class decorator that applies decorators to the methods of a class.
309
+
310
+ Decorators are given outermost first, like `compose`; a `Policy` works too.
311
+ Plain, ``async def``, static and class methods are all handled: the underlying
312
+ function is decorated and re-wrapped in ``staticmethod``/``classmethod``. Only
313
+ methods defined on the class itself are touched, not inherited ones.
314
+
315
+ By default dunder methods, names starting with ``_`` and properties are skipped.
316
+ Naming one in `include` (a collection of names, not a predicate) opts it in;
317
+ for a property, the getter, setter and deleter are all decorated.
318
+
319
+ Args:
320
+ *decorators: Decorators to apply, outermost first.
321
+ include: Names (or a predicate on the name) to restrict the selection to.
322
+ ``None`` selects every eligible method.
323
+ exclude: Names (or a predicate) to leave alone. Wins over `include`.
324
+ include_private: Also select single-underscore names such as ``_helper``.
325
+
326
+ Returns:
327
+ A class decorator that modifies and returns the same class.
328
+
329
+ Raises:
330
+ TypeError: If any decorator is not callable.
331
+
332
+ Example:
333
+ ```python
334
+ @wrap_methods(retry(max_attempts=3, on=ConnectionError), exclude=["close"])
335
+ class Client:
336
+ def get(self, url: str) -> str: ...
337
+ def close(self) -> None: ...
338
+ ```
339
+ """
340
+ for index, decorator in enumerate(decorators):
341
+ if not callable(decorator):
342
+ raise TypeError(
343
+ f"wrap_methods() arguments must be callable; argument {index} is {decorator!r}"
344
+ )
345
+
346
+ def matcher(spec: Iterable[str] | Callable[[str], bool]) -> Callable[[str], bool]:
347
+ if callable(spec):
348
+ return spec
349
+ names = {spec} if isinstance(spec, str) else set(spec)
350
+ return names.__contains__
351
+
352
+ explicit: frozenset[str] = frozenset()
353
+ if include is not None and not callable(include):
354
+ explicit = frozenset({include} if isinstance(include, str) else include)
355
+ include_ok = matcher(include) if include is not None else (lambda _n: True)
356
+ exclude_ok = matcher(exclude) if exclude is not None else (lambda _n: False)
357
+
358
+ def decorate_func(func: Any) -> Any:
359
+ result = func
360
+ for decorator in reversed(decorators):
361
+ result = decorator(result)
362
+ return result
363
+
364
+ def decorate(cls: C) -> C:
365
+ for name, attr in list(vars(cls).items()):
366
+ if exclude_ok(name) or not include_ok(name):
367
+ continue
368
+ named = name in explicit
369
+ if not named and (_is_dunder(name) or (name.startswith("_") and not include_private)):
370
+ continue
371
+ if isinstance(attr, property):
372
+ if not named:
373
+ continue
374
+ fget, fset, fdel = (
375
+ decorate_func(p) if p is not None else None
376
+ for p in (attr.fget, attr.fset, attr.fdel)
377
+ )
378
+ setattr(cls, name, property(fget, fset, fdel, attr.__doc__))
379
+ elif isinstance(attr, (staticmethod, classmethod)):
380
+ setattr(cls, name, type(attr)(decorate_func(attr.__func__)))
381
+ elif inspect.isfunction(attr):
382
+ setattr(cls, name, decorate_func(attr))
383
+ return cls
384
+
385
+ return decorate
python_wrap/errors.py ADDED
@@ -0,0 +1,86 @@
1
+ """Exceptions raised by python-wrap.
2
+
3
+ Every one subclasses `PythonWrapError`. Those with a natural built-in counterpart also
4
+ subclass it (`TimeoutExpired` is a `TimeoutError`, `ValidationError` a `ValueError`,
5
+ `ArgumentTypeError` a `TypeError`), so ``except TimeoutError`` or ``except ValueError``
6
+ keeps working for code written against v0.1.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class PythonWrapError(Exception):
13
+ """Base class for every exception this package raises."""
14
+
15
+
16
+ class TimeoutExpired(PythonWrapError, TimeoutError):
17
+ """The wrapped call did not finish within its time limit."""
18
+
19
+ def __init__(self, message: str, *, seconds: float) -> None:
20
+ super().__init__(message)
21
+ self.seconds = seconds
22
+
23
+
24
+ class RateLimitExceeded(PythonWrapError):
25
+ """The call was rejected because its rate limit is used up."""
26
+
27
+ def __init__(self, message: str, *, retry_after: float) -> None:
28
+ super().__init__(message)
29
+ self.retry_after = retry_after
30
+
31
+
32
+ class CircuitOpenError(PythonWrapError):
33
+ """The circuit breaker is open, so the call was not attempted."""
34
+
35
+ def __init__(self, message: str, *, retry_after: float) -> None:
36
+ super().__init__(message)
37
+ self.retry_after = retry_after
38
+
39
+
40
+ class BulkheadFull(PythonWrapError):
41
+ """A bulkhead is at capacity: its wait queue is full, or waiting for a slot timed out."""
42
+
43
+ def __init__(self, message: str, *, name: str | None, max_concurrent: int) -> None:
44
+ super().__init__(message)
45
+ self.name = name
46
+ self.max_concurrent = max_concurrent
47
+
48
+
49
+ class ValidationError(PythonWrapError, ValueError):
50
+ """An argument failed a validator."""
51
+
52
+ def __init__(self, message: str, *, parameter: str, value: object) -> None:
53
+ super().__init__(message)
54
+ self.parameter = parameter
55
+ self.value = value
56
+
57
+
58
+ class ArgumentTypeError(PythonWrapError, TypeError):
59
+ """An argument does not match its expected type."""
60
+
61
+ def __init__(self, message: str, *, parameter: str, expected: object, value: object) -> None:
62
+ super().__init__(message)
63
+ self.parameter = parameter
64
+ self.expected = expected
65
+ self.value = value
66
+
67
+
68
+ class BatchError(PythonWrapError):
69
+ """A batch function broke its output contract for this caller's item.
70
+
71
+ Raised by ``batch_calls`` when the batch function returns the wrong number of
72
+ results, a mapping without this item's key, or something that is neither a sequence
73
+ nor a mapping.
74
+ """
75
+
76
+
77
+ __all__ = [
78
+ "ArgumentTypeError",
79
+ "BatchError",
80
+ "BulkheadFull",
81
+ "CircuitOpenError",
82
+ "PythonWrapError",
83
+ "RateLimitExceeded",
84
+ "TimeoutExpired",
85
+ "ValidationError",
86
+ ]
python_wrap/events.py ADDED
@@ -0,0 +1,161 @@
1
+ """Observe what python-wrap decorators do: retries, cache hits, circuit changes, ...
2
+
3
+ Decorators publish `Event` objects to a process-wide list of listeners. With no
4
+ listener registered, publishing costs a single list check. Use `subscribe` (or the
5
+ `listening` context manager) to feed Prometheus, OpenTelemetry, statsd or your logs.
6
+ `EVENT_NAMES` is the catalogue of every event and its fields.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Iterator, Mapping
12
+ from contextlib import contextmanager
13
+ from types import MappingProxyType
14
+
15
+ from python_wrap import _core
16
+ from python_wrap._core import Event
17
+
18
+ __all__ = ["EVENT_NAMES", "Event", "Listener", "filter_events", "listening", "subscribe"]
19
+
20
+ Listener = Callable[[Event], None]
21
+
22
+ #: Event name -> description of its ``fields`` (``Event.function`` is the decorated
23
+ #: function's qualified name unless noted). The docs page renders this mapping.
24
+ EVENT_NAMES: Mapping[str, str] = MappingProxyType(
25
+ {
26
+ "retry.attempt_failed": (
27
+ "An attempt failed and another will follow. Fields: attempt (int, 1-based), "
28
+ "exception (BaseException, or None for a value rejected by retry_if_result), "
29
+ "next_delay (float seconds before the next attempt)."
30
+ ),
31
+ "retry.succeeded": "The call finally succeeded. Fields: attempts (int, total made).",
32
+ "retry.budget_exhausted": (
33
+ "A shared RetryBudget refused another retry; the call stops (retry.gave_up "
34
+ "follows). Fields: attempts (int), exception (BaseException, or None when "
35
+ "the last attempt returned a value rejected by retry_if_result)."
36
+ ),
37
+ "retry.gave_up": (
38
+ "Attempts were exhausted. Fields: attempts (int), exception (BaseException, or "
39
+ "None when the last attempt returned a value rejected by retry_if_result)."
40
+ ),
41
+ "timeout.expired": "A call exceeded its time limit. Fields: seconds (float, the limit).",
42
+ "circuit.state_changed": (
43
+ "A circuit breaker moved between states. function is the breaker name. "
44
+ "Fields: old (str), new (str): 'closed', 'open' or 'half_open'."
45
+ ),
46
+ "circuit.rejected": (
47
+ "A call was refused because the circuit is open. Fields: retry_after (float seconds)."
48
+ ),
49
+ "rate_limit.rejected": (
50
+ "A call was refused by a rate limiter. Fields: retry_after (float seconds)."
51
+ ),
52
+ "rate_limit.waited": (
53
+ "A call was delayed by a rate limiter. Fields: seconds (float, time waited)."
54
+ ),
55
+ "throttle.skipped": "A call was skipped by throttle. No fields.",
56
+ "cache.hit": "A cached value was returned. No fields.",
57
+ "cache.miss": "No cached value; the function ran. No fields.",
58
+ "cache.evicted": "An entry left the cache. Fields: reason (str): 'lru' or 'ttl'.",
59
+ "cache.stale": (
60
+ "A stale value was served (stale-while-revalidate) and a background refresh "
61
+ "started. No fields."
62
+ ),
63
+ "cache.refresh_failed": (
64
+ "A background refresh failed; the stale value is kept. "
65
+ "Fields: exception (BaseException)."
66
+ ),
67
+ "bulkhead.rejected": (
68
+ "A call was refused because the bulkhead is full. Fields: active (int), waiting (int)."
69
+ ),
70
+ "periodic.run": (
71
+ "A background run of a periodic function finished. function is its name. "
72
+ "Fields: run (int, 1-based), seconds (float, how long it took)."
73
+ ),
74
+ "periodic.failed": (
75
+ "A background run of a periodic function raised. Fields: run (int), "
76
+ "exception (BaseException)."
77
+ ),
78
+ }
79
+ )
80
+
81
+
82
+ def subscribe(listener: Listener) -> Callable[[], None]:
83
+ """Register ``listener`` to receive every `Event`.
84
+
85
+ Listeners run synchronously in the thread that triggered the event, so keep them
86
+ fast. An exception raised by a listener is logged and swallowed; it never reaches the
87
+ decorated function. Subscribing the same callable twice delivers each event twice.
88
+
89
+ Args:
90
+ listener: Callable taking one `Event`.
91
+
92
+ Returns:
93
+ A function that removes this registration. Calling it again does nothing.
94
+
95
+ Example:
96
+ >>> seen = []
97
+ >>> unsubscribe = subscribe(seen.append)
98
+ >>> unsubscribe()
99
+ """
100
+ with _core.listeners_lock:
101
+ _core.listeners.append(listener)
102
+ removed = False
103
+
104
+ def unsubscribe() -> None:
105
+ nonlocal removed
106
+ with _core.listeners_lock:
107
+ if removed:
108
+ return
109
+ removed = True
110
+ # ``emit`` iterates over a tuple snapshot, so deleting in place is safe.
111
+ for i, item in enumerate(_core.listeners):
112
+ if item is listener:
113
+ del _core.listeners[i]
114
+ break
115
+
116
+ return unsubscribe
117
+
118
+
119
+ @contextmanager
120
+ def listening(listener: Listener) -> Iterator[Listener]:
121
+ """Subscribe ``listener`` for the duration of a ``with`` block.
122
+
123
+ Args:
124
+ listener: Callable taking one `Event`.
125
+
126
+ Yields:
127
+ The listener, so ``with listening(events.append) as handler`` works.
128
+
129
+ Example:
130
+ >>> events = []
131
+ >>> with listening(events.append):
132
+ ... pass
133
+ """
134
+ unsubscribe = subscribe(listener)
135
+ try:
136
+ yield listener
137
+ finally:
138
+ unsubscribe()
139
+
140
+
141
+ def filter_events(prefix: str | tuple[str, ...], listener: Listener) -> Listener:
142
+ """Wrap ``listener`` so it only receives events whose name starts with ``prefix``.
143
+
144
+ Args:
145
+ prefix: A name prefix such as ``"retry."``, or a tuple of prefixes.
146
+ listener: The listener to call for matching events.
147
+
148
+ Returns:
149
+ A new listener; pass it to `subscribe`.
150
+
151
+ Example:
152
+ >>> retries = []
153
+ >>> unsubscribe = subscribe(filter_events("retry.", retries.append))
154
+ >>> unsubscribe()
155
+ """
156
+
157
+ def filtered(event: Event) -> None:
158
+ if event.name.startswith(prefix):
159
+ listener(event)
160
+
161
+ return filtered