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/config.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Project-wide defaults for python-wrap decorators.
|
|
2
|
+
|
|
3
|
+
Call `configure` once at application start-up, **before** the decorators you want to
|
|
4
|
+
affect are applied. Values are read when a decorator is *applied* (at import time for
|
|
5
|
+
module-level functions), so a decorator applied earlier keeps the settings it saw.
|
|
6
|
+
Arguments passed explicitly to a decorator always win over configured defaults.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import difflib
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from python_wrap import _core
|
|
19
|
+
|
|
20
|
+
__all__ = ["configure", "configured", "get_config", "reset_config"]
|
|
21
|
+
|
|
22
|
+
UNSET: Any = object()
|
|
23
|
+
"""Sentinel meaning "argument not passed"; resolved against the configured defaults."""
|
|
24
|
+
|
|
25
|
+
_KEYS = ("logger", "retry", "redact", "redact_extra")
|
|
26
|
+
_RETRY_KEYS = (
|
|
27
|
+
"max_attempts",
|
|
28
|
+
"delay",
|
|
29
|
+
"backoff",
|
|
30
|
+
"max_delay",
|
|
31
|
+
"jitter",
|
|
32
|
+
"max_time",
|
|
33
|
+
"log_level",
|
|
34
|
+
"budget",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_lock = threading.RLock()
|
|
38
|
+
_state: dict[str, Any] = {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _hint(name: str, valid: tuple[str, ...], what: str) -> TypeError:
|
|
42
|
+
close = difflib.get_close_matches(name, valid, n=1)
|
|
43
|
+
tip = f" Did you mean {close[0]!r}?" if close else ""
|
|
44
|
+
return TypeError(f"Unknown {what} {name!r}.{tip} Valid: {', '.join(valid)}.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _names(value: Any, key: str) -> tuple[str, ...]:
|
|
48
|
+
if isinstance(value, str) or not isinstance(value, Iterable):
|
|
49
|
+
raise TypeError(f"{key} must be an iterable of strings, got {value!r}")
|
|
50
|
+
names = tuple(value)
|
|
51
|
+
if not all(isinstance(n, str) for n in names):
|
|
52
|
+
raise TypeError(f"{key} must contain only strings, got {value!r}")
|
|
53
|
+
return names
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate(key: str, value: Any) -> Any:
|
|
57
|
+
if key == "logger":
|
|
58
|
+
if value is not None and not isinstance(value, (str, logging.Logger)):
|
|
59
|
+
raise TypeError(f"logger must be a logging.Logger, a name or None, got {value!r}")
|
|
60
|
+
return value
|
|
61
|
+
if key == "retry":
|
|
62
|
+
if not isinstance(value, Mapping):
|
|
63
|
+
raise TypeError(f"retry must be a mapping of retry defaults, got {value!r}")
|
|
64
|
+
for name in value:
|
|
65
|
+
if name not in _RETRY_KEYS:
|
|
66
|
+
raise _hint(str(name), _RETRY_KEYS, "retry default")
|
|
67
|
+
# Imported here to avoid an import cycle.
|
|
68
|
+
from python_wrap.retry import _check_budget
|
|
69
|
+
from python_wrap.retry import _validate as validate_retry
|
|
70
|
+
|
|
71
|
+
_check_budget(value.get("budget"))
|
|
72
|
+
validate_retry(
|
|
73
|
+
value.get("max_attempts", 3),
|
|
74
|
+
value.get("delay", 1.0),
|
|
75
|
+
value.get("backoff", 1.0),
|
|
76
|
+
value.get("max_delay"),
|
|
77
|
+
value.get("jitter", 0.0),
|
|
78
|
+
value.get("max_time"),
|
|
79
|
+
)
|
|
80
|
+
return dict(value)
|
|
81
|
+
if key == "redact":
|
|
82
|
+
return None if value is None else _names(value, "redact")
|
|
83
|
+
return _names(value, "redact_extra")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def configure(**defaults: Any) -> None:
|
|
87
|
+
"""Set project-wide defaults for python-wrap decorators.
|
|
88
|
+
|
|
89
|
+
Call it once at start-up, before decorating: values are read when a decorator is
|
|
90
|
+
applied, so decorators applied earlier keep their settings. Keys not mentioned keep
|
|
91
|
+
their current value; each key you pass replaces that key entirely (`retry={...}`
|
|
92
|
+
replaces the previous retry defaults). Explicit decorator arguments always win.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
**defaults: Any of:
|
|
96
|
+
|
|
97
|
+
- `logger`: logger (or name) used by every decorator that logs when it
|
|
98
|
+
is not given `logger=`. `None` restores the package logger.
|
|
99
|
+
- `retry`: dict of defaults for `retry`/`retrying`; keys `max_attempts`,
|
|
100
|
+
`delay`, `backoff`, `max_delay`, `jitter`, `max_time`, `log_level`,
|
|
101
|
+
`budget` (a `RetryBudget` shared by every retry applied afterwards).
|
|
102
|
+
- `redact`: default redaction fragments for `log_args` and `audit`
|
|
103
|
+
(replaces `DEFAULT_REDACT`); `None` restores it.
|
|
104
|
+
- `redact_extra`: fragments added to the redaction list (the built-ins
|
|
105
|
+
stay), for `log_args` and `audit`.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
TypeError: For an unknown key (with a "did you mean" hint) or a wrongly typed value
|
|
109
|
+
(including a `budget` that is not a `RetryBudget`).
|
|
110
|
+
ValueError: If a retry default is out of range.
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
```python
|
|
114
|
+
import logging
|
|
115
|
+
from python_wrap.config import configure, reset_config
|
|
116
|
+
|
|
117
|
+
configure(logger=logging.getLogger("app"), retry={"max_attempts": 5, "delay": 0.5})
|
|
118
|
+
reset_config()
|
|
119
|
+
```
|
|
120
|
+
"""
|
|
121
|
+
for key in defaults:
|
|
122
|
+
if key not in _KEYS:
|
|
123
|
+
raise _hint(key, _KEYS, "configuration key")
|
|
124
|
+
clean = {k: _validate(k, v) for k, v in defaults.items()}
|
|
125
|
+
with _lock:
|
|
126
|
+
_state.update(clean)
|
|
127
|
+
if "logger" in clean:
|
|
128
|
+
_core._default_logger = clean["logger"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_config() -> dict[str, Any]:
|
|
132
|
+
"""Return a copy of the current configuration.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A dict with keys `logger`, `retry`, `redact` and `redact_extra`, holding the
|
|
136
|
+
defaults in force (`None`, `{}`, `None` and `()` when nothing is configured).
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
```python
|
|
140
|
+
from python_wrap.config import get_config
|
|
141
|
+
|
|
142
|
+
assert get_config()["retry"] == {}
|
|
143
|
+
```
|
|
144
|
+
"""
|
|
145
|
+
with _lock:
|
|
146
|
+
return {
|
|
147
|
+
"logger": _state.get("logger"),
|
|
148
|
+
"retry": dict(_state.get("retry", {})),
|
|
149
|
+
"redact": _state.get("redact"),
|
|
150
|
+
"redact_extra": _state.get("redact_extra", ()),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def reset_config() -> None:
|
|
155
|
+
"""Forget every configured default, restoring the built-in behaviour.
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
```python
|
|
159
|
+
from python_wrap.config import configure, get_config, reset_config
|
|
160
|
+
|
|
161
|
+
configure(retry={"max_attempts": 9})
|
|
162
|
+
reset_config()
|
|
163
|
+
assert get_config()["retry"] == {}
|
|
164
|
+
```
|
|
165
|
+
"""
|
|
166
|
+
with _lock:
|
|
167
|
+
_state.clear()
|
|
168
|
+
_core._default_logger = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@contextlib.contextmanager
|
|
172
|
+
def configured(**defaults: Any) -> Iterator[None]:
|
|
173
|
+
"""Apply `configure(**defaults)` for a `with` block, then restore the previous state.
|
|
174
|
+
|
|
175
|
+
Meant for tests. Remember that decorators read the defaults when applied, so apply
|
|
176
|
+
them inside the block.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
**defaults: Same keys as `configure`.
|
|
180
|
+
|
|
181
|
+
Example:
|
|
182
|
+
```python
|
|
183
|
+
from python_wrap.config import configured, get_config
|
|
184
|
+
|
|
185
|
+
with configured(retry={"max_attempts": 2}):
|
|
186
|
+
assert get_config()["retry"] == {"max_attempts": 2}
|
|
187
|
+
assert get_config()["retry"] == {}
|
|
188
|
+
```
|
|
189
|
+
"""
|
|
190
|
+
with _lock:
|
|
191
|
+
saved = dict(_state)
|
|
192
|
+
configure(**defaults)
|
|
193
|
+
try:
|
|
194
|
+
yield
|
|
195
|
+
finally:
|
|
196
|
+
with _lock:
|
|
197
|
+
_state.clear()
|
|
198
|
+
_state.update(saved)
|
|
199
|
+
_core._default_logger = saved.get("logger")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def retry_default(name: str, value: Any, fallback: Any) -> Any:
|
|
203
|
+
"""Resolve a retry argument: explicit value, else configured default, else `fallback`."""
|
|
204
|
+
if value is not UNSET:
|
|
205
|
+
return value
|
|
206
|
+
with _lock:
|
|
207
|
+
retry_defaults: dict[str, Any] = _state.get("retry", {})
|
|
208
|
+
return retry_defaults.get(name, fallback)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def redaction(explicit: Iterable[str] | None, builtin: tuple[str, ...]) -> tuple[str, ...]:
|
|
212
|
+
"""Final redaction fragments: explicit list or configured/built-in, plus `redact_extra`."""
|
|
213
|
+
with _lock:
|
|
214
|
+
base: tuple[str, ...] | None = _state.get("redact")
|
|
215
|
+
extra: tuple[str, ...] = _state.get("redact_extra", ())
|
|
216
|
+
chosen = tuple(explicit) if explicit is not None else (builtin if base is None else base)
|
|
217
|
+
return chosen + extra
|
python_wrap/deadline.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Deadlines: one time budget shared by every call inside a block.
|
|
2
|
+
|
|
3
|
+
`deadline(seconds)` records an absolute point in time in a `contextvars.ContextVar`,
|
|
4
|
+
so it follows the code it wraps across function calls, `await`s and tasks created
|
|
5
|
+
inside the block (each task copies the context it was created in). Inner code asks
|
|
6
|
+
`remaining()` how much time is left, or calls `check()` to fail fast once it is gone.
|
|
7
|
+
|
|
8
|
+
A deadline is cooperative: it never interrupts anything by itself. The toolkit's
|
|
9
|
+
`timeout` (decorator or context manager) clamps its own limit to the remaining deadline,
|
|
10
|
+
so a `timeout(30)` inside `deadline(5)` gives up after at most 5 seconds. Your own code
|
|
11
|
+
can do the same, e.g. pass `remaining()` as an HTTP client timeout.
|
|
12
|
+
|
|
13
|
+
Nested deadlines never extend an outer one: the earliest wins.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextvars
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
|
|
21
|
+
from python_wrap import _core
|
|
22
|
+
from python_wrap.errors import TimeoutExpired
|
|
23
|
+
|
|
24
|
+
__all__ = ["Deadline", "check", "deadline", "remaining"]
|
|
25
|
+
|
|
26
|
+
# (absolute expiry on the ``_core.monotonic()`` clock, seconds the setting block asked for)
|
|
27
|
+
_current: contextvars.ContextVar[tuple[float, float] | None] = contextvars.ContextVar(
|
|
28
|
+
"python_wrap_deadline", default=None
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Deadline:
|
|
33
|
+
"""A time budget for a block of code; create it with `deadline`.
|
|
34
|
+
|
|
35
|
+
Use as `with deadline(5):` or `async with deadline(5):`. Entering computes the
|
|
36
|
+
absolute expiry; if an enclosing deadline expires sooner, that one stays in force.
|
|
37
|
+
One instance may be entered several times, but each `with` must exit in the reverse
|
|
38
|
+
order of entering (as `with` blocks naturally do in one thread or task).
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
seconds: The budget this deadline was created with.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, seconds: float) -> None:
|
|
45
|
+
if seconds < 0:
|
|
46
|
+
raise ValueError(f"deadline seconds must be >= 0, got {seconds!r}")
|
|
47
|
+
self.seconds = seconds
|
|
48
|
+
self._tokens: list[contextvars.Token[tuple[float, float] | None]] = []
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def expires_at(self) -> float | None:
|
|
52
|
+
"""The effective expiry (on the `_core.monotonic()` clock) in the current context.
|
|
53
|
+
|
|
54
|
+
`None` when no deadline is active here.
|
|
55
|
+
"""
|
|
56
|
+
current = _current.get()
|
|
57
|
+
return None if current is None else current[0]
|
|
58
|
+
|
|
59
|
+
def __enter__(self) -> Deadline:
|
|
60
|
+
expiry = _core.monotonic() + self.seconds
|
|
61
|
+
outer = _current.get()
|
|
62
|
+
# An enclosing deadline that comes first stays in force.
|
|
63
|
+
keep_outer = outer is not None and outer[0] <= expiry
|
|
64
|
+
value = outer if keep_outer and outer is not None else (expiry, self.seconds)
|
|
65
|
+
self._tokens.append(_current.set(value))
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def __exit__(
|
|
69
|
+
self,
|
|
70
|
+
exc_type: type[BaseException] | None,
|
|
71
|
+
exc: BaseException | None,
|
|
72
|
+
tb: TracebackType | None,
|
|
73
|
+
) -> None:
|
|
74
|
+
_current.reset(self._tokens.pop())
|
|
75
|
+
|
|
76
|
+
async def __aenter__(self) -> Deadline:
|
|
77
|
+
return self.__enter__()
|
|
78
|
+
|
|
79
|
+
async def __aexit__(
|
|
80
|
+
self,
|
|
81
|
+
exc_type: type[BaseException] | None,
|
|
82
|
+
exc: BaseException | None,
|
|
83
|
+
tb: TracebackType | None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.__exit__(exc_type, exc, tb)
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return f"Deadline(seconds={self.seconds!r}, remaining={remaining()!r})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def deadline(seconds: float) -> Deadline:
|
|
92
|
+
"""Give the enclosed block (and everything it calls) at most ``seconds`` seconds.
|
|
93
|
+
|
|
94
|
+
The deadline is stored in a context variable, so it is visible to every function
|
|
95
|
+
called inside the block, sync or async, and to tasks started from it. Nesting never
|
|
96
|
+
extends time: an inner `deadline(10)` inside `deadline(2)` still ends after 2 seconds.
|
|
97
|
+
|
|
98
|
+
Nothing is interrupted automatically. `timeout` clamps to the remaining time, and
|
|
99
|
+
your code can call `remaining()` or `check()`.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
seconds: The budget in seconds (>= 0).
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
A `Deadline` to use with `with` or `async with`.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
ValueError: If ``seconds`` is negative.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
```python
|
|
112
|
+
from python_wrap.deadline import deadline, remaining
|
|
113
|
+
|
|
114
|
+
assert remaining() is None
|
|
115
|
+
with deadline(5):
|
|
116
|
+
with deadline(60): # cannot extend the outer budget
|
|
117
|
+
left = remaining()
|
|
118
|
+
assert left is not None and left <= 5
|
|
119
|
+
assert remaining() is None
|
|
120
|
+
```
|
|
121
|
+
"""
|
|
122
|
+
return Deadline(seconds)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def remaining() -> float | None:
|
|
126
|
+
"""Seconds left before the current deadline, or `None` if there is none.
|
|
127
|
+
|
|
128
|
+
Never negative: an expired deadline reports `0.0`.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The remaining time in seconds, or `None` outside any `deadline` block.
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
```python
|
|
135
|
+
from python_wrap.deadline import deadline, remaining
|
|
136
|
+
|
|
137
|
+
with deadline(2):
|
|
138
|
+
budget = remaining() # e.g. pass as an HTTP client timeout
|
|
139
|
+
assert budget is not None and 0 < budget <= 2
|
|
140
|
+
```
|
|
141
|
+
"""
|
|
142
|
+
current = _current.get()
|
|
143
|
+
if current is None:
|
|
144
|
+
return None
|
|
145
|
+
return max(current[0] - _core.monotonic(), 0.0)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def check() -> None:
|
|
149
|
+
"""Raise `TimeoutExpired` if the current deadline has passed; otherwise do nothing.
|
|
150
|
+
|
|
151
|
+
Call it between steps of a long job to stop as soon as the budget is gone.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
TimeoutExpired: The deadline has passed. Its ``seconds`` is the budget of the
|
|
155
|
+
`deadline` block that set it.
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
```python
|
|
159
|
+
from python_wrap.deadline import check, deadline
|
|
160
|
+
|
|
161
|
+
with deadline(5):
|
|
162
|
+
for step in range(3):
|
|
163
|
+
check() # raises TimeoutExpired once the 5 seconds are used up
|
|
164
|
+
```
|
|
165
|
+
"""
|
|
166
|
+
current = _current.get()
|
|
167
|
+
if current is not None and _core.monotonic() >= current[0]:
|
|
168
|
+
raise TimeoutExpired(f"deadline of {current[1]:g}s has passed", seconds=current[1])
|