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/metrics.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""In-process call metrics: `count_calls` tracks calls, errors, concurrency and latency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import math
|
|
7
|
+
import threading
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any, Concatenate, ParamSpec, Protocol, TypeVar, cast, overload
|
|
11
|
+
|
|
12
|
+
from python_wrap import _core
|
|
13
|
+
|
|
14
|
+
__all__ = ["CallStats", "Counted", "count_calls"]
|
|
15
|
+
|
|
16
|
+
P = ParamSpec("P")
|
|
17
|
+
P2 = ParamSpec("P2")
|
|
18
|
+
R = TypeVar("R")
|
|
19
|
+
R_co = TypeVar("R_co", covariant=True)
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _percentile(sorted_values: list[float], q: float) -> float:
|
|
24
|
+
"""Nearest-rank percentile of an already sorted list (0.0 when empty)."""
|
|
25
|
+
if not sorted_values:
|
|
26
|
+
return 0.0
|
|
27
|
+
rank = max(math.ceil(q / 100 * len(sorted_values)), 1)
|
|
28
|
+
return sorted_values[rank - 1]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CallStats:
|
|
32
|
+
"""Live statistics of a function wrapped by `count_calls`.
|
|
33
|
+
|
|
34
|
+
Every attribute is read under a lock at access time, so ``f.stats.calls`` always
|
|
35
|
+
reflects the present. Latency figures (``mean``, ``p50``, ``p95``, ``p99``) cover the
|
|
36
|
+
last ``window`` finished calls, successful or not, in seconds; they are ``0.0`` before
|
|
37
|
+
the first call finishes. ``calls`` and ``errors`` count every call since the last
|
|
38
|
+
reset, not just the window.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
calls: Calls that have finished (returned or raised).
|
|
42
|
+
errors: Calls that raised an exception.
|
|
43
|
+
in_flight: Calls running right now.
|
|
44
|
+
last_duration: Seconds the most recently finished call took.
|
|
45
|
+
mean: Mean duration over the window.
|
|
46
|
+
p50: Median duration over the window (nearest rank).
|
|
47
|
+
p95: 95th percentile duration over the window.
|
|
48
|
+
p99: 99th percentile duration over the window.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, window: int) -> None:
|
|
52
|
+
self._window = window
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
self._durations: deque[float] = deque(maxlen=window)
|
|
55
|
+
self._calls = 0
|
|
56
|
+
self._errors = 0
|
|
57
|
+
self._in_flight = 0
|
|
58
|
+
self._last = 0.0
|
|
59
|
+
|
|
60
|
+
def _begin(self) -> None:
|
|
61
|
+
with self._lock:
|
|
62
|
+
self._in_flight += 1
|
|
63
|
+
|
|
64
|
+
def _end(self, duration: float, ok: bool) -> None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
self._in_flight -= 1
|
|
67
|
+
self._calls += 1
|
|
68
|
+
if not ok:
|
|
69
|
+
self._errors += 1
|
|
70
|
+
self._last = duration
|
|
71
|
+
self._durations.append(duration)
|
|
72
|
+
|
|
73
|
+
def _reset(self) -> None:
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._durations.clear()
|
|
76
|
+
self._calls = self._errors = 0
|
|
77
|
+
self._last = 0.0
|
|
78
|
+
# in_flight is left alone: those calls are still running and will finish.
|
|
79
|
+
|
|
80
|
+
def _sorted(self) -> list[float]:
|
|
81
|
+
with self._lock:
|
|
82
|
+
return sorted(self._durations)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def calls(self) -> int:
|
|
86
|
+
with self._lock:
|
|
87
|
+
return self._calls
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def errors(self) -> int:
|
|
91
|
+
with self._lock:
|
|
92
|
+
return self._errors
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def in_flight(self) -> int:
|
|
96
|
+
with self._lock:
|
|
97
|
+
return self._in_flight
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def last_duration(self) -> float:
|
|
101
|
+
with self._lock:
|
|
102
|
+
return self._last
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def mean(self) -> float:
|
|
106
|
+
values = self._sorted()
|
|
107
|
+
return sum(values) / len(values) if values else 0.0
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def p50(self) -> float:
|
|
111
|
+
return _percentile(self._sorted(), 50)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def p95(self) -> float:
|
|
115
|
+
return _percentile(self._sorted(), 95)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def p99(self) -> float:
|
|
119
|
+
return _percentile(self._sorted(), 99)
|
|
120
|
+
|
|
121
|
+
def __repr__(self) -> str:
|
|
122
|
+
return (
|
|
123
|
+
f"CallStats(calls={self.calls}, errors={self.errors}, in_flight={self.in_flight}, "
|
|
124
|
+
f"mean={self.mean:.6f}, p95={self.p95:.6f})"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Counted(Protocol[P, R_co]):
|
|
129
|
+
"""A function wrapped by `count_calls` (callable plus ``stats`` and ``reset()``).
|
|
130
|
+
|
|
131
|
+
Use it to annotate decorated functions, e.g. ``Counted[[int], str]``. The ``__get__``
|
|
132
|
+
overloads let type checkers bind ``self`` on instance access and ``cls`` under a
|
|
133
|
+
``@classmethod`` stacked above, with the same typing limitations as `Memoized`
|
|
134
|
+
(see `python_wrap.caching`). The statistics belong to the function, so they are
|
|
135
|
+
shared by every instance of a class.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
stats: CallStats
|
|
139
|
+
|
|
140
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
|
|
141
|
+
|
|
142
|
+
@overload
|
|
143
|
+
def __get__(
|
|
144
|
+
self: Counted[Concatenate[type[T], P2], R_co], instance: None, owner: type[T]
|
|
145
|
+
) -> _CountedOnClass[P, P2, R_co]: ...
|
|
146
|
+
@overload
|
|
147
|
+
def __get__(self, instance: None, owner: type | None = None) -> Counted[P, R_co]: ...
|
|
148
|
+
@overload
|
|
149
|
+
def __get__(
|
|
150
|
+
self: Counted[Concatenate[Any, P2], R_co], instance: object, owner: type | None = None
|
|
151
|
+
) -> Counted[P2, R_co]: ...
|
|
152
|
+
|
|
153
|
+
def reset(self) -> None:
|
|
154
|
+
"""Zero the counters and clear the duration window."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _CountedOnClass(Counted[P, R_co], Protocol[P, P2, R_co]):
|
|
158
|
+
"""`Counted` read from a class: accepts the call with and without the first argument."""
|
|
159
|
+
|
|
160
|
+
@overload
|
|
161
|
+
def __call__(self, *args: P2.args, **kwargs: P2.kwargs) -> R_co: ...
|
|
162
|
+
@overload
|
|
163
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@overload
|
|
167
|
+
def count_calls(func: Callable[P, R], /) -> Counted[P, R]: ...
|
|
168
|
+
@overload
|
|
169
|
+
def count_calls(
|
|
170
|
+
func: None = None, /, *, window: int = 1000
|
|
171
|
+
) -> Callable[[Callable[P, R]], Counted[P, R]]: ...
|
|
172
|
+
def count_calls(func: Callable[..., Any] | None = None, /, *, window: int = 1000) -> Any:
|
|
173
|
+
"""Track how often a function runs, fails, and how long it takes.
|
|
174
|
+
|
|
175
|
+
The wrapper exposes ``.stats`` (a live `CallStats`: ``calls``, ``errors``,
|
|
176
|
+
``in_flight``, ``last_duration``, ``mean``, ``p50``, ``p95``, ``p99``) and
|
|
177
|
+
``.reset()``. Sync and ``async def`` functions are supported (async calls are timed
|
|
178
|
+
across the whole awaited coroutine), as are methods; statistics are per function, not
|
|
179
|
+
per instance. A call that raises counts as an error and its exception propagates.
|
|
180
|
+
Cancellation counts as an error too. Updates are thread-safe.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
func: The function, when used as a bare ``@count_calls``.
|
|
184
|
+
window: How many of the most recent durations feed ``mean`` and the percentiles.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The wrapped function, a `Counted`.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
ValueError: If ``window`` is less than 1.
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
>>> @count_calls(window=500)
|
|
194
|
+
... def work() -> int:
|
|
195
|
+
... return 1
|
|
196
|
+
>>> work()
|
|
197
|
+
1
|
|
198
|
+
>>> work.stats.calls
|
|
199
|
+
1
|
|
200
|
+
"""
|
|
201
|
+
if window < 1:
|
|
202
|
+
raise ValueError("window must be >= 1")
|
|
203
|
+
|
|
204
|
+
def decorate(f: Callable[P, R]) -> Counted[P, R]:
|
|
205
|
+
stats = CallStats(window)
|
|
206
|
+
|
|
207
|
+
if _core.is_async(f):
|
|
208
|
+
|
|
209
|
+
@functools.wraps(f)
|
|
210
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
211
|
+
stats._begin()
|
|
212
|
+
began = _core.perf_counter()
|
|
213
|
+
ok = False
|
|
214
|
+
try:
|
|
215
|
+
result = await f(*args, **kwargs) # type: ignore[misc]
|
|
216
|
+
ok = True
|
|
217
|
+
return result
|
|
218
|
+
finally:
|
|
219
|
+
stats._end(_core.perf_counter() - began, ok)
|
|
220
|
+
|
|
221
|
+
wrapper: Any = awrapper
|
|
222
|
+
else:
|
|
223
|
+
|
|
224
|
+
@functools.wraps(f)
|
|
225
|
+
def swrapper(*args: Any, **kwargs: Any) -> Any:
|
|
226
|
+
stats._begin()
|
|
227
|
+
began = _core.perf_counter()
|
|
228
|
+
ok = False
|
|
229
|
+
try:
|
|
230
|
+
result = f(*args, **kwargs)
|
|
231
|
+
ok = True
|
|
232
|
+
return result
|
|
233
|
+
finally:
|
|
234
|
+
stats._end(_core.perf_counter() - began, ok)
|
|
235
|
+
|
|
236
|
+
wrapper = swrapper
|
|
237
|
+
|
|
238
|
+
wrapper.stats = stats
|
|
239
|
+
wrapper.reset = stats._reset
|
|
240
|
+
return cast("Counted[P, R]", wrapper)
|
|
241
|
+
|
|
242
|
+
return decorate if func is None else decorate(func)
|