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/devtools.py
ADDED
|
@@ -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
|