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.
- python_wrap/__init__.py +272 -0
- python_wrap/_core.py +212 -0
- python_wrap/aio.py +620 -0
- python_wrap/batching.py +556 -0
- python_wrap/bulkhead.py +431 -0
- python_wrap/caching.py +1613 -0
- python_wrap/circuit.py +553 -0
- python_wrap/concurrency.py +1886 -0
- python_wrap/config.py +217 -0
- python_wrap/deadline.py +168 -0
- python_wrap/decorators.py +7 -0
- python_wrap/devtools.py +385 -0
- python_wrap/errors.py +86 -0
- python_wrap/events.py +161 -0
- python_wrap/hedging.py +192 -0
- python_wrap/logs.py +767 -0
- python_wrap/metrics.py +242 -0
- python_wrap/periodic.py +491 -0
- python_wrap/policy.py +263 -0
- python_wrap/py.typed +0 -0
- python_wrap/retry.py +1324 -0
- python_wrap/state.py +500 -0
- python_wrap/testing.py +662 -0
- python_wrap/timing.py +535 -0
- python_wrap/validation.py +662 -0
- python_wrap-0.2.1.dist-info/METADATA +485 -0
- python_wrap-0.2.1.dist-info/RECORD +29 -0
- python_wrap-0.2.1.dist-info/WHEEL +4 -0
- python_wrap-0.2.1.dist-info/licenses/LICENSE +21 -0
python_wrap/__init__.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""python-wrap: everyday Python decorators that just work.
|
|
2
|
+
|
|
3
|
+
Every decorator works on plain functions, ``async def`` functions and methods, keeps
|
|
4
|
+
the wrapped signature for type checkers, and never configures logging for you.
|
|
5
|
+
|
|
6
|
+
Testing helpers live in ``python_wrap.testing`` and the event catalogue in
|
|
7
|
+
``python_wrap.events``; they are not re-exported here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from .aio import (
|
|
13
|
+
RaceFailed,
|
|
14
|
+
amap_iter,
|
|
15
|
+
gather_limit,
|
|
16
|
+
map_async,
|
|
17
|
+
race,
|
|
18
|
+
run_sync,
|
|
19
|
+
)
|
|
20
|
+
from .batching import (
|
|
21
|
+
AsyncBatched,
|
|
22
|
+
Batched,
|
|
23
|
+
batch_calls,
|
|
24
|
+
)
|
|
25
|
+
from .bulkhead import (
|
|
26
|
+
Bulkhead,
|
|
27
|
+
Limited,
|
|
28
|
+
limit_concurrency,
|
|
29
|
+
)
|
|
30
|
+
from .caching import (
|
|
31
|
+
CachedProperty,
|
|
32
|
+
CacheInfo,
|
|
33
|
+
CacheStore,
|
|
34
|
+
Memoized,
|
|
35
|
+
Once,
|
|
36
|
+
cache,
|
|
37
|
+
cached_property,
|
|
38
|
+
invalidate,
|
|
39
|
+
memoize,
|
|
40
|
+
once,
|
|
41
|
+
)
|
|
42
|
+
from .circuit import (
|
|
43
|
+
CircuitBreaker,
|
|
44
|
+
CircuitProtected,
|
|
45
|
+
CircuitState,
|
|
46
|
+
CircuitStats,
|
|
47
|
+
circuit_breaker,
|
|
48
|
+
)
|
|
49
|
+
from .concurrency import (
|
|
50
|
+
AsyncDebounced,
|
|
51
|
+
Debounced,
|
|
52
|
+
RateLimiter,
|
|
53
|
+
Timeout,
|
|
54
|
+
debounce,
|
|
55
|
+
limiter_of,
|
|
56
|
+
rate_limit,
|
|
57
|
+
run_in_thread,
|
|
58
|
+
singleflight,
|
|
59
|
+
throttle,
|
|
60
|
+
timeout,
|
|
61
|
+
)
|
|
62
|
+
from .config import (
|
|
63
|
+
configure,
|
|
64
|
+
configured,
|
|
65
|
+
get_config,
|
|
66
|
+
reset_config,
|
|
67
|
+
)
|
|
68
|
+
from .deadline import (
|
|
69
|
+
Deadline,
|
|
70
|
+
deadline,
|
|
71
|
+
)
|
|
72
|
+
from .devtools import (
|
|
73
|
+
compose,
|
|
74
|
+
deprecate,
|
|
75
|
+
mock_data,
|
|
76
|
+
no_debug,
|
|
77
|
+
wrap_methods,
|
|
78
|
+
)
|
|
79
|
+
from .errors import (
|
|
80
|
+
ArgumentTypeError,
|
|
81
|
+
BatchError,
|
|
82
|
+
BulkheadFull,
|
|
83
|
+
CircuitOpenError,
|
|
84
|
+
PythonWrapError,
|
|
85
|
+
RateLimitExceeded,
|
|
86
|
+
TimeoutExpired,
|
|
87
|
+
ValidationError,
|
|
88
|
+
)
|
|
89
|
+
from .events import (
|
|
90
|
+
listening,
|
|
91
|
+
subscribe,
|
|
92
|
+
)
|
|
93
|
+
from .hedging import (
|
|
94
|
+
hedge,
|
|
95
|
+
)
|
|
96
|
+
from .logs import (
|
|
97
|
+
DEFAULT_REDACT,
|
|
98
|
+
AuditRecord,
|
|
99
|
+
audit,
|
|
100
|
+
current_log_context,
|
|
101
|
+
log_args,
|
|
102
|
+
log_context,
|
|
103
|
+
log_return,
|
|
104
|
+
trace,
|
|
105
|
+
)
|
|
106
|
+
from .metrics import (
|
|
107
|
+
CallStats,
|
|
108
|
+
Counted,
|
|
109
|
+
count_calls,
|
|
110
|
+
)
|
|
111
|
+
from .periodic import (
|
|
112
|
+
AsyncPeriodic,
|
|
113
|
+
Periodic,
|
|
114
|
+
periodic,
|
|
115
|
+
)
|
|
116
|
+
from .policy import (
|
|
117
|
+
Policy,
|
|
118
|
+
policy,
|
|
119
|
+
)
|
|
120
|
+
from .retry import (
|
|
121
|
+
Attempt,
|
|
122
|
+
RetryBudget,
|
|
123
|
+
RetryBudgetStats,
|
|
124
|
+
Retrying,
|
|
125
|
+
RetryState,
|
|
126
|
+
RetryStats,
|
|
127
|
+
fallback,
|
|
128
|
+
retry,
|
|
129
|
+
retry_after_header,
|
|
130
|
+
retry_on_exception,
|
|
131
|
+
retry_stats,
|
|
132
|
+
retrying,
|
|
133
|
+
suppress,
|
|
134
|
+
)
|
|
135
|
+
from .state import (
|
|
136
|
+
INJECTED,
|
|
137
|
+
Transaction,
|
|
138
|
+
current_transaction,
|
|
139
|
+
require_transaction,
|
|
140
|
+
revert_on_failure,
|
|
141
|
+
transactional,
|
|
142
|
+
)
|
|
143
|
+
from .timing import (
|
|
144
|
+
BenchmarkResult,
|
|
145
|
+
MemoryRecord,
|
|
146
|
+
TimingRecord,
|
|
147
|
+
benchmark,
|
|
148
|
+
benchmark_async,
|
|
149
|
+
measure_memory,
|
|
150
|
+
profile,
|
|
151
|
+
timing,
|
|
152
|
+
)
|
|
153
|
+
from .validation import (
|
|
154
|
+
check_type,
|
|
155
|
+
enforce_types,
|
|
156
|
+
validate_args,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
__version__ = "0.2.1"
|
|
160
|
+
|
|
161
|
+
# A library must never configure logging; this only silences "no handler" warnings.
|
|
162
|
+
logging.getLogger("python_wrap").addHandler(logging.NullHandler())
|
|
163
|
+
|
|
164
|
+
__all__ = [
|
|
165
|
+
"DEFAULT_REDACT",
|
|
166
|
+
"INJECTED",
|
|
167
|
+
"ArgumentTypeError",
|
|
168
|
+
"AsyncBatched",
|
|
169
|
+
"AsyncDebounced",
|
|
170
|
+
"AsyncPeriodic",
|
|
171
|
+
"Attempt",
|
|
172
|
+
"AuditRecord",
|
|
173
|
+
"BatchError",
|
|
174
|
+
"Batched",
|
|
175
|
+
"BenchmarkResult",
|
|
176
|
+
"Bulkhead",
|
|
177
|
+
"BulkheadFull",
|
|
178
|
+
"CacheInfo",
|
|
179
|
+
"CacheStore",
|
|
180
|
+
"CachedProperty",
|
|
181
|
+
"CallStats",
|
|
182
|
+
"CircuitBreaker",
|
|
183
|
+
"CircuitOpenError",
|
|
184
|
+
"CircuitProtected",
|
|
185
|
+
"CircuitState",
|
|
186
|
+
"CircuitStats",
|
|
187
|
+
"Counted",
|
|
188
|
+
"Deadline",
|
|
189
|
+
"Debounced",
|
|
190
|
+
"Limited",
|
|
191
|
+
"Memoized",
|
|
192
|
+
"MemoryRecord",
|
|
193
|
+
"Once",
|
|
194
|
+
"Periodic",
|
|
195
|
+
"Policy",
|
|
196
|
+
"PythonWrapError",
|
|
197
|
+
"RaceFailed",
|
|
198
|
+
"RateLimitExceeded",
|
|
199
|
+
"RateLimiter",
|
|
200
|
+
"RetryBudget",
|
|
201
|
+
"RetryBudgetStats",
|
|
202
|
+
"RetryState",
|
|
203
|
+
"RetryStats",
|
|
204
|
+
"Retrying",
|
|
205
|
+
"Timeout",
|
|
206
|
+
"TimeoutExpired",
|
|
207
|
+
"TimingRecord",
|
|
208
|
+
"Transaction",
|
|
209
|
+
"ValidationError",
|
|
210
|
+
"__version__",
|
|
211
|
+
"amap_iter",
|
|
212
|
+
"audit",
|
|
213
|
+
"batch_calls",
|
|
214
|
+
"benchmark",
|
|
215
|
+
"benchmark_async",
|
|
216
|
+
"cache",
|
|
217
|
+
"cached_property",
|
|
218
|
+
"check_type",
|
|
219
|
+
"circuit_breaker",
|
|
220
|
+
"compose",
|
|
221
|
+
"configure",
|
|
222
|
+
"configured",
|
|
223
|
+
"count_calls",
|
|
224
|
+
"current_log_context",
|
|
225
|
+
"current_transaction",
|
|
226
|
+
"deadline",
|
|
227
|
+
"debounce",
|
|
228
|
+
"deprecate",
|
|
229
|
+
"enforce_types",
|
|
230
|
+
"fallback",
|
|
231
|
+
"gather_limit",
|
|
232
|
+
"get_config",
|
|
233
|
+
"hedge",
|
|
234
|
+
"invalidate",
|
|
235
|
+
"limit_concurrency",
|
|
236
|
+
"limiter_of",
|
|
237
|
+
"listening",
|
|
238
|
+
"log_args",
|
|
239
|
+
"log_context",
|
|
240
|
+
"log_return",
|
|
241
|
+
"map_async",
|
|
242
|
+
"measure_memory",
|
|
243
|
+
"memoize",
|
|
244
|
+
"mock_data",
|
|
245
|
+
"no_debug",
|
|
246
|
+
"once",
|
|
247
|
+
"periodic",
|
|
248
|
+
"policy",
|
|
249
|
+
"profile",
|
|
250
|
+
"race",
|
|
251
|
+
"rate_limit",
|
|
252
|
+
"require_transaction",
|
|
253
|
+
"reset_config",
|
|
254
|
+
"retry",
|
|
255
|
+
"retry_after_header",
|
|
256
|
+
"retry_on_exception",
|
|
257
|
+
"retry_stats",
|
|
258
|
+
"retrying",
|
|
259
|
+
"revert_on_failure",
|
|
260
|
+
"run_in_thread",
|
|
261
|
+
"run_sync",
|
|
262
|
+
"singleflight",
|
|
263
|
+
"subscribe",
|
|
264
|
+
"suppress",
|
|
265
|
+
"throttle",
|
|
266
|
+
"timeout",
|
|
267
|
+
"timing",
|
|
268
|
+
"trace",
|
|
269
|
+
"transactional",
|
|
270
|
+
"validate_args",
|
|
271
|
+
"wrap_methods",
|
|
272
|
+
]
|
python_wrap/_core.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Shared plumbing for every decorator in the package.
|
|
2
|
+
|
|
3
|
+
Rules every module follows:
|
|
4
|
+
|
|
5
|
+
* Never configure logging. Log through `get_logger`; the package logger carries a
|
|
6
|
+
``NullHandler`` so nothing is printed unless the application configures logging.
|
|
7
|
+
* Support ``async def`` functions: branch on `is_async` and return an ``async def``
|
|
8
|
+
wrapper, so ``inspect.iscoroutinefunction(wrapped)`` stays true.
|
|
9
|
+
* Read time through ``_core.monotonic()`` / ``_core.perf_counter()`` and wait through
|
|
10
|
+
``_core.sleep()`` / ``_core.async_sleep()`` — always as module attributes, never
|
|
11
|
+
imported by name — so tests can patch one place to get a fake clock.
|
|
12
|
+
* ``_core.sleep`` / ``_core.async_sleep`` are only for deliberate backoff waits (retry
|
|
13
|
+
delays, a blocked rate limiter). A limit on how long to wait — a timeout, a deadline
|
|
14
|
+
for a slot, a hedge delay, a batching or debounce window — runs on real time
|
|
15
|
+
(``asyncio.timeout`` / ``asyncio.sleep`` timers, ``threading.Timer``, ``wait(timeout=)``
|
|
16
|
+
and ``time.monotonic()``), so the test overrides and the fake clock never shorten
|
|
17
|
+
it, and sync and async code behave the same.
|
|
18
|
+
* Honour the process-wide test overrides (see below).
|
|
19
|
+
* Decorated functions pickle by reference (``module.qualname``), so they can be sent to
|
|
20
|
+
process pools. A decorator that returns an object instead of a function gives it a
|
|
21
|
+
``__reduce__`` returning its ``__qualname__``. Objects holding shared runtime state
|
|
22
|
+
(locks, counters, timers) refuse to pickle through `refuse_pickle`: that state is
|
|
23
|
+
per process.
|
|
24
|
+
|
|
25
|
+
Test overrides contract
|
|
26
|
+
-----------------------
|
|
27
|
+
``_core.overrides`` is a single process-wide `Overrides` instance, mutated in
|
|
28
|
+
place (never replaced), so it may be read as ``_core.overrides.<field>`` at any time.
|
|
29
|
+
Reads are plain attribute reads (thread-safe under the GIL); changing it is a test-time
|
|
30
|
+
operation done through `set_overrides` (which holds ``overrides_lock``) or the
|
|
31
|
+
public helpers in `python_wrap.testing`. Modules must read the field **at call
|
|
32
|
+
time**, not at decoration time, so a test can flip it after import:
|
|
33
|
+
|
|
34
|
+
* ``no_sleep``: `sleep` / `async_sleep` return immediately (the async one
|
|
35
|
+
still yields to the event loop once). Handled here; other modules need do nothing as
|
|
36
|
+
long as they wait only through ``_core``. A fake clock installed by ``fake_clock()``
|
|
37
|
+
replaces the sleeps and so still advances time. A blocking rate limiter that would
|
|
38
|
+
have to wait lets the call through at once (still counted) instead of spinning.
|
|
39
|
+
* ``max_attempts``: when not ``None``, ``retry`` uses
|
|
40
|
+
``min(configured_max_attempts, _core.overrides.max_attempts)`` attempts.
|
|
41
|
+
* ``disable_rate_limits``: when true, rate limiters (``rate_limit``, ``RateLimiter``)
|
|
42
|
+
and ``throttle`` let every call through immediately. Concurrency limits
|
|
43
|
+
(``Bulkhead``, ``limit_concurrency``) are not affected.
|
|
44
|
+
* ``disable_caching``: when true, ``memoize`` / ``cache`` / ``once``-style caches
|
|
45
|
+
neither read nor write their stores and always call the wrapped function.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import asyncio
|
|
51
|
+
import functools
|
|
52
|
+
import inspect
|
|
53
|
+
import logging
|
|
54
|
+
import threading
|
|
55
|
+
import time
|
|
56
|
+
from collections.abc import Callable
|
|
57
|
+
from dataclasses import dataclass, fields
|
|
58
|
+
from typing import Any, NoReturn, ParamSpec, TypeVar
|
|
59
|
+
|
|
60
|
+
P = ParamSpec("P")
|
|
61
|
+
R = TypeVar("R")
|
|
62
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
63
|
+
|
|
64
|
+
LOGGER_NAME = "python_wrap"
|
|
65
|
+
|
|
66
|
+
LoggerLike = logging.Logger | str | None
|
|
67
|
+
|
|
68
|
+
# Time sources, patched by tests. Call them as ``_core.monotonic()``.
|
|
69
|
+
monotonic: Callable[[], float] = time.monotonic
|
|
70
|
+
perf_counter: Callable[[], float] = time.perf_counter
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Overrides:
|
|
75
|
+
"""Process-wide switches for tests. See the module docstring for the contract."""
|
|
76
|
+
|
|
77
|
+
no_sleep: bool = False
|
|
78
|
+
max_attempts: int | None = None
|
|
79
|
+
disable_rate_limits: bool = False
|
|
80
|
+
disable_caching: bool = False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
overrides = Overrides()
|
|
84
|
+
overrides_lock = threading.RLock()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def set_overrides(**values: Any) -> dict[str, Any]:
|
|
88
|
+
"""Set override fields atomically and return their previous values.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
TypeError: If a name is not a field of `Overrides`.
|
|
92
|
+
ValueError: If ``max_attempts`` is not ``None`` or a positive integer.
|
|
93
|
+
"""
|
|
94
|
+
names = {f.name for f in fields(Overrides)}
|
|
95
|
+
unknown = set(values) - names
|
|
96
|
+
if unknown:
|
|
97
|
+
raise TypeError(f"unknown override(s): {', '.join(sorted(unknown))}")
|
|
98
|
+
attempts = values.get("max_attempts")
|
|
99
|
+
if attempts is not None and (not isinstance(attempts, int) or attempts < 1):
|
|
100
|
+
raise ValueError("max_attempts override must be None or an integer >= 1")
|
|
101
|
+
with overrides_lock:
|
|
102
|
+
previous = {name: getattr(overrides, name) for name in values}
|
|
103
|
+
for name, value in values.items():
|
|
104
|
+
setattr(overrides, name, value)
|
|
105
|
+
return previous
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def sleep(seconds: float) -> None:
|
|
109
|
+
"""Blocking sleep; a no-op while ``overrides.no_sleep`` is set."""
|
|
110
|
+
if overrides.no_sleep:
|
|
111
|
+
return
|
|
112
|
+
time.sleep(seconds)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def async_sleep(seconds: float) -> None:
|
|
116
|
+
"""Indirection over `asyncio.sleep` so tests can patch it.
|
|
117
|
+
|
|
118
|
+
While ``overrides.no_sleep`` is set it only yields to the event loop once.
|
|
119
|
+
"""
|
|
120
|
+
await asyncio.sleep(0 if overrides.no_sleep else seconds)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_default_logger: LoggerLike = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_logger(logger: LoggerLike = None) -> logging.Logger:
|
|
127
|
+
"""Resolve a ``logger=`` argument: a Logger, a logger name, or the package logger."""
|
|
128
|
+
if logger is None:
|
|
129
|
+
logger = _default_logger # set by python_wrap.config.configure(logger=...)
|
|
130
|
+
if isinstance(logger, logging.Logger):
|
|
131
|
+
return logger
|
|
132
|
+
return logging.getLogger(logger or LOGGER_NAME)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_async(func: Callable[..., Any]) -> bool:
|
|
136
|
+
"""True if calling ``func`` returns a coroutine.
|
|
137
|
+
|
|
138
|
+
Covers ``async def`` functions and methods, ``functools.partial`` objects around
|
|
139
|
+
them, and callable instances whose class defines ``async def __call__``. A class is
|
|
140
|
+
never async: calling it constructs an instance, even if the class has an async
|
|
141
|
+
``__call__``.
|
|
142
|
+
"""
|
|
143
|
+
while isinstance(func, functools.partial):
|
|
144
|
+
func = func.func
|
|
145
|
+
if inspect.iscoroutinefunction(func):
|
|
146
|
+
return True
|
|
147
|
+
if inspect.isclass(func) or inspect.isfunction(func) or inspect.ismethod(func):
|
|
148
|
+
return False
|
|
149
|
+
# Look the method up on the type, as the call itself does.
|
|
150
|
+
call = inspect.getattr_static(type(func), "__call__", None)
|
|
151
|
+
return call is not None and inspect.iscoroutinefunction(call)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def qualname(func: Callable[..., Any]) -> str:
|
|
155
|
+
"""A readable name for log messages."""
|
|
156
|
+
return getattr(func, "__qualname__", None) or getattr(func, "__name__", None) or repr(func)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def refuse_pickle(obj: object, advice: str | None = None) -> NoReturn:
|
|
160
|
+
"""Raise a clear ``TypeError`` from ``__reduce__`` of an object with per-process state.
|
|
161
|
+
|
|
162
|
+
Locks, counters and timers cannot be shared with another process, so pickling (and
|
|
163
|
+
copying) such an object fails here instead of deep inside ``pickle``. ``advice``
|
|
164
|
+
replaces the default hint for objects that are not used as decorators.
|
|
165
|
+
"""
|
|
166
|
+
name = type(obj).__name__
|
|
167
|
+
if advice is None:
|
|
168
|
+
advice = (
|
|
169
|
+
f"Create one {name} per process instead (e.g. at module level, so each worker "
|
|
170
|
+
"builds its own); functions decorated with it pickle by reference."
|
|
171
|
+
)
|
|
172
|
+
raise TypeError(
|
|
173
|
+
f"cannot pickle {name!r} object: its state (locks, counters, timers) belongs to "
|
|
174
|
+
f"one process. {advice}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------- events
|
|
179
|
+
# One process-wide hook for metrics. Decorators call ``_core.emit(...)`` at interesting
|
|
180
|
+
# moments (a retry, a cache hit, a circuit opening); ``python_wrap.events`` is the public
|
|
181
|
+
# face. With no listener registered, ``emit`` is a single list check.
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class Event:
|
|
186
|
+
"""Something a decorator did, for metrics and tracing.
|
|
187
|
+
|
|
188
|
+
Attributes:
|
|
189
|
+
name: Dotted event name, e.g. ``"retry.attempt_failed"``.
|
|
190
|
+
function: Qualified name of the decorated function (or the breaker/limiter name).
|
|
191
|
+
fields: Event-specific data, e.g. ``{"attempt": 2, "next_delay": 0.5}``.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
name: str
|
|
195
|
+
function: str
|
|
196
|
+
fields: dict[str, Any]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
listeners: list[Callable[[Event], None]] = []
|
|
200
|
+
listeners_lock = threading.Lock()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def emit(name: str, function: str, **fields: Any) -> None:
|
|
204
|
+
"""Deliver an event to every listener. A failing listener is logged, never raised."""
|
|
205
|
+
if not listeners:
|
|
206
|
+
return
|
|
207
|
+
event = Event(name, function, fields)
|
|
208
|
+
for listener in tuple(listeners):
|
|
209
|
+
try:
|
|
210
|
+
listener(event)
|
|
211
|
+
except Exception:
|
|
212
|
+
get_logger().exception("python_wrap event listener %r failed", listener)
|