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/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
@@ -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])
@@ -0,0 +1,7 @@
1
+ """Compatibility module: v0.1 imported everything from ``python_wrap.decorators``.
2
+
3
+ New code should import from ``python_wrap`` directly.
4
+ """
5
+
6
+ from . import * # noqa: F403
7
+ from . import __all__ as __all__