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