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/timing.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Performance decorators: timing, benchmarking, memory measurement and profiling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
7
|
+
import statistics
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Any, overload
|
|
13
|
+
|
|
14
|
+
from python_wrap import _core
|
|
15
|
+
from python_wrap._core import F, LoggerLike, P, R
|
|
16
|
+
from python_wrap.logs import _instrument, _log
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import cProfile
|
|
20
|
+
import pstats
|
|
21
|
+
|
|
22
|
+
# cProfile, pstats and tracemalloc are imported on first use: they are only needed
|
|
23
|
+
# by profile and measure_memory, and importing them eagerly slows down every import.
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BenchmarkResult",
|
|
27
|
+
"MemoryRecord",
|
|
28
|
+
"TimingRecord",
|
|
29
|
+
"benchmark",
|
|
30
|
+
"benchmark_async",
|
|
31
|
+
"measure_memory",
|
|
32
|
+
"profile",
|
|
33
|
+
"timing",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------------------- helpers
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _format_duration(seconds: float) -> str:
|
|
41
|
+
"""Pick readable units: ``850.00 us``, ``12.34 ms``, ``1.50 s``, ``2 min 03.0 s``."""
|
|
42
|
+
if seconds < 1e-3:
|
|
43
|
+
return f"{seconds * 1e6:.2f} us"
|
|
44
|
+
if seconds < 1:
|
|
45
|
+
return f"{seconds * 1e3:.2f} ms"
|
|
46
|
+
if seconds < 60:
|
|
47
|
+
return f"{seconds:.2f} s"
|
|
48
|
+
minutes, rest = divmod(seconds, 60)
|
|
49
|
+
return f"{int(minutes)} min {rest:04.1f} s"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _format_bytes(count: int, *, signed: bool = False) -> str:
|
|
53
|
+
"""Human-readable byte count (binary units)."""
|
|
54
|
+
sign = "-" if count < 0 else ("+" if signed else "")
|
|
55
|
+
value = float(abs(count))
|
|
56
|
+
for unit in ("B", "KiB", "MiB", "GiB"):
|
|
57
|
+
if value < 1024 or unit == "GiB":
|
|
58
|
+
text = f"{value:.0f} {unit}" if unit == "B" else f"{value:.2f} {unit}"
|
|
59
|
+
return sign + text
|
|
60
|
+
value /= 1024
|
|
61
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _safe_callback(
|
|
65
|
+
lg: logging.Logger, callback: Callable[[Any], None] | None, record: Any, name: str
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Call a user callback; a failing metrics hook must not break the call."""
|
|
68
|
+
if callback is None:
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
callback(record)
|
|
72
|
+
except Exception:
|
|
73
|
+
lg.exception("callback for %s failed", name)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------- timing
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class TimingRecord:
|
|
81
|
+
"""Result of one timed call, passed to ``timing(callback=...)``.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
name: Qualified name of the function.
|
|
85
|
+
elapsed: Seconds the call took.
|
|
86
|
+
success: ``False`` if the call raised.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
name: str
|
|
90
|
+
elapsed: float
|
|
91
|
+
success: bool
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@overload
|
|
95
|
+
def timing(func: F, /) -> F: ...
|
|
96
|
+
@overload
|
|
97
|
+
def timing(
|
|
98
|
+
func: None = None,
|
|
99
|
+
/,
|
|
100
|
+
*,
|
|
101
|
+
logger: LoggerLike = None,
|
|
102
|
+
level: int = logging.INFO,
|
|
103
|
+
threshold: float | None = None,
|
|
104
|
+
callback: Callable[[TimingRecord], None] | None = None,
|
|
105
|
+
) -> Callable[[F], F]: ...
|
|
106
|
+
def timing(
|
|
107
|
+
func: Callable[..., Any] | None = None,
|
|
108
|
+
/,
|
|
109
|
+
*,
|
|
110
|
+
logger: LoggerLike = None,
|
|
111
|
+
level: int = logging.INFO,
|
|
112
|
+
threshold: float | None = None,
|
|
113
|
+
callback: Callable[[TimingRecord], None] | None = None,
|
|
114
|
+
) -> Any:
|
|
115
|
+
"""Measure and log how long each call takes.
|
|
116
|
+
|
|
117
|
+
Logs ``<name> took 12.34 ms``. If the call raises, logs
|
|
118
|
+
``<name> raised ValueError after 12.34 ms`` and re-raises. Async functions are timed
|
|
119
|
+
across the whole awaited coroutine (v0.1 measured about 0 s for them).
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
func: The function, when used as a bare ``@timing``.
|
|
123
|
+
logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger.
|
|
124
|
+
level: Log level.
|
|
125
|
+
threshold: If set, successful calls are only logged when slower than this many
|
|
126
|
+
seconds. Failed calls are always logged.
|
|
127
|
+
callback: Called with a `TimingRecord` for *every* call, regardless of
|
|
128
|
+
``threshold``; use it to feed a metrics system. Errors in the callback are
|
|
129
|
+
logged and swallowed.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The decorated function, with its signature preserved.
|
|
133
|
+
|
|
134
|
+
Example:
|
|
135
|
+
>>> @timing(threshold=0.5, callback=lambda r: print(r.name, r.elapsed))
|
|
136
|
+
... def slow(): ...
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def decorate(f: Callable[P, R]) -> Callable[P, R]:
|
|
140
|
+
lg = _core.get_logger(logger)
|
|
141
|
+
name = _core.qualname(f)
|
|
142
|
+
|
|
143
|
+
def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> float:
|
|
144
|
+
return _core.perf_counter()
|
|
145
|
+
|
|
146
|
+
def finish(began: float, ok: bool, value: Any) -> None:
|
|
147
|
+
elapsed = _core.perf_counter() - began
|
|
148
|
+
if lg.isEnabledFor(level):
|
|
149
|
+
if not ok:
|
|
150
|
+
_log(
|
|
151
|
+
lg,
|
|
152
|
+
level,
|
|
153
|
+
"%s raised %s after %s",
|
|
154
|
+
name,
|
|
155
|
+
type(value).__name__,
|
|
156
|
+
_format_duration(elapsed),
|
|
157
|
+
)
|
|
158
|
+
elif threshold is None or elapsed > threshold:
|
|
159
|
+
_log(lg, level, "%s took %s", name, _format_duration(elapsed))
|
|
160
|
+
_safe_callback(lg, callback, TimingRecord(name, elapsed, ok), name)
|
|
161
|
+
|
|
162
|
+
return _instrument(f, start, finish)
|
|
163
|
+
|
|
164
|
+
return decorate if func is None else decorate(func)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------------- benchmark
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class BenchmarkResult:
|
|
172
|
+
"""Statistics from `benchmark` or `benchmark_async`; all times are in seconds.
|
|
173
|
+
|
|
174
|
+
Warm-up calls are not included. ``str(result)`` gives a one-line summary.
|
|
175
|
+
|
|
176
|
+
Attributes:
|
|
177
|
+
name: Qualified name of the benchmarked function.
|
|
178
|
+
runs: Number of measured calls.
|
|
179
|
+
timings: Duration of each measured call, in call order.
|
|
180
|
+
mean: Arithmetic mean of ``timings``.
|
|
181
|
+
median: Median of ``timings``.
|
|
182
|
+
min: Fastest call.
|
|
183
|
+
max: Slowest call.
|
|
184
|
+
stdev: Sample standard deviation of ``timings`` (``0.0`` when ``runs`` is 1).
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
name: str
|
|
188
|
+
runs: int
|
|
189
|
+
timings: tuple[float, ...]
|
|
190
|
+
mean: float
|
|
191
|
+
median: float
|
|
192
|
+
min: float
|
|
193
|
+
max: float
|
|
194
|
+
stdev: float
|
|
195
|
+
|
|
196
|
+
def __str__(self) -> str:
|
|
197
|
+
return (
|
|
198
|
+
f"{self.name}: {self.runs} runs, "
|
|
199
|
+
f"mean {_format_duration(self.mean)}, median {_format_duration(self.median)}, "
|
|
200
|
+
f"min {_format_duration(self.min)}, max {_format_duration(self.max)}, "
|
|
201
|
+
f"stdev {_format_duration(self.stdev)}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _summarise(name: str, timings: list[float]) -> BenchmarkResult:
|
|
206
|
+
return BenchmarkResult(
|
|
207
|
+
name=name,
|
|
208
|
+
runs=len(timings),
|
|
209
|
+
timings=tuple(timings),
|
|
210
|
+
mean=statistics.fmean(timings),
|
|
211
|
+
median=statistics.median(timings),
|
|
212
|
+
min=min(timings),
|
|
213
|
+
max=max(timings),
|
|
214
|
+
stdev=statistics.stdev(timings) if len(timings) > 1 else 0.0,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _check_bench_args(runs: int, warmup: int) -> None:
|
|
219
|
+
if runs < 1:
|
|
220
|
+
raise ValueError(f"runs must be >= 1, got {runs}")
|
|
221
|
+
if warmup < 0:
|
|
222
|
+
raise ValueError(f"warmup must be >= 0, got {warmup}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def benchmark(
|
|
226
|
+
func: Callable[..., Any],
|
|
227
|
+
/,
|
|
228
|
+
*,
|
|
229
|
+
args: tuple[Any, ...] = (),
|
|
230
|
+
kwargs: Mapping[str, Any] | None = None,
|
|
231
|
+
runs: int = 5,
|
|
232
|
+
warmup: int = 1,
|
|
233
|
+
) -> BenchmarkResult:
|
|
234
|
+
"""Run ``func`` repeatedly and return timing statistics.
|
|
235
|
+
|
|
236
|
+
BREAKING vs v0.1: this is a plain function, not a decorator. (The v0.1 decorator
|
|
237
|
+
silently ran the function three times on every call.)
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
func: The synchronous callable to measure.
|
|
241
|
+
args: Positional arguments for each call.
|
|
242
|
+
kwargs: Keyword arguments for each call.
|
|
243
|
+
runs: Number of measured runs (>= 1).
|
|
244
|
+
warmup: Unmeasured runs executed first (>= 0).
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
A `BenchmarkResult`.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
TypeError: If ``func`` is an ``async def`` function; use `benchmark_async`.
|
|
251
|
+
ValueError: If ``runs`` or ``warmup`` is out of range.
|
|
252
|
+
|
|
253
|
+
Example:
|
|
254
|
+
>>> result = benchmark(sorted, args=([3, 1, 2],), runs=10)
|
|
255
|
+
>>> print(result) # doctest: +SKIP
|
|
256
|
+
"""
|
|
257
|
+
if _core.is_async(func):
|
|
258
|
+
raise TypeError(
|
|
259
|
+
f"{_core.qualname(func)} is a coroutine function; use benchmark_async() instead"
|
|
260
|
+
)
|
|
261
|
+
_check_bench_args(runs, warmup)
|
|
262
|
+
kw = dict(kwargs or {})
|
|
263
|
+
for _ in range(warmup):
|
|
264
|
+
func(*args, **kw)
|
|
265
|
+
timings: list[float] = []
|
|
266
|
+
for _ in range(runs):
|
|
267
|
+
began = _core.perf_counter()
|
|
268
|
+
func(*args, **kw)
|
|
269
|
+
timings.append(_core.perf_counter() - began)
|
|
270
|
+
return _summarise(_core.qualname(func), timings)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def benchmark_async(
|
|
274
|
+
func: Callable[..., Any],
|
|
275
|
+
/,
|
|
276
|
+
*,
|
|
277
|
+
args: tuple[Any, ...] = (),
|
|
278
|
+
kwargs: Mapping[str, Any] | None = None,
|
|
279
|
+
runs: int = 5,
|
|
280
|
+
warmup: int = 1,
|
|
281
|
+
) -> BenchmarkResult:
|
|
282
|
+
"""Like `benchmark`, for ``async def`` functions (each run is awaited).
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
func: The coroutine function to measure.
|
|
286
|
+
args: Positional arguments for each call.
|
|
287
|
+
kwargs: Keyword arguments for each call.
|
|
288
|
+
runs: Number of measured runs (>= 1).
|
|
289
|
+
warmup: Unmeasured runs executed first (>= 0).
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
A `BenchmarkResult`.
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
TypeError: If ``func`` is not an ``async def`` function; use `benchmark`.
|
|
296
|
+
ValueError: If ``runs`` or ``warmup`` is out of range.
|
|
297
|
+
|
|
298
|
+
Example:
|
|
299
|
+
>>> result = await benchmark_async(fetch, args=(url,), runs=3) # doctest: +SKIP
|
|
300
|
+
"""
|
|
301
|
+
if not _core.is_async(func):
|
|
302
|
+
raise TypeError(f"{_core.qualname(func)} is not a coroutine function; use benchmark()")
|
|
303
|
+
_check_bench_args(runs, warmup)
|
|
304
|
+
kw = dict(kwargs or {})
|
|
305
|
+
for _ in range(warmup):
|
|
306
|
+
await func(*args, **kw)
|
|
307
|
+
timings: list[float] = []
|
|
308
|
+
for _ in range(runs):
|
|
309
|
+
began = _core.perf_counter()
|
|
310
|
+
await func(*args, **kw)
|
|
311
|
+
timings.append(_core.perf_counter() - began)
|
|
312
|
+
return _summarise(_core.qualname(func), timings)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# --------------------------------------------------------------------- measure_memory
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass(frozen=True)
|
|
319
|
+
class MemoryRecord:
|
|
320
|
+
"""Memory used by one call, passed to ``measure_memory(callback=...)``.
|
|
321
|
+
|
|
322
|
+
Attributes:
|
|
323
|
+
name: Qualified name of the function.
|
|
324
|
+
peak_bytes: Highest traced allocation during the call, relative to its start.
|
|
325
|
+
net_bytes: Traced memory still held at the end minus at the start (can be negative).
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
name: str
|
|
329
|
+
peak_bytes: int
|
|
330
|
+
net_bytes: int
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
_trace_lock = threading.Lock()
|
|
334
|
+
_trace_users = 0
|
|
335
|
+
_trace_started_by_us = False
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _trace_acquire() -> None:
|
|
339
|
+
global _trace_users, _trace_started_by_us
|
|
340
|
+
import tracemalloc
|
|
341
|
+
|
|
342
|
+
with _trace_lock:
|
|
343
|
+
if _trace_users == 0 and not tracemalloc.is_tracing():
|
|
344
|
+
tracemalloc.start()
|
|
345
|
+
_trace_started_by_us = True
|
|
346
|
+
_trace_users += 1
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _trace_release() -> None:
|
|
350
|
+
global _trace_users, _trace_started_by_us
|
|
351
|
+
import tracemalloc
|
|
352
|
+
|
|
353
|
+
with _trace_lock:
|
|
354
|
+
_trace_users -= 1
|
|
355
|
+
if _trace_users == 0 and _trace_started_by_us:
|
|
356
|
+
tracemalloc.stop()
|
|
357
|
+
_trace_started_by_us = False
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@overload
|
|
361
|
+
def measure_memory(func: F, /) -> F: ...
|
|
362
|
+
@overload
|
|
363
|
+
def measure_memory(
|
|
364
|
+
func: None = None,
|
|
365
|
+
/,
|
|
366
|
+
*,
|
|
367
|
+
logger: LoggerLike = None,
|
|
368
|
+
level: int = logging.INFO,
|
|
369
|
+
callback: Callable[[MemoryRecord], None] | None = None,
|
|
370
|
+
) -> Callable[[F], F]: ...
|
|
371
|
+
def measure_memory(
|
|
372
|
+
func: Callable[..., Any] | None = None,
|
|
373
|
+
/,
|
|
374
|
+
*,
|
|
375
|
+
logger: LoggerLike = None,
|
|
376
|
+
level: int = logging.INFO,
|
|
377
|
+
callback: Callable[[MemoryRecord], None] | None = None,
|
|
378
|
+
) -> Any:
|
|
379
|
+
"""Measure the Python memory a call allocates, using `tracemalloc`.
|
|
380
|
+
|
|
381
|
+
Works on every OS (v0.1 used ``resource.ru_maxrss``, which is Unix-only and reports
|
|
382
|
+
process-wide peak in inconsistent units). Tracing is started if it is not already
|
|
383
|
+
running and stopped afterwards only if this decorator started it. Logs
|
|
384
|
+
``<name> peak memory 1.00 MiB (net +12.00 KiB)``.
|
|
385
|
+
|
|
386
|
+
Note:
|
|
387
|
+
``tracemalloc`` is process-global: allocations made by other threads or asyncio
|
|
388
|
+
tasks while the call runs are counted too, and the peak counter is reset at the
|
|
389
|
+
start of every measured call. Only memory allocated by Python (not by C
|
|
390
|
+
extensions that bypass its allocator) is seen, and tracing slows the program.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
func: The function, when used as a bare ``@measure_memory``.
|
|
394
|
+
logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger.
|
|
395
|
+
level: Log level.
|
|
396
|
+
callback: Called with a `MemoryRecord` for every call.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
The decorated function, with its signature preserved.
|
|
400
|
+
|
|
401
|
+
Example:
|
|
402
|
+
>>> @measure_memory
|
|
403
|
+
... def build():
|
|
404
|
+
... return [0] * 1_000_000
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
import tracemalloc
|
|
408
|
+
|
|
409
|
+
def decorate(f: Callable[P, R]) -> Callable[P, R]:
|
|
410
|
+
lg = _core.get_logger(logger)
|
|
411
|
+
name = _core.qualname(f)
|
|
412
|
+
|
|
413
|
+
def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> int:
|
|
414
|
+
_trace_acquire()
|
|
415
|
+
tracemalloc.reset_peak()
|
|
416
|
+
return tracemalloc.get_traced_memory()[0]
|
|
417
|
+
|
|
418
|
+
def finish(baseline: int, ok: bool, value: Any) -> None:
|
|
419
|
+
current, peak = tracemalloc.get_traced_memory()
|
|
420
|
+
_trace_release()
|
|
421
|
+
record = MemoryRecord(name, max(peak - baseline, 0), current - baseline)
|
|
422
|
+
lg.log(
|
|
423
|
+
level,
|
|
424
|
+
"%s peak memory %s (net %s)",
|
|
425
|
+
name,
|
|
426
|
+
_format_bytes(record.peak_bytes),
|
|
427
|
+
_format_bytes(record.net_bytes, signed=True),
|
|
428
|
+
)
|
|
429
|
+
_safe_callback(lg, callback, record, name)
|
|
430
|
+
|
|
431
|
+
return _instrument(f, start, finish)
|
|
432
|
+
|
|
433
|
+
return decorate if func is None else decorate(func)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ------------------------------------------------------------------------- profile
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _another_profiler_active() -> bool:
|
|
440
|
+
# Before 3.12 enabling a second cProfile silently replaces the first one.
|
|
441
|
+
return sys.version_info < (3, 12) and sys.getprofile() is not None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@overload
|
|
445
|
+
def profile(func: F, /) -> F: ...
|
|
446
|
+
@overload
|
|
447
|
+
def profile(
|
|
448
|
+
func: None = None,
|
|
449
|
+
/,
|
|
450
|
+
*,
|
|
451
|
+
logger: LoggerLike = None,
|
|
452
|
+
level: int = logging.INFO,
|
|
453
|
+
sort: str = "cumulative",
|
|
454
|
+
limit: int | None = 20,
|
|
455
|
+
callback: Callable[[pstats.Stats], None] | None = None,
|
|
456
|
+
) -> Callable[[F], F]: ...
|
|
457
|
+
def profile(
|
|
458
|
+
func: Callable[..., Any] | None = None,
|
|
459
|
+
/,
|
|
460
|
+
*,
|
|
461
|
+
logger: LoggerLike = None,
|
|
462
|
+
level: int = logging.INFO,
|
|
463
|
+
sort: str = "cumulative",
|
|
464
|
+
limit: int | None = 20,
|
|
465
|
+
callback: Callable[[pstats.Stats], None] | None = None,
|
|
466
|
+
) -> Any:
|
|
467
|
+
"""Profile each call with `cProfile` and log the top rows.
|
|
468
|
+
|
|
469
|
+
If another profiler is already active (Python 3.12+ raises ``ValueError`` when
|
|
470
|
+
profilers nest), a warning is logged once and the function simply runs unprofiled.
|
|
471
|
+
|
|
472
|
+
Note:
|
|
473
|
+
For ``async def`` functions the profiler is enabled for the whole awaited
|
|
474
|
+
coroutine, so work the event loop does for *other* tasks while this one is
|
|
475
|
+
suspended is included in the results.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
func: The function, when used as a bare ``@profile``.
|
|
479
|
+
logger: A ``logging.Logger``, a logger name, or ``None`` for the package logger.
|
|
480
|
+
level: Log level.
|
|
481
|
+
sort: A `pstats.SortKey` value such as ``"cumulative"`` or ``"tottime"``.
|
|
482
|
+
limit: Number of rows to log; ``None`` logs all.
|
|
483
|
+
callback: Called with the `pstats.Stats` of every profiled call.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
The decorated function, with its signature preserved.
|
|
487
|
+
|
|
488
|
+
Raises:
|
|
489
|
+
ValueError: If ``sort`` is not a valid `pstats.SortKey`.
|
|
490
|
+
|
|
491
|
+
Example:
|
|
492
|
+
>>> @profile(sort="tottime", limit=10)
|
|
493
|
+
... def work(): ...
|
|
494
|
+
"""
|
|
495
|
+
import cProfile
|
|
496
|
+
import pstats
|
|
497
|
+
|
|
498
|
+
sort_key = pstats.SortKey(sort) # validate eagerly
|
|
499
|
+
|
|
500
|
+
def decorate(f: Callable[P, R]) -> Callable[P, R]:
|
|
501
|
+
lg = _core.get_logger(logger)
|
|
502
|
+
name = _core.qualname(f)
|
|
503
|
+
warned = False
|
|
504
|
+
|
|
505
|
+
def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> cProfile.Profile | None:
|
|
506
|
+
nonlocal warned
|
|
507
|
+
profiler = cProfile.Profile()
|
|
508
|
+
try:
|
|
509
|
+
if _another_profiler_active():
|
|
510
|
+
raise ValueError("another profiler is active")
|
|
511
|
+
profiler.enable()
|
|
512
|
+
except ValueError:
|
|
513
|
+
if not warned:
|
|
514
|
+
warned = True
|
|
515
|
+
lg.warning("Another profiler is active; running %s unprofiled", name)
|
|
516
|
+
return None
|
|
517
|
+
return profiler
|
|
518
|
+
|
|
519
|
+
def finish(profiler: cProfile.Profile | None, ok: bool, value: Any) -> None:
|
|
520
|
+
if profiler is None:
|
|
521
|
+
return
|
|
522
|
+
profiler.disable()
|
|
523
|
+
stream = io.StringIO()
|
|
524
|
+
stats = pstats.Stats(profiler, stream=stream).sort_stats(sort_key)
|
|
525
|
+
if lg.isEnabledFor(level):
|
|
526
|
+
if limit is None:
|
|
527
|
+
stats.print_stats()
|
|
528
|
+
else:
|
|
529
|
+
stats.print_stats(limit)
|
|
530
|
+
lg.log(level, "Profile of %s:\n%s", name, stream.getvalue().rstrip())
|
|
531
|
+
_safe_callback(lg, callback, stats, name)
|
|
532
|
+
|
|
533
|
+
return _instrument(f, start, finish)
|
|
534
|
+
|
|
535
|
+
return decorate if func is None else decorate(func)
|