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,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)