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