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
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: python-wrap
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Everyday Python decorators that just work: retry, memoize, timeout, rate limiting, circuit breakers and more. Typed, async-aware, zero dependencies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/LitePy/PythonWrap
|
|
6
|
+
Project-URL: Documentation, https://pythonwrap.readthedocs.io
|
|
7
|
+
Project-URL: Source, https://github.com/LitePy/PythonWrap
|
|
8
|
+
Project-URL: Issues, https://github.com/LitePy/PythonWrap/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/LitePy/PythonWrap/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Mahmoud Ahmed <pythondeveloper6@gmail.com>
|
|
11
|
+
Maintainer-email: Mahmoud Ahmed <pythondeveloper6@gmail.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: asyncio,cache,circuit-breaker,debounce,decorators,memoize,rate-limit,retry,throttle,timeout,utilities
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Topic :: Utilities
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
<div align="center">
|
|
33
|
+
|
|
34
|
+
<a href="https://pythonwrap.readthedocs.io">
|
|
35
|
+
<picture>
|
|
36
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/LitePy/PythonWrap/main/docs/assets/logo-dark.svg">
|
|
37
|
+
<img alt="python-wrap" src="https://raw.githubusercontent.com/LitePy/PythonWrap/main/docs/assets/logo-light.svg" height="64">
|
|
38
|
+
</picture>
|
|
39
|
+
</a>
|
|
40
|
+
|
|
41
|
+
**The resilience and concurrency toolkit you add to every Python project.**<br>
|
|
42
|
+
Retry (with `Retry-After`), circuit breaker, timeout, cache, rate limit, bulkhead, batching, async helpers, metrics and test helpers. One import, sync and async, zero dependencies.
|
|
43
|
+
|
|
44
|
+
[](https://github.com/LitePy/PythonWrap/actions/workflows/ci.yml)
|
|
45
|
+
[](https://pypi.org/project/python-wrap/)
|
|
46
|
+
[](https://pypi.org/project/python-wrap/)
|
|
47
|
+
[](https://github.com/LitePy/PythonWrap/blob/main/LICENSE)
|
|
48
|
+
[](https://pythonwrap.readthedocs.io)
|
|
49
|
+
[](https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict)
|
|
50
|
+
|
|
51
|
+
[**Documentation**](https://pythonwrap.readthedocs.io) ·
|
|
52
|
+
[Cookbook](https://pythonwrap.readthedocs.io/en/latest/cookbook/) ·
|
|
53
|
+
[Performance](https://pythonwrap.readthedocs.io/en/latest/project/performance/) ·
|
|
54
|
+
[Migration guide](https://pythonwrap.readthedocs.io/en/latest/project/migration/) ·
|
|
55
|
+
[Changelog](https://github.com/LitePy/PythonWrap/blob/main/CHANGELOG.md)
|
|
56
|
+
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## Why python-wrap
|
|
61
|
+
|
|
62
|
+
- **One import for the things every project hand-writes.** Retry, circuit breaker, timeout, cache, rate limit, bulkhead, batching, bounded `gather`, metrics and test helpers, designed to stack and to be tested together.
|
|
63
|
+
- **Resilience that behaves.** `retry` honours `Retry-After`, a time budget and cancellation; a shared `deadline()` caps a whole request; `policy(...)` stacks the protections in the right order for you.
|
|
64
|
+
- **Zero dependencies.** Pure Python, nothing to audit or pin.
|
|
65
|
+
- **Typed.** Decorators preserve the wrapped signature (`ParamSpec`), and stacking keeps the exact type (`@retry` over `@memoize` still has `.cache_info()`). Ships `py.typed`, checked with `mypy --strict`.
|
|
66
|
+
- **Sync and async, one API.** Works on `def`, `async def` and methods. Async wrappers stay coroutine functions and never block the event loop.
|
|
67
|
+
- **Fast.** Cache hits and validation skip signature binding on the hot path ([numbers](https://pythonwrap.readthedocs.io/en/latest/project/performance/)).
|
|
68
|
+
- **Observable and testable.** One event stream for retries, cache, circuit, limiters and timeouts; `python_wrap.testing` makes tests instant and deterministic.
|
|
69
|
+
- **Never configures your logging**, is thread-safe, and is tested on Python 3.10 to 3.14 on Linux, macOS and Windows.
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install python-wrap
|
|
75
|
+
# or
|
|
76
|
+
uv add python-wrap
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Python 3.10 or newer.
|
|
80
|
+
|
|
81
|
+
## 30-second tour
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import asyncio
|
|
85
|
+
import time
|
|
86
|
+
|
|
87
|
+
from python_wrap import (
|
|
88
|
+
CircuitBreaker, gather_limit, memoize, policy, rate_limit, retry, retry_after_header, timeout,
|
|
89
|
+
)
|
|
90
|
+
from python_wrap.testing import fast_mode
|
|
91
|
+
|
|
92
|
+
# One reusable stack for a flaky HTTP dependency. Roles decide the order, not you.
|
|
93
|
+
http = policy(
|
|
94
|
+
cache=memoize(ttl=60, maxsize=256), # hits skip everything below
|
|
95
|
+
breaker=CircuitBreaker(failure_threshold=5, recovery_timeout=30, name="api"),
|
|
96
|
+
retry=retry(max_attempts=4, delay=0.5, backoff=2, jitter=0.1,
|
|
97
|
+
on=ConnectionError, delay_from=retry_after_header, max_time=20),
|
|
98
|
+
timeout=timeout(5), # per attempt
|
|
99
|
+
name="http",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@http
|
|
104
|
+
def fetch_user(user_id: int) -> dict[str, int]:
|
|
105
|
+
return {"id": user_id} # your real HTTP call goes here
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
print(fetch_user(1), fetch_user(1)) # the second one is a cache hit
|
|
109
|
+
print(fetch_user.cache_info()) # the cache API survives the stack
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@rate_limit(100, 1.0, block=True) # at most 100 calls per second, waits when full
|
|
113
|
+
async def fetch_async(user_id: int) -> dict[str, int]:
|
|
114
|
+
await asyncio.sleep(0.01)
|
|
115
|
+
return {"id": user_id}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def main() -> None:
|
|
119
|
+
# 50 requests, never more than 20 in flight; results come back in order.
|
|
120
|
+
users = await gather_limit(*(fetch_async(i) for i in range(50)), limit=20)
|
|
121
|
+
print(len(users), "users")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
asyncio.run(main())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# In tests, every wait is instant but retries still run the configured number of times.
|
|
128
|
+
calls = []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@retry(max_attempts=4, delay=30, backoff=2, on=ConnectionError)
|
|
132
|
+
def flaky() -> str:
|
|
133
|
+
calls.append(1)
|
|
134
|
+
if len(calls) < 4:
|
|
135
|
+
raise ConnectionError
|
|
136
|
+
return "ok"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
start = time.monotonic()
|
|
140
|
+
with fast_mode():
|
|
141
|
+
assert flaky() == "ok" # would take 30 + 60 + 120 seconds
|
|
142
|
+
print(f"{time.monotonic() - start:.2f}s")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## What is in the box
|
|
146
|
+
|
|
147
|
+
Everything below is importable from `python_wrap` unless a module is shown. [Choose a decorator](https://pythonwrap.readthedocs.io/en/latest/get-started/choosing/) is the quick way in.
|
|
148
|
+
|
|
149
|
+
| Area | Name | What it does |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| **Resilience** | `retry` | Backoff, jitter, `max_delay`, `max_time`, `retry_if`, `retry_if_result`, `on_retry` (async hooks too); `delay_from=retry_after_header` honours `Retry-After`; never retries cancellation |
|
|
152
|
+
| | `RetryBudget` | One retry allowance shared across functions, so a failing dependency does not trigger a retry storm |
|
|
153
|
+
| | `retrying`, `Retrying` | Retry an inline block (`for attempt in retrying(...)`, sync and `async for`) |
|
|
154
|
+
| | `retry_stats`, `RetryStats` | Attempts, successes and failures per retried function |
|
|
155
|
+
| | `fallback`, `suppress` | Return a default (or handler result), or swallow chosen exceptions |
|
|
156
|
+
| | `circuit_breaker`, `CircuitBreaker` | Fail fast while a dependency is down; `success_threshold`, `exclude`, `on_state_change`, `.stats`; one breaker can guard many functions |
|
|
157
|
+
| | `timeout`, `Timeout` | Decorator or `with timeout(5):` / `async with`; raises `TimeoutExpired`; `cleanup_grace=` bounds async cleanup |
|
|
158
|
+
| | `deadline`, `Deadline` | A shared time budget; `retry` and `timeout` clamp to it |
|
|
159
|
+
| | `hedge` | Fire a backup call when the first is slow and take the first success (idempotent calls only) |
|
|
160
|
+
| **Async toolkit** | `gather_limit`, `map_async`, `amap_iter` | Bounded concurrency, lazy input, ordered results; everything is cancelled and awaited on error |
|
|
161
|
+
| | `race`, `RaceFailed` | First success wins, the rest are cancelled |
|
|
162
|
+
| | `run_sync` | Call async code from sync code (refuses inside a running loop) |
|
|
163
|
+
| **Caching** | `memoize` | LRU + TTL, `typed`, `key=`, per-item `ttl=`, `cache_exceptions=`, `stale_ttl=` (serve stale while refreshing), `per_instance=True`, `cache_info/clear/delete/contains/get`; async in-flight dedupe |
|
|
164
|
+
| | `CacheStore` | Protocol to keep `memoize` entries in your own store (dict, Redis, ...) |
|
|
165
|
+
| | `cached_property`, `invalidate` | `functools.cached_property` plus `ttl`, locking and `async def` getters |
|
|
166
|
+
| | `once`, `cache` | Run once and reuse (thread-safe, `.reset()`); `cache` is the v0.1 name for `memoize(ttl=...)` |
|
|
167
|
+
| **Concurrency & limits** | `rate_limit`, `RateLimiter` | Sliding window, `block=True`, `key=`, several limits, one budget shared across functions |
|
|
168
|
+
| | `Bulkhead`, `limit_concurrency` | Cap concurrent calls, bound the queue, raise `BulkheadFull` |
|
|
169
|
+
| | `throttle`, `debounce` | At most once per interval; run after calls stop (`.cancel()`, `.flush()`) |
|
|
170
|
+
| | `singleflight`, `run_in_thread` | Share one execution between identical concurrent calls; run in a thread, get a `Future` |
|
|
171
|
+
| | `periodic` | Run a function every N seconds in the background, without drift, with clean `start()`/`stop()` |
|
|
172
|
+
| **Batching** | `batch_calls` | DataLoader pattern: many single-item calls become one bulk call; sync and async, deduped |
|
|
173
|
+
| **Observability & metrics** | `count_calls` | `.stats`: calls, errors, in-flight, mean, p50, p95, p99 |
|
|
174
|
+
| | `subscribe`, `listening` | One event hook for retry, cache, circuit, limiter, bulkhead and timeout events (`python_wrap.events` also has `filter_events`, `EVENT_NAMES`) |
|
|
175
|
+
| | `log_args`, `log_return`, `trace`, `audit` | Structured, redacted logging; `AuditRecord` to a sink; `trace(otel=True)` opens an OpenTelemetry span |
|
|
176
|
+
| | `log_context`, `current_log_context` | Contextvar fields in every log line and audit record |
|
|
177
|
+
| | `timing`, `profile`, `measure_memory`, `benchmark` | Duration with `threshold=`/`callback=`, `cProfile`, `tracemalloc`, `benchmark_async` |
|
|
178
|
+
| **Validation** | `enforce_types`, `validate_args`, `check_type` | Check from annotations, with predicates, or with explicit types |
|
|
179
|
+
| **State** | `transactional`, `Transaction`, `current_transaction` | Commit and rollback callbacks, usable as a (async) context manager |
|
|
180
|
+
| | `revert_on_failure` | Restore an object's attributes if the call raises |
|
|
181
|
+
| **Configuration** | `configure`, `configured`, `get_config`, `reset_config` | Project-wide defaults for logger, retry options and redaction |
|
|
182
|
+
| | `policy`, `Policy` | Named, immutable decorator bundles applied in the recommended order |
|
|
183
|
+
| **Testing** (`python_wrap.testing`) | `fast_mode`, `disable_retries`, `disable_rate_limits`, `disable_caching`, `overrides` | Skip waits, cap retries, switch limiters and caches off |
|
|
184
|
+
| | `FakeClock`, `fake_clock`, `spy`, `spy_on`, `eventually` | Control time, record calls, wait for background work; pytest fixtures `pw_fake_clock`, `pw_fast_mode` |
|
|
185
|
+
| **Dev tools** | `deprecate`, `mock_data`, `compose`, `wrap_methods` | Deprecation warnings at the caller, fixed test data, decorator bundles, decorate a whole class |
|
|
186
|
+
|
|
187
|
+
Exceptions live in `python_wrap.errors` and at the top level: `PythonWrapError`, `TimeoutExpired` (a `TimeoutError`), `RateLimitExceeded`, `CircuitOpenError`, `BulkheadFull`, `BatchError`, `ValidationError` (a `ValueError`) and `ArgumentTypeError` (a `TypeError`).
|
|
188
|
+
|
|
189
|
+
## Highlights
|
|
190
|
+
|
|
191
|
+
### Retry, `Retry-After` and fail-soft
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from python_wrap import RetryState, retry
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def note(state: RetryState) -> None:
|
|
198
|
+
print(f"attempt {state.attempt} failed: {state.exception!r}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
calls = 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@retry(
|
|
205
|
+
max_attempts=5,
|
|
206
|
+
delay=0.01, # wait before the 2nd attempt
|
|
207
|
+
backoff=2.0, # then 2x longer each time
|
|
208
|
+
max_delay=0.1, # but never more than this
|
|
209
|
+
jitter=0.01, # plus up to this many random seconds
|
|
210
|
+
max_time=5, # and never spend longer than this in total
|
|
211
|
+
on=(ConnectionError, TimeoutError),
|
|
212
|
+
retry_if=lambda exc: "temporary" in str(exc),
|
|
213
|
+
on_retry=note,
|
|
214
|
+
)
|
|
215
|
+
def flaky() -> str:
|
|
216
|
+
global calls
|
|
217
|
+
calls += 1
|
|
218
|
+
if calls < 3:
|
|
219
|
+
raise ConnectionError("temporary glitch")
|
|
220
|
+
return "ok"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
print(flaky()) # ok, after two retries
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Fail soft with `fallback(value)` or `suppress(OSError)`. The last exception is re-raised unchanged once attempts run out. `max_attempts=None` retries forever. Cancellation (`asyncio.CancelledError`, `KeyboardInterrupt`) is never retried. Add `delay_from=retry_after_header` to wait exactly as long as a 429/503 `Retry-After` header says, and retry a block of code with `for attempt in retrying(...)`.
|
|
227
|
+
|
|
228
|
+
### Circuit breaker, timeout and deadline
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
import time
|
|
232
|
+
|
|
233
|
+
from python_wrap import CircuitBreaker, CircuitOpenError, CircuitState, TimeoutExpired, deadline, timeout
|
|
234
|
+
|
|
235
|
+
payments = CircuitBreaker(failure_threshold=2, recovery_timeout=30, name="payments")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@payments
|
|
239
|
+
def charge(amount: int) -> str:
|
|
240
|
+
raise ConnectionError("gateway down")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
for _ in range(2):
|
|
244
|
+
try:
|
|
245
|
+
charge(10)
|
|
246
|
+
except ConnectionError:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
charge(10)
|
|
251
|
+
except CircuitOpenError:
|
|
252
|
+
print("failing fast, circuit is", payments.state.value) # no call was made
|
|
253
|
+
assert payments.state is CircuitState.OPEN
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@timeout(1)
|
|
257
|
+
def step(seconds: float) -> None:
|
|
258
|
+
time.sleep(seconds)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
with deadline(0.05): # one budget for everything inside; timeouts clamp to it
|
|
263
|
+
step(0.01)
|
|
264
|
+
step(1)
|
|
265
|
+
except TimeoutExpired:
|
|
266
|
+
print("out of time")
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
After `recovery_timeout` the breaker goes half-open and lets a trial call through. Share one `CircuitBreaker` across functions for one dependency, or use `@circuit_breaker(...)` for a private one (`func.breaker`). `timeout` interrupts sync code with `SIGALRM` on the Unix main thread, cancels `async def` with `asyncio.wait_for`, and elsewhere falls back to a thread that cannot stop the work (see [Limitations](https://pythonwrap.readthedocs.io/en/latest/project/limitations/)).
|
|
270
|
+
|
|
271
|
+
### Caching
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from python_wrap import memoize
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@memoize(maxsize=128, ttl=30)
|
|
278
|
+
def square(n: int, *, scale: int = 1) -> int:
|
|
279
|
+
return n * n * scale
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
square(3)
|
|
283
|
+
square(n=3) # same entry: arguments are bound to the signature
|
|
284
|
+
square(3, scale=1) # still the same entry
|
|
285
|
+
print(square.cache_info()) # 2 hits, 1 miss
|
|
286
|
+
square.cache_delete(3) # drop one entry
|
|
287
|
+
square.cache_clear() # drop everything
|
|
288
|
+
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`cached_property(ttl=)` does the same for attributes. Keys are built from the bound signature, lists, dicts and sets are converted automatically, and exceptions are not cached unless you ask (`cache_exceptions=True, exception_ttl=`). Concurrent awaits of the same missing key in an `async def` share one in-flight task. On methods the cache keeps instances alive, so give it `maxsize` or `ttl`.
|
|
292
|
+
|
|
293
|
+
### Async toolkit, bulkheads and batching
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
import asyncio
|
|
297
|
+
|
|
298
|
+
from python_wrap import Bulkhead, BulkheadFull, batch_calls, map_async, race
|
|
299
|
+
|
|
300
|
+
queries: list[list[int]] = []
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@batch_calls(max_size=100, max_wait=0.005) # the DataLoader pattern
|
|
304
|
+
async def get_user(ids: list[int]) -> dict[int, str]:
|
|
305
|
+
queries.append(ids) # one round trip for the whole batch
|
|
306
|
+
return {i: f"user{i}" for i in ids}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
db = Bulkhead(2, max_waiting=1, name="db") # 2 running, 1 queued, the rest rejected
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@db
|
|
313
|
+
async def query(n: int) -> int:
|
|
314
|
+
await asyncio.sleep(0.01)
|
|
315
|
+
return n
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def main() -> None:
|
|
319
|
+
users = await asyncio.gather(get_user(1), get_user(2), get_user(1))
|
|
320
|
+
assert users == ["user1", "user2", "user1"] and queries == [[1, 2]]
|
|
321
|
+
|
|
322
|
+
doubled = await map_async(lambda n: query(n * 2), range(3), limit=2)
|
|
323
|
+
assert doubled == [0, 2, 4]
|
|
324
|
+
|
|
325
|
+
results = await asyncio.gather(*(query(n) for n in range(4)), return_exceptions=True)
|
|
326
|
+
assert isinstance(results[3], BulkheadFull)
|
|
327
|
+
|
|
328
|
+
async def slow() -> str:
|
|
329
|
+
await asyncio.sleep(1)
|
|
330
|
+
return "slow"
|
|
331
|
+
|
|
332
|
+
async def fast() -> str:
|
|
333
|
+
return "fast"
|
|
334
|
+
|
|
335
|
+
assert await race(slow(), fast()) == "fast" # the loser is cancelled
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
asyncio.run(main())
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Every helper cancels and awaits the tasks it started, so nothing leaks on error or cancellation. Also see `hedge` for backup requests on slow reads and `run_sync` for calling async code from scripts.
|
|
342
|
+
|
|
343
|
+
### Rate limits, throttle and debounce
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
from python_wrap import RateLimitExceeded, RateLimiter, rate_limit
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@rate_limit(2, 0.2, key=lambda user, *_: user) # 2 calls / 0.2 s, per user
|
|
350
|
+
def send(user: str, text: str) -> str:
|
|
351
|
+
return f"{user}: {text}"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
send("ann", "a")
|
|
355
|
+
send("ann", "b")
|
|
356
|
+
try:
|
|
357
|
+
send("ann", "c")
|
|
358
|
+
except RateLimitExceeded as exc:
|
|
359
|
+
print(f"slow down, retry in {exc.retry_after:.2f}s")
|
|
360
|
+
send("bob", "hello") # different key, different budget
|
|
361
|
+
|
|
362
|
+
github = RateLimiter(5, 1.0, name="github") # one budget shared by several functions
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@github
|
|
366
|
+
def get_repo(name: str) -> str:
|
|
367
|
+
return name
|
|
368
|
+
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
`rate_limit(block=True)` waits instead of raising (with `await` for `async def`), and `limits=[(5, 1), (600, 900)]` enforces several windows at once. `throttle` and `debounce` cover "at most once per interval" and "after the burst settles". The sliding window never lets a `2 * calls` burst through at a window edge.
|
|
372
|
+
|
|
373
|
+
### Metrics and events
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
from python_wrap import count_calls, listening, retry
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@count_calls
|
|
380
|
+
@retry(max_attempts=3, delay=0)
|
|
381
|
+
def work(attempts: list[int]) -> str:
|
|
382
|
+
attempts.append(1)
|
|
383
|
+
if len(attempts) < 3:
|
|
384
|
+
raise ConnectionError
|
|
385
|
+
return "ok"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
names: list[str] = []
|
|
389
|
+
with listening(lambda event: names.append(event.name)):
|
|
390
|
+
work([])
|
|
391
|
+
|
|
392
|
+
print(names) # retry.attempt_failed, ..., retry.succeeded
|
|
393
|
+
print(work.stats.calls, work.stats.p95)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Subscribe once and forward events to Prometheus, OpenTelemetry or statsd (recipes in the [guide](https://pythonwrap.readthedocs.io/en/latest/features/observability/metrics/)). Listeners cannot break the decorated function.
|
|
397
|
+
|
|
398
|
+
### Validation and logging with redaction
|
|
399
|
+
|
|
400
|
+
```python
|
|
401
|
+
import logging
|
|
402
|
+
|
|
403
|
+
from python_wrap import ArgumentTypeError, enforce_types, log_args
|
|
404
|
+
|
|
405
|
+
logging.basicConfig(level=logging.DEBUG, format="%(message)s") # your app configures logging
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@enforce_types
|
|
409
|
+
def total(prices: list[float], currency: str = "USD") -> str:
|
|
410
|
+
return f"{sum(prices):.2f} {currency}"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
print(total([1.5, 2.5]))
|
|
414
|
+
try:
|
|
415
|
+
total([1.5, "2.5"]) # type: ignore[list-item]
|
|
416
|
+
except ArgumentTypeError as exc:
|
|
417
|
+
print(exc) # parameter 'prices' expected list[float], got list containing str at index 1
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@log_args(logger="myapp.auth")
|
|
421
|
+
def login(username: str, password: str, api_key: str = "") -> bool:
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
login("ann", "hunter2", api_key="sk-123")
|
|
426
|
+
# Calling login(username='ann', password='***', api_key='***')
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Parameters whose names contain `password`, `token`, `secret`, `api_key` and similar (`DEFAULT_REDACT`) are masked in `log_args` and `audit`; extend with `redact_extra=` or project-wide through `configure(redact_extra=...)`. For custom rules use `validate_args(x=lambda x: x > 0)`.
|
|
430
|
+
|
|
431
|
+
### Transactions and defaults
|
|
432
|
+
|
|
433
|
+
`@transactional` gives a function a `tx: Transaction` (declare it last or keyword-only) to register `on_commit` and `on_rollback` callbacks; `current_transaction()` reaches it from anywhere. `configure(logger=..., redact_extra=...)` sets project-wide defaults read at decoration time, and explicit arguments always win.
|
|
434
|
+
|
|
435
|
+
## Stacking decorators and policies
|
|
436
|
+
|
|
437
|
+
Decorators are applied bottom-up and run top-down, so order matters:
|
|
438
|
+
|
|
439
|
+
| Put this... | ...outside of this | Because |
|
|
440
|
+
|---|---|---|
|
|
441
|
+
| `memoize` | `retry`, `rate_limit`, `timeout` | Cache hits skip everything; only misses pay |
|
|
442
|
+
| `retry` | `timeout`, `circuit_breaker`, `rate_limit` | Each attempt gets its own deadline |
|
|
443
|
+
| `circuit_breaker` | `retry` or `timeout` | The breaker counts calls whose retries were used up |
|
|
444
|
+
| `fallback` | `retry`, `circuit_breaker` | Fall back only after retries are exhausted or the circuit is open |
|
|
445
|
+
| `log_args`, `timing`, `audit` | everything | They observe what callers actually experience |
|
|
446
|
+
| `enforce_types`, `validate_args` | the rest | Reject bad input before doing any work |
|
|
447
|
+
|
|
448
|
+
You do not have to memorise this. `policy(observe=, validate=, fallback=, cache=, limiter=, bulkhead=, breaker=, retry=, timeout=, guard=)` takes each decorator by role and applies them in the recommended order, whatever order you wrote them in. Policies are immutable (`.with_(timeout=timeout(30))`), work on methods and `async def`, and `wrap_methods(policy)` protects a whole class. `compose(a, b, c)(f)` is `a(b(c(f)))` when you just want a named stack. Details: [Stacking](https://pythonwrap.readthedocs.io/en/latest/get-started/stacking/) and [Policies](https://pythonwrap.readthedocs.io/en/latest/features/resilience/policies/).
|
|
449
|
+
|
|
450
|
+
## How it compares
|
|
451
|
+
|
|
452
|
+
python-wrap is a broad toolkit; the others are deeper in one area. Use them when that one area is all you need and you want the specialist.
|
|
453
|
+
|
|
454
|
+
| Library | Use it instead when... | python-wrap's angle |
|
|
455
|
+
|---|---|---|
|
|
456
|
+
| [tenacity](https://github.com/jd/tenacity) | You need very rich retry policies: composable stop/wait strategies, per-call overrides | `retry` covers the common cases (backoff, jitter, `Retry-After`, time budget, hooks, async) with a flat signature, and shares a deadline, breaker and events with the rest |
|
|
457
|
+
| [stamina](https://github.com/hynek/stamina) | You want opinionated, production-ready retries with Prometheus and structlog integration built in | Similar defaults-first retry, plus the breaker, timeout, caches and limits it composes with; metrics via a generic event hook |
|
|
458
|
+
| [backoff](https://github.com/litl/backoff) | You already use it and are happy. The repository was archived in 2025, so it no longer receives fixes | A maintained, typed retry with the same core ideas |
|
|
459
|
+
| [cachetools](https://github.com/tkem/cachetools) | You need other eviction policies (LFU, FIFO, RR), explicit cache objects or custom locks | `memoize` gives LRU + TTL, `cache_info()`, async in-flight dedupe, signature-bound keys and `cached_property(ttl=)` |
|
|
460
|
+
| [async-lru](https://github.com/aio-libs/async-lru) | You only cache coroutines and want a tiny dependency | Same async dedupe, also for sync code, with TTL, per-item TTL and exception caching |
|
|
461
|
+
| [aiolimiter](https://github.com/mjpieters/aiolimiter) | You only need an asyncio leaky-bucket limiter | Sliding window for sync and async, per-key buckets, shared `RateLimiter`, `block=True`, plus `Bulkhead` for concurrency |
|
|
462
|
+
| [pybreaker](https://github.com/danielfm/pybreaker) | You need listeners or circuit state shared across processes (Redis storage) | A typed `CircuitBreaker` with async, `exclude=`, `success_threshold` and `.stats`, no dependency |
|
|
463
|
+
| [functools](https://docs.python.org/3/library/functools.html) | A plain `lru_cache` or `cache` on a sync function with hashable arguments is enough. Prefer it for that | Adds TTL, async, unhashable arguments and `cache_delete` |
|
|
464
|
+
|
|
465
|
+
None of them cover bounded `gather`, bulkheads, batching, hedging, policies or test helpers; python-wrap trades per-area depth for one consistent, typed, dependency-free API.
|
|
466
|
+
|
|
467
|
+
## Learn more
|
|
468
|
+
|
|
469
|
+
- [Cookbook](https://pythonwrap.readthedocs.io/en/latest/cookbook/): resilient HTTP client, async crawler, FastAPI, Django, CLI tools, DataLoader batching, testing, observability.
|
|
470
|
+
- [Performance](https://pythonwrap.readthedocs.io/en/latest/project/performance/): per-call overhead and how to benchmark it yourself.
|
|
471
|
+
- [Testing](https://pythonwrap.readthedocs.io/en/latest/testing/): `fast_mode`, `FakeClock`, `spy` and pytest fixtures.
|
|
472
|
+
- [Migration guide](https://pythonwrap.readthedocs.io/en/latest/project/migration/) and [CHANGELOG](https://github.com/LitePy/PythonWrap/blob/main/CHANGELOG.md).
|
|
473
|
+
- Runnable [examples](https://github.com/LitePy/PythonWrap/tree/main/examples).
|
|
474
|
+
|
|
475
|
+
## Upgrading from 0.1
|
|
476
|
+
|
|
477
|
+
0.2.0 fixes decorators that did not do what their names promised (`transactional`, `revert_on_failure`, async `timing`) and makes a few breaking changes: `run_in_thread` returns a `Future`, `benchmark` is a function, `retry_on_exception` is deprecated and bounded, `rate_limit` raises `RateLimitExceeded`, and the logging decorators default to `DEBUG`. Python 3.8 and 3.9 are no longer supported. `from python_wrap.decorators import ...` still works. See the [migration guide](https://pythonwrap.readthedocs.io/en/latest/project/migration/).
|
|
478
|
+
|
|
479
|
+
## Contributing
|
|
480
|
+
|
|
481
|
+
Bug reports, ideas and pull requests are welcome. Read [CONTRIBUTING.md](https://github.com/LitePy/PythonWrap/blob/main/CONTRIBUTING.md) for the development setup and checks, and [SECURITY.md](https://github.com/LitePy/PythonWrap/blob/main/SECURITY.md) for reporting vulnerabilities.
|
|
482
|
+
|
|
483
|
+
## License
|
|
484
|
+
|
|
485
|
+
[MIT](https://github.com/LitePy/PythonWrap/blob/main/LICENSE)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
python_wrap/__init__.py,sha256=mOEWHKnniaYpqW2GioCvCeXFdrnAz_6dGxTJXBvHnOw,4807
|
|
2
|
+
python_wrap/_core.py,sha256=wpAq9lhYN1dWeSfoFqsPznxRAkF3_ui1qERmmMyRiW0,8695
|
|
3
|
+
python_wrap/aio.py,sha256=zFutfNc-mDAFb7ImV8oONB1mYLIWIrmmJQn-tuJmdFI,22147
|
|
4
|
+
python_wrap/batching.py,sha256=w9CGn60vrMjA2uu9qnMHZBRF9uAjxO8z9TzqC7FDlVo,21370
|
|
5
|
+
python_wrap/bulkhead.py,sha256=6p2won4-n59NbAUhThLlayPiXQQADrbGVqNiOn6D93g,15309
|
|
6
|
+
python_wrap/caching.py,sha256=IJZm312bUtnMRTkQRKYnuOBXMcTCu-lniuLh2h9x_Ro,62883
|
|
7
|
+
python_wrap/circuit.py,sha256=aw06aM9B0uNhcYZuwyVtkMwKOYwEbb2MavEkpi2Cgbc,20654
|
|
8
|
+
python_wrap/concurrency.py,sha256=jb_-L0BeFU17Gy78WlSqlbDcQlEXgtIHmpcDdxF6PKU,73855
|
|
9
|
+
python_wrap/config.py,sha256=Llp-7jlRMTEKltwL_qBdOQsaSKW7bFfsrm592cGZ4EM,7542
|
|
10
|
+
python_wrap/deadline.py,sha256=v2KTpf0kxdqXcGQm8YDoQ3vx-epSTSEpTt84jop4EIc,5859
|
|
11
|
+
python_wrap/decorators.py,sha256=x03toQUV0ZpIWimgBWXj0tihB9dYp9fdd9U0bkl2W6Y,206
|
|
12
|
+
python_wrap/devtools.py,sha256=4oTk5ubN8a0Knq3FzyFbtV8L8EeNQXpfl9VohAhdSLE,13512
|
|
13
|
+
python_wrap/errors.py,sha256=j7lGwoqS7Eq1T_I0IV-CwIrbcpxwACz2wGVsOnQdAD8,2685
|
|
14
|
+
python_wrap/events.py,sha256=n88H-1yyd4UHDSCFD6UofcV7pgiLK66QK-KgEy3xFW4,6083
|
|
15
|
+
python_wrap/hedging.py,sha256=shj4sebgYGXlWgQZfgH9Fo7Q33LEuCJcBtBWCz0wPOM,8162
|
|
16
|
+
python_wrap/logs.py,sha256=bfvsNUQyfS-i7SOPXO2pTvxDGBu8_BBAzwi6iKSOS3A,26802
|
|
17
|
+
python_wrap/metrics.py,sha256=-unhVArc_mABM35gyYyhHl7pmHD5HbM14t7q_ckicwI,7858
|
|
18
|
+
python_wrap/periodic.py,sha256=D2LqO1EOuleHsESMrgOZDxLeq04YLF8SBO8oOrAVKjk,19095
|
|
19
|
+
python_wrap/policy.py,sha256=DcTPVIx2BSlPXsTt5PZWW_leNeQWH_pIZu4ocPK3a0M,9561
|
|
20
|
+
python_wrap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
python_wrap/retry.py,sha256=j9DjOF0SHTuEJnJQNtC_FRLeU0bYzOMCDA-I8VNxoik,49139
|
|
22
|
+
python_wrap/state.py,sha256=pM2fl8IdLw3EU3uGWjraGx9xPMTmPaDk3M7xMd7hXbE,18099
|
|
23
|
+
python_wrap/testing.py,sha256=YbYQ_NDvLq4UBU6MDD23XWAadslwM43Bx_BpNYuZmqc,21513
|
|
24
|
+
python_wrap/timing.py,sha256=Bnud5P0ZLEu1UMPr7_a5d6Co1Tawq3VZnl3bjEJSXU0,17067
|
|
25
|
+
python_wrap/validation.py,sha256=JC_7WZlHl4raTZPzfCDihLYseGME3c16n9Ia6Mm9bis,25774
|
|
26
|
+
python_wrap-0.2.1.dist-info/METADATA,sha256=PRxusaLTae9U0WVqGgMEpMtibyi41inO9y6iOvRz-xc,25036
|
|
27
|
+
python_wrap-0.2.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
28
|
+
python_wrap-0.2.1.dist-info/licenses/LICENSE,sha256=HJx7meo3u1AM4Ms6Q1QNNdwSSdstdmEpo_AAx_TycH0,1079
|
|
29
|
+
python_wrap-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 PythonWrap Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|