executiontimer 1.0.2__py3-none-any.whl → 1.0.3__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.
- execution_timer/__init__.py +1 -1
- execution_timer/_timer.py +139 -33
- executiontimer-1.0.3.dist-info/METADATA +117 -0
- executiontimer-1.0.3.dist-info/RECORD +7 -0
- executiontimer-1.0.2.dist-info/METADATA +0 -333
- executiontimer-1.0.2.dist-info/RECORD +0 -7
- {executiontimer-1.0.2.dist-info → executiontimer-1.0.3.dist-info}/WHEEL +0 -0
- {executiontimer-1.0.2.dist-info → executiontimer-1.0.3.dist-info}/licenses/LICENSE +0 -0
execution_timer/__init__.py
CHANGED
execution_timer/_timer.py
CHANGED
|
@@ -20,39 +20,49 @@ from contextvars import ContextVar
|
|
|
20
20
|
from itertools import count
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from types import TracebackType
|
|
23
|
-
from typing import Final,
|
|
23
|
+
from typing import Final, ParamSpec, TypeAlias, TypedDict, TypeVar, cast
|
|
24
24
|
|
|
25
25
|
P = ParamSpec("P")
|
|
26
26
|
R = TypeVar("R")
|
|
27
27
|
T = TypeVar("T")
|
|
28
28
|
|
|
29
29
|
DEFAULT_CATEGORY: Final = "default"
|
|
30
|
+
"""The category of sections created without one."""
|
|
30
31
|
|
|
31
32
|
_LOGGER: Final = logging.getLogger(__name__)
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
class TimingReport(TypedDict):
|
|
35
|
-
"""Timing entry for one section
|
|
36
|
+
"""Timing entry for one section, as returned by :func:`get_execution_timings`."""
|
|
36
37
|
|
|
37
38
|
time: float
|
|
39
|
+
"""Accumulated elapsed seconds."""
|
|
38
40
|
category: str
|
|
41
|
+
"""The section's category."""
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
class SectionRecord(TypedDict):
|
|
42
|
-
"""One section in the JSON export
|
|
45
|
+
"""One section in the JSON export, see :class:`TimingsPayload`."""
|
|
43
46
|
|
|
44
47
|
name: str
|
|
48
|
+
"""The section's own name, the last element of :attr:`path`."""
|
|
45
49
|
path: list[str]
|
|
50
|
+
"""Names from the top-level section down to this one."""
|
|
46
51
|
time: float
|
|
52
|
+
"""Accumulated elapsed seconds, rounded to microseconds."""
|
|
47
53
|
category: str
|
|
54
|
+
"""The section's category."""
|
|
48
55
|
|
|
49
56
|
|
|
50
57
|
class TimingsPayload(TypedDict):
|
|
51
|
-
"""Top-level JSON export
|
|
58
|
+
"""Top-level object of the JSON export from :func:`get_execution_times_json`."""
|
|
52
59
|
|
|
53
60
|
total_time: float
|
|
61
|
+
"""Seconds across all top-level sections, as returned by :func:`get_total_time`."""
|
|
54
62
|
total_category_time: dict[str, float]
|
|
63
|
+
"""Seconds per category, counting only the top-most section of each category."""
|
|
55
64
|
sections: list[SectionRecord]
|
|
65
|
+
"""Every section, ordered depth-first so children follow their parent."""
|
|
56
66
|
|
|
57
67
|
|
|
58
68
|
class _TimesDict(TypedDict):
|
|
@@ -61,14 +71,10 @@ class _TimesDict(TypedDict):
|
|
|
61
71
|
category: str
|
|
62
72
|
|
|
63
73
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
category: str
|
|
69
|
-
start_time: float
|
|
70
|
-
entry: _TimesDict
|
|
71
|
-
parent: _Frame | None
|
|
74
|
+
# Per-invocation state, with a cached path and an immutable parent link:
|
|
75
|
+
# ``(path, category, start_time, entry, parent)``. A plain tuple rather than a NamedTuple,
|
|
76
|
+
# whose constructor runs in Python and costs more than the rest of section entry combined.
|
|
77
|
+
_Frame: TypeAlias = "tuple[tuple[str, ...], str, float, _TimesDict, _Frame | None]"
|
|
72
78
|
|
|
73
79
|
|
|
74
80
|
_ACTIVE_CONTEXT: ContextVar[_Frame | None] = ContextVar("execution_timer_context", default=None)
|
|
@@ -133,10 +139,10 @@ class _ExecutionTimer:
|
|
|
133
139
|
def start_timer(self, name: str, category: str) -> None:
|
|
134
140
|
"""Start timing a section under the given name within the active context."""
|
|
135
141
|
parent = _ACTIVE_CONTEXT.get()
|
|
136
|
-
full_name = (*parent
|
|
142
|
+
full_name = (*parent[0], name) if parent is not None else (name,)
|
|
137
143
|
with self._lock:
|
|
138
|
-
if parent is not None and (parent
|
|
139
|
-
msg = f"Category '{category}' is not allowed inside category '{parent
|
|
144
|
+
if parent is not None and (parent[1], category) in self.forbidden_nesting:
|
|
145
|
+
msg = f"Category '{category}' is not allowed inside category '{parent[1]}'."
|
|
140
146
|
raise ValueError(msg)
|
|
141
147
|
sequence = next(self._sequence)
|
|
142
148
|
entry: _TimesDict | None = self.timings.get(full_name)
|
|
@@ -146,7 +152,7 @@ class _ExecutionTimer:
|
|
|
146
152
|
else:
|
|
147
153
|
entry["sequence"] = sequence
|
|
148
154
|
entry["category"] = category
|
|
149
|
-
_ = _ACTIVE_CONTEXT.set(
|
|
155
|
+
_ = _ACTIVE_CONTEXT.set((full_name, category, time.perf_counter(), entry, parent))
|
|
150
156
|
|
|
151
157
|
def stop_timer(self, name: str) -> None:
|
|
152
158
|
"""Stop timing a section and accumulate its elapsed time.
|
|
@@ -161,19 +167,20 @@ class _ExecutionTimer:
|
|
|
161
167
|
end_time = time.perf_counter()
|
|
162
168
|
active = _ACTIVE_CONTEXT.get()
|
|
163
169
|
frame = active
|
|
164
|
-
while frame is not None and frame
|
|
165
|
-
frame = frame
|
|
170
|
+
while frame is not None and frame[0][-1] != name:
|
|
171
|
+
frame = frame[4]
|
|
166
172
|
if frame is None:
|
|
167
173
|
return
|
|
174
|
+
_, _, start_time, entry, parent = frame
|
|
168
175
|
with self._lock:
|
|
169
176
|
# A clear detaches this entry from the registry. Updating the detached object
|
|
170
177
|
# cannot resurrect an old sample or add it to a replacement at the same path.
|
|
171
|
-
|
|
172
|
-
_ = _ACTIVE_CONTEXT.set(
|
|
178
|
+
entry["elapsed_time"] += end_time - start_time
|
|
179
|
+
_ = _ACTIVE_CONTEXT.set(parent)
|
|
173
180
|
if frame is not active and active is not None:
|
|
174
181
|
# Warn only once the state is consistent: warnings configured as errors raise here.
|
|
175
182
|
msg = (
|
|
176
|
-
f"Section '{name}' exited while '{active
|
|
183
|
+
f"Section '{name}' exited while '{active[0][-1]}' was still active; discarding the "
|
|
177
184
|
"unfinished inner sections. Close sections before a generator yields."
|
|
178
185
|
)
|
|
179
186
|
warnings.warn(msg, RuntimeWarning, stacklevel=3)
|
|
@@ -258,7 +265,32 @@ def _has_ancestor_with_category(
|
|
|
258
265
|
|
|
259
266
|
|
|
260
267
|
class TimerContext:
|
|
261
|
-
"""Context manager and decorator for timing a named section of code.
|
|
268
|
+
"""Context manager and decorator for timing a named section of code.
|
|
269
|
+
|
|
270
|
+
Sections entered inside another section are recorded beneath it, so nesting ``with``
|
|
271
|
+
blocks or decorated calls builds the hierarchy shown in reports. Repeated entries of the
|
|
272
|
+
same section accumulate their durations. A context can be reused, re-entered while it is
|
|
273
|
+
active, and shared between threads and asyncio tasks.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
name: The section's name within its parent.
|
|
277
|
+
category: Any string used to group sections, for example ``"io"`` or ``"gpu"``.
|
|
278
|
+
counter: Records the section as ``name[counter]``, typically a loop index. Reports
|
|
279
|
+
merge these variants unless you pass ``flatten=False``.
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
ValueError: On entry, if a rule from :func:`register_forbidden_nesting` forbids this
|
|
283
|
+
category directly inside the enclosing section's category.
|
|
284
|
+
|
|
285
|
+
Example:
|
|
286
|
+
.. code-block:: python
|
|
287
|
+
|
|
288
|
+
with TimerContext("load", category="io"):
|
|
289
|
+
data = load()
|
|
290
|
+
|
|
291
|
+
@TimerContext("solve")
|
|
292
|
+
def solve(data): ...
|
|
293
|
+
"""
|
|
262
294
|
|
|
263
295
|
def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
|
|
264
296
|
self.name: str = _build_name_with_counter(name, counter)
|
|
@@ -279,8 +311,11 @@ class TimerContext:
|
|
|
279
311
|
|
|
280
312
|
Coroutine functions are wrapped so the timing spans the entire ``await``, not just
|
|
281
313
|
creation of the coroutine object. So is a coroutine returned by a plain function,
|
|
282
|
-
typically another decorator stacked on an ``async def``.
|
|
283
|
-
|
|
314
|
+
typically another decorator stacked on an ``async def``.
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
TypeError: If ``func`` is a generator or async generator function. A wrapper
|
|
318
|
+
would time only creation of the generator object, not its iteration.
|
|
284
319
|
"""
|
|
285
320
|
if inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func):
|
|
286
321
|
msg = (
|
|
@@ -341,12 +376,30 @@ def _basic_name_without_counter(name: str) -> str:
|
|
|
341
376
|
|
|
342
377
|
|
|
343
378
|
def get_execution_times_report(*, flatten: bool = True) -> str:
|
|
344
|
-
"""Get a formatted report of all recorded sections
|
|
379
|
+
"""Get a formatted, indented report of all recorded sections.
|
|
380
|
+
|
|
381
|
+
Each line shows a section's accumulated seconds and its share of the total time.
|
|
382
|
+
Indentation reflects nesting.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
flatten: Merge ``counter`` variants such as ``step[0]`` and ``step[1]`` into ``step``.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
The report, or ``""`` if nothing has been recorded.
|
|
389
|
+
"""
|
|
345
390
|
return _TIMER.report_timings(flatten=flatten)
|
|
346
391
|
|
|
347
392
|
|
|
348
393
|
def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
|
|
349
|
-
"""Log the
|
|
394
|
+
"""Log the report from :func:`get_execution_times_report` at ``INFO`` level.
|
|
395
|
+
|
|
396
|
+
Logs a warning instead if nothing has been recorded. Does nothing, and skips building
|
|
397
|
+
the report, if the logger has ``INFO`` disabled.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
flatten: Merge ``counter`` variants of a section.
|
|
401
|
+
logger: The logger to use. Defaults to the ``execution_timer._timer`` logger.
|
|
402
|
+
"""
|
|
350
403
|
target = logger if logger is not None else _LOGGER
|
|
351
404
|
if target.isEnabledFor(logging.INFO):
|
|
352
405
|
report = get_execution_times_report(flatten=flatten)
|
|
@@ -358,7 +411,15 @@ def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None =
|
|
|
358
411
|
|
|
359
412
|
|
|
360
413
|
def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
|
|
361
|
-
"""Get elapsed seconds and category for every recorded section
|
|
414
|
+
"""Get elapsed seconds and category for every recorded section.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
flatten: Merge ``counter`` variants of a section.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
A new dictionary keyed by section path, such as ``("solve", "step")``. Changing it
|
|
421
|
+
does not affect the recorded timings.
|
|
422
|
+
"""
|
|
362
423
|
return _TIMER.get_execution_timings(flatten=flatten)
|
|
363
424
|
|
|
364
425
|
|
|
@@ -388,34 +449,79 @@ def _build_payload(*, flatten: bool = True) -> TimingsPayload:
|
|
|
388
449
|
|
|
389
450
|
|
|
390
451
|
def get_execution_times_json(*, flatten: bool = True, indent: int | None = 2) -> str:
|
|
391
|
-
"""Get all timings as a JSON
|
|
452
|
+
"""Get all timings as a JSON document shaped like :class:`TimingsPayload`.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
flatten: Merge ``counter`` variants of a section.
|
|
456
|
+
indent: Passed to :func:`json.dumps`; ``None`` gives the most compact output.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
The JSON text.
|
|
460
|
+
"""
|
|
392
461
|
return json.dumps(_build_payload(flatten=flatten), indent=indent)
|
|
393
462
|
|
|
394
463
|
|
|
395
464
|
def save_execution_timings_json(path: str | Path, *, flatten: bool = True, indent: int | None = 2) -> Path:
|
|
396
|
-
"""Write
|
|
465
|
+
"""Write the JSON from :func:`get_execution_times_json` to a UTF-8 file.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
path: The file to write, replacing it if it exists. Its directory must exist.
|
|
469
|
+
flatten: Merge ``counter`` variants of a section.
|
|
470
|
+
indent: Passed to :func:`json.dumps`.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
The path written to.
|
|
474
|
+
"""
|
|
397
475
|
out = Path(path)
|
|
398
476
|
_ = out.write_text(get_execution_times_json(flatten=flatten, indent=indent) + "\n", encoding="utf-8")
|
|
399
477
|
return out
|
|
400
478
|
|
|
401
479
|
|
|
402
480
|
def get_total_time() -> float:
|
|
403
|
-
"""Get total elapsed seconds across all top-level sections.
|
|
481
|
+
"""Get total elapsed seconds across all top-level sections.
|
|
482
|
+
|
|
483
|
+
Nested sections are part of their parent's time, so they are not added again.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
The total, or ``0.0`` if nothing has been recorded.
|
|
487
|
+
"""
|
|
404
488
|
return _TIMER.compute_total_time()
|
|
405
489
|
|
|
406
490
|
|
|
407
491
|
def get_total_category_time(category: str) -> float:
|
|
408
|
-
"""Get total elapsed seconds in a category
|
|
492
|
+
"""Get total elapsed seconds in a category.
|
|
493
|
+
|
|
494
|
+
Only the top-most section of the category counts: a section nested inside another of
|
|
495
|
+
the same category is part of that section's time and is not added again.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
category: The category to total.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
The total, or ``0.0`` for a category with no sections.
|
|
502
|
+
"""
|
|
409
503
|
return _TIMER.compute_total_category_time(category)
|
|
410
504
|
|
|
411
505
|
|
|
412
506
|
def clear_execution_timings() -> None:
|
|
413
|
-
"""
|
|
507
|
+
"""Discard all recorded timings.
|
|
508
|
+
|
|
509
|
+
A section that is active during the clear is not recorded when it exits. Sections
|
|
510
|
+
started inside it afterwards are recorded as top-level sections. Nesting rules are kept;
|
|
511
|
+
see :func:`clear_forbidden_nesting`.
|
|
512
|
+
"""
|
|
414
513
|
_TIMER.clear()
|
|
415
514
|
|
|
416
515
|
|
|
417
516
|
def register_forbidden_nesting(outer: str, inner: str) -> None:
|
|
418
|
-
"""Forbid
|
|
517
|
+
"""Forbid sections of category ``inner`` directly inside sections of category ``outer``.
|
|
518
|
+
|
|
519
|
+
Entering such a section raises :class:`ValueError`. Only the direct parent is checked.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
outer: The enclosing section's category.
|
|
523
|
+
inner: The category that may not appear directly inside it.
|
|
524
|
+
"""
|
|
419
525
|
_TIMER.register_forbidden_nesting(outer, inner)
|
|
420
526
|
|
|
421
527
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: executiontimer
|
|
3
|
+
Version: 1.0.3
|
|
4
|
+
Summary: Hierarchical execution timing with user-defined categories.
|
|
5
|
+
Project-URL: Homepage, https://github.com/seba2390/ExecutionTimer
|
|
6
|
+
Project-URL: Documentation, https://seba2390.github.io/ExecutionTimer/
|
|
7
|
+
Project-URL: Repository, https://github.com/seba2390/ExecutionTimer
|
|
8
|
+
Project-URL: Issues, https://github.com/seba2390/ExecutionTimer/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md
|
|
10
|
+
Author: Sebastian Yde Madsen
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: benchmark,context-manager,decorator,execution-time,instrumentation,performance,profiling,timing
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
24
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
25
|
+
Classifier: Topic :: Software Development
|
|
26
|
+
Classifier: Topic :: Software Development :: Testing
|
|
27
|
+
Classifier: Topic :: System :: Benchmark
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<picture>
|
|
34
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-dark.svg">
|
|
35
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-light.svg">
|
|
36
|
+
<img src="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-light.svg" alt="executiontimer" width="520">
|
|
37
|
+
</picture>
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
<p align="center">
|
|
41
|
+
<a href="https://pypi.org/project/executiontimer/"><img src="https://img.shields.io/pypi/v/executiontimer?color=blue" alt="PyPI version"></a>
|
|
42
|
+
<a href="https://pypi.org/project/executiontimer/"><img src="https://img.shields.io/pypi/pyversions/executiontimer" alt="Python versions"></a>
|
|
43
|
+
<a href="https://github.com/seba2390/ExecutionTimer/actions/workflows/ci.yml"><img src="https://github.com/seba2390/ExecutionTimer/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
44
|
+
<a href="https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
|
|
45
|
+
<a href="https://seba2390.github.io/ExecutionTimer/"><img src="https://img.shields.io/badge/docs-GitHub%20Pages-4F46E5" alt="Documentation"></a>
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
Time named sections of your code with a `with` block or a decorator. Sections nested inside
|
|
49
|
+
each other form a tree, so you see where the time actually went, not just one number at the
|
|
50
|
+
end.
|
|
51
|
+
|
|
52
|
+
Zero dependencies. Fully typed. Works with threads and `asyncio`.
|
|
53
|
+
|
|
54
|
+
## Documentation
|
|
55
|
+
[seba2390.github.io/ExecutionTimer](https://seba2390.github.io/ExecutionTimer/)
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install executiontimer
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires Python 3.11+. The install name is `executiontimer`; the import name is
|
|
64
|
+
`execution_timer`.
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import time
|
|
70
|
+
|
|
71
|
+
from execution_timer import TimerContext, get_execution_times_report
|
|
72
|
+
|
|
73
|
+
with TimerContext("load_data"):
|
|
74
|
+
time.sleep(0.12)
|
|
75
|
+
|
|
76
|
+
with TimerContext("solve"):
|
|
77
|
+
for i in range(3):
|
|
78
|
+
with TimerContext("step", category="gpu", counter=i):
|
|
79
|
+
time.sleep(0.05)
|
|
80
|
+
with TimerContext("postprocess", category="cpu"):
|
|
81
|
+
time.sleep(0.03)
|
|
82
|
+
|
|
83
|
+
print(get_execution_times_report())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
Total time: 0.3156 s.
|
|
88
|
+
|
|
89
|
+
load_data: 0.1219 s (38.62%)
|
|
90
|
+
solve: 0.1937 s (61.38%)
|
|
91
|
+
.. step: 0.1585 s (50.24%)
|
|
92
|
+
.. postprocess: 0.0350 s (11.10%)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`TimerContext` also works as a decorator, including on `async def` functions:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
@TimerContext("preprocess")
|
|
99
|
+
def preprocess(rows: list[str]) -> list[str]:
|
|
100
|
+
return [row.strip() for row in rows]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Learn more
|
|
104
|
+
|
|
105
|
+
- [Getting started](https://seba2390.github.io/ExecutionTimer/getting-started.html)
|
|
106
|
+
- User guide: [timing code](https://seba2390.github.io/ExecutionTimer/guide/timing-code.html),
|
|
107
|
+
[counters](https://seba2390.github.io/ExecutionTimer/guide/counters.html),
|
|
108
|
+
[categories](https://seba2390.github.io/ExecutionTimer/guide/categories.html),
|
|
109
|
+
[reports and JSON export](https://seba2390.github.io/ExecutionTimer/guide/reports.html),
|
|
110
|
+
[threads, asyncio and generators](https://seba2390.github.io/ExecutionTimer/guide/concurrency.html)
|
|
111
|
+
- [API reference](https://seba2390.github.io/ExecutionTimer/api.html)
|
|
112
|
+
- [Changelog](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) and
|
|
113
|
+
[contributing guide](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md)
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
execution_timer/__init__.py,sha256=chCRC3XhPx8siDmgixnvyOGEfS1-6LOQOKv7e6s6hLs,959
|
|
2
|
+
execution_timer/_timer.py,sha256=ld91tKWPwAm5ewK35hBljdqfMGkgf7LxeDL2l9GlWwg,21372
|
|
3
|
+
execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
executiontimer-1.0.3.dist-info/METADATA,sha256=Lh0LaMQBbmoTkGM4k_x2JKBe6Q95sSW-esmDjOTIfgg,4957
|
|
5
|
+
executiontimer-1.0.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
executiontimer-1.0.3.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
|
|
7
|
+
executiontimer-1.0.3.dist-info/RECORD,,
|
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: executiontimer
|
|
3
|
-
Version: 1.0.2
|
|
4
|
-
Summary: Hierarchical execution timing with user-defined categories.
|
|
5
|
-
Project-URL: Homepage, https://github.com/seba2390/ExecutionTimer
|
|
6
|
-
Project-URL: Documentation, https://github.com/seba2390/ExecutionTimer#readme
|
|
7
|
-
Project-URL: Repository, https://github.com/seba2390/ExecutionTimer
|
|
8
|
-
Project-URL: Issues, https://github.com/seba2390/ExecutionTimer/issues
|
|
9
|
-
Project-URL: Changelog, https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md
|
|
10
|
-
Author: Sebastian Yde Madsen
|
|
11
|
-
License-Expression: MIT
|
|
12
|
-
License-File: LICENSE
|
|
13
|
-
Keywords: benchmark,context-manager,decorator,execution-time,instrumentation,performance,profiling,timing
|
|
14
|
-
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
-
Classifier: Intended Audience :: Developers
|
|
16
|
-
Classifier: Intended Audience :: Science/Research
|
|
17
|
-
Classifier: Operating System :: OS Independent
|
|
18
|
-
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
-
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
24
|
-
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
25
|
-
Classifier: Topic :: Software Development
|
|
26
|
-
Classifier: Topic :: Software Development :: Testing
|
|
27
|
-
Classifier: Topic :: System :: Benchmark
|
|
28
|
-
Classifier: Typing :: Typed
|
|
29
|
-
Requires-Python: >=3.11
|
|
30
|
-
Description-Content-Type: text/markdown
|
|
31
|
-
|
|
32
|
-
<p align="center">
|
|
33
|
-
<picture>
|
|
34
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-dark.svg">
|
|
35
|
-
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-light.svg">
|
|
36
|
-
<img src="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-light.svg" alt="executiontimer" width="520">
|
|
37
|
-
</picture>
|
|
38
|
-
</p>
|
|
39
|
-
|
|
40
|
-
<p align="center">
|
|
41
|
-
<a href="https://pypi.org/project/executiontimer/"><img src="https://img.shields.io/pypi/v/executiontimer?color=blue" alt="PyPI version"></a>
|
|
42
|
-
<a href="https://pypi.org/project/executiontimer/"><img src="https://img.shields.io/pypi/pyversions/executiontimer" alt="Python versions"></a>
|
|
43
|
-
<a href="https://github.com/seba2390/ExecutionTimer/actions/workflows/ci.yml"><img src="https://github.com/seba2390/ExecutionTimer/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
44
|
-
<a href="https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
|
|
45
|
-
<img src="https://img.shields.io/badge/types-py.typed-blue" alt="Typed">
|
|
46
|
-
</p>
|
|
47
|
-
|
|
48
|
-
Time named sections of your code with a `with` block or a decorator. Nested sections
|
|
49
|
-
automatically form a hierarchy, so you get a breakdown of where time actually went — not
|
|
50
|
-
just a single number.
|
|
51
|
-
|
|
52
|
-
Zero dependencies. Fully type annotated. Works with threads and `asyncio`.
|
|
53
|
-
|
|
54
|
-
## Features
|
|
55
|
-
|
|
56
|
-
- ⏱️ **One primitive** — `TimerContext` is both a context manager and a decorator
|
|
57
|
-
- 🌳 **Automatic hierarchy** — nesting `with` blocks nests the report, no wiring required
|
|
58
|
-
- 🏷️ **User-defined categories** — tag sections with any string (`"gpu"`, `"io"`, `"db"`) and get per-category totals
|
|
59
|
-
- ⚡ **Native async** — decorating an `async def` times the whole `await`, not the coroutine object
|
|
60
|
-
- 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task,
|
|
61
|
-
including on free-threaded Python builds
|
|
62
|
-
- 🔢 **Loop counters** — time each iteration separately, then merge them back together
|
|
63
|
-
- 📤 **JSON export** — structured output for dashboards, CI, or an LLM
|
|
64
|
-
- 🚫 **Nesting rules** — optionally forbid one category inside another to catch mistakes early
|
|
65
|
-
- 📦 **Zero dependencies**
|
|
66
|
-
|
|
67
|
-
## Installation
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
pip install executiontimer
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
uv add executiontimer
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Requires Python 3.11+.
|
|
78
|
-
|
|
79
|
-
> **Note** — the install name is `executiontimer`, the import name is `execution_timer`:
|
|
80
|
-
>
|
|
81
|
-
> ```python
|
|
82
|
-
> from execution_timer import TimerContext
|
|
83
|
-
> ```
|
|
84
|
-
|
|
85
|
-
## Quick start
|
|
86
|
-
|
|
87
|
-
```python
|
|
88
|
-
import time
|
|
89
|
-
|
|
90
|
-
from execution_timer import TimerContext, get_execution_times_report
|
|
91
|
-
|
|
92
|
-
with TimerContext("load_data"):
|
|
93
|
-
time.sleep(0.12)
|
|
94
|
-
|
|
95
|
-
with TimerContext("solve"):
|
|
96
|
-
for i in range(3):
|
|
97
|
-
with TimerContext("step", category="gpu", counter=i):
|
|
98
|
-
time.sleep(0.05)
|
|
99
|
-
with TimerContext("postprocess", category="cpu"):
|
|
100
|
-
time.sleep(0.03)
|
|
101
|
-
|
|
102
|
-
print(get_execution_times_report())
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
```text
|
|
106
|
-
Total time: 0.3156 s.
|
|
107
|
-
|
|
108
|
-
load_data: 0.1219 s (38.62%)
|
|
109
|
-
solve: 0.1937 s (61.38%)
|
|
110
|
-
.. step: 0.1585 s (50.24%)
|
|
111
|
-
.. postprocess: 0.0350 s (11.10%)
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
Indentation reflects nesting. Percentages are relative to the total of all top-level
|
|
115
|
-
sections, so nested entries show their share of the whole run.
|
|
116
|
-
|
|
117
|
-
## Usage
|
|
118
|
-
|
|
119
|
-
### As a decorator
|
|
120
|
-
|
|
121
|
-
```python
|
|
122
|
-
from execution_timer import TimerContext
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
@TimerContext("preprocess")
|
|
126
|
-
def preprocess(rows: list[str]) -> list[str]:
|
|
127
|
-
return [row.strip() for row in rows]
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
Coroutine functions are supported natively — the timing spans the entire `await`:
|
|
131
|
-
|
|
132
|
-
```python
|
|
133
|
-
@TimerContext("fetch", category="io")
|
|
134
|
-
async def fetch(url: str) -> bytes: ...
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
This also holds when another decorator sits between `TimerContext` and the `async def`
|
|
138
|
-
and returns its coroutine: the section covers both the call and the `await`.
|
|
139
|
-
|
|
140
|
-
Generator functions (including `async` generators) cannot be decorated and raise a
|
|
141
|
-
`TypeError`: the decorator would time only the creation of the generator object, not its
|
|
142
|
-
iteration. Time the loop that consumes the generator with a `with` block instead.
|
|
143
|
-
|
|
144
|
-
### Counters
|
|
145
|
-
|
|
146
|
-
Pass `counter=i` to time loop iterations separately. The report merges them by default
|
|
147
|
-
(`flatten=True`) and keeps them apart when you ask for it:
|
|
148
|
-
|
|
149
|
-
```python
|
|
150
|
-
for i in range(3):
|
|
151
|
-
with TimerContext("step", counter=i):
|
|
152
|
-
...
|
|
153
|
-
|
|
154
|
-
get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
|
|
155
|
-
get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
Flattening removes the final integer suffix (including negative counters). Other
|
|
159
|
-
bracketed names such as `array[index]` are preserved. Flattening cannot tell a counter
|
|
160
|
-
from a name you wrote yourself, so sections named `"row[1]"` and `"row[2]"` are also merged
|
|
161
|
-
into `row`; use `flatten=False` to keep them apart. If merged entries have different
|
|
162
|
-
categories, the category from the most recently entered section is used.
|
|
163
|
-
|
|
164
|
-
Each counter value is stored as its own section until `clear_execution_timings()` is
|
|
165
|
-
called, so a long-running process that times an unbounded loop with `counter=` keeps
|
|
166
|
-
growing the registry. Clear it periodically, or drop `counter=` to accumulate into one
|
|
167
|
-
section.
|
|
168
|
-
|
|
169
|
-
### Categories
|
|
170
|
-
|
|
171
|
-
Categories are plain strings — use whatever fits your domain:
|
|
172
|
-
|
|
173
|
-
```python
|
|
174
|
-
from execution_timer import get_total_category_time
|
|
175
|
-
|
|
176
|
-
with TimerContext("matmul", category="gpu"):
|
|
177
|
-
...
|
|
178
|
-
|
|
179
|
-
get_total_category_time("gpu")
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
`get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
|
|
183
|
-
section nested inside another `gpu` section is not double-counted.
|
|
184
|
-
|
|
185
|
-
Repeated calls to the same path accumulate time. If its category changes, the latest
|
|
186
|
-
category applies to that path's entire accumulated time. Use consistent categories per
|
|
187
|
-
path when you need separate category totals.
|
|
188
|
-
|
|
189
|
-
You can also forbid a category from appearing inside another, which raises a `ValueError`
|
|
190
|
-
as soon as the invalid nesting happens:
|
|
191
|
-
|
|
192
|
-
```python
|
|
193
|
-
from execution_timer import register_forbidden_nesting
|
|
194
|
-
|
|
195
|
-
register_forbidden_nesting(outer="gpu", inner="cpu")
|
|
196
|
-
|
|
197
|
-
with TimerContext("kernel", category="gpu"):
|
|
198
|
-
with TimerContext("reduce", category="cpu"): # ValueError
|
|
199
|
-
...
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
### JSON export
|
|
203
|
-
|
|
204
|
-
`get_execution_times_json` and `save_execution_timings_json` emit a structured snapshot —
|
|
205
|
-
convenient for dashboards, CI artifacts, or handing to a language model:
|
|
206
|
-
|
|
207
|
-
```python
|
|
208
|
-
from execution_timer import save_execution_timings_json
|
|
209
|
-
|
|
210
|
-
save_execution_timings_json("timings.json")
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
```json
|
|
214
|
-
{
|
|
215
|
-
"total_time": 0.31556,
|
|
216
|
-
"total_category_time": { "cpu": 0.035024, "default": 0.31556, "gpu": 0.158528 },
|
|
217
|
-
"sections": [
|
|
218
|
-
{ "name": "load_data", "path": ["load_data"], "time": 0.121869, "category": "default" },
|
|
219
|
-
{ "name": "solve", "path": ["solve"], "time": 0.193691, "category": "default" },
|
|
220
|
-
{ "name": "step", "path": ["solve", "step"], "time": 0.158528, "category": "gpu" },
|
|
221
|
-
{ "name": "postprocess", "path": ["solve", "postprocess"], "time": 0.035024, "category": "cpu" }
|
|
222
|
-
]
|
|
223
|
-
}
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
Sections and totals come from one snapshot. Category totals use the original paths,
|
|
227
|
-
even when flattening merges sections with different categories.
|
|
228
|
-
|
|
229
|
-
### Concurrency
|
|
230
|
-
|
|
231
|
-
The recorded timings live in one process-wide registry guarded by a lock. The *active
|
|
232
|
-
context stack* is stored in a `ContextVar`, so it is isolated per thread and per asyncio
|
|
233
|
-
task: concurrently recorded sections nest independently and merge into a single report.
|
|
234
|
-
|
|
235
|
-
```python
|
|
236
|
-
async def worker(n: int) -> None:
|
|
237
|
-
with TimerContext(f"task{n}"):
|
|
238
|
-
await fetch(...) # recorded as ("task{n}", "fetch")
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
await asyncio.gather(worker(0), worker(1))
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
Overlapping calls to the same section path are supported: each call keeps its own start
|
|
245
|
-
time, and their durations are added together. These totals measure accumulated elapsed
|
|
246
|
-
time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
|
|
247
|
-
concurrent calls separately.
|
|
248
|
-
|
|
249
|
-
New asyncio tasks inherit the timing context in which they are created. Their sections
|
|
250
|
-
nest under that parent; changes to each task's active stack remain independent. Await
|
|
251
|
-
child tasks inside the parent section if you want the parent duration to include them.
|
|
252
|
-
|
|
253
|
-
New threads do *not* inherit the timing context, so sections recorded in a thread
|
|
254
|
-
appear at the top level of the report, and their time is added to the total alongside
|
|
255
|
-
the section that started the thread. To nest thread work under the current section, run
|
|
256
|
-
it with `contextvars.copy_context().run(...)` or `asyncio.to_thread(...)`. Either way,
|
|
257
|
-
concurrent threads accumulate overlapping time. (Free-threaded builds of Python 3.14 make
|
|
258
|
-
threads inherit the context by default.)
|
|
259
|
-
|
|
260
|
-
Generators run in their caller's context. A `with TimerContext(...)` block that stays open
|
|
261
|
-
across a `yield` therefore also contains whatever the caller times while the generator is
|
|
262
|
-
paused, and its duration includes that paused time. Close sections before yielding, or
|
|
263
|
-
time the loop that consumes the generator instead.
|
|
264
|
-
|
|
265
|
-
If the caller's section exits while a paused generator's section is still open, the
|
|
266
|
-
timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
|
|
267
|
-
discards the generator's unfinished one, so later sections nest correctly. Closing that
|
|
268
|
-
generator afterwards does nothing. If warnings are configured as errors, the warning is
|
|
269
|
-
raised only after that cleanup, so the timings and nesting stay consistent.
|
|
270
|
-
|
|
271
|
-
### Reusing contexts and clearing timings
|
|
272
|
-
|
|
273
|
-
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
274
|
-
For a tight loop, reuse a context to avoid constructing one on every iteration:
|
|
275
|
-
|
|
276
|
-
```python
|
|
277
|
-
step_timer = TimerContext("step")
|
|
278
|
-
for item in items:
|
|
279
|
-
with step_timer:
|
|
280
|
-
process(item)
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
284
|
-
samples from sections that were already active, without disturbing their nesting stack.
|
|
285
|
-
Sections started after the clear are recorded normally; if their parent was cleared, they
|
|
286
|
-
are reported as top-level sections and count toward the total. Reports include completed calls;
|
|
287
|
-
an active section's current duration is added only when it exits.
|
|
288
|
-
|
|
289
|
-
### Measuring overhead
|
|
290
|
-
|
|
291
|
-
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
292
|
-
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
293
|
-
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
294
|
-
|
|
295
|
-
## API
|
|
296
|
-
|
|
297
|
-
| Function | Description |
|
|
298
|
-
| --- | --- |
|
|
299
|
-
| `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
|
|
300
|
-
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
301
|
-
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty); a no-op if `INFO` is disabled. |
|
|
302
|
-
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
303
|
-
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
304
|
-
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
305
|
-
| `get_total_time()` | Total seconds across all top-level sections. |
|
|
306
|
-
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
307
|
-
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
308
|
-
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
309
|
-
| `clear_forbidden_nesting()` | Remove all nesting rules. |
|
|
310
|
-
|
|
311
|
-
`flatten=True` merges `counter` variants of a section back together; `flatten=False`
|
|
312
|
-
keeps each `name[i]` separate.
|
|
313
|
-
|
|
314
|
-
Exported types: `TimingReport`, `SectionRecord`, `TimingsPayload`, and `DEFAULT_CATEGORY`.
|
|
315
|
-
The package ships a `py.typed` marker, so type checkers use the inline annotations.
|
|
316
|
-
|
|
317
|
-
## Development
|
|
318
|
-
|
|
319
|
-
Requires [uv](https://docs.astral.sh/uv/).
|
|
320
|
-
|
|
321
|
-
```bash
|
|
322
|
-
uv sync
|
|
323
|
-
uv run pytest --cov
|
|
324
|
-
uv run ruff check --fix && uv run ruff format
|
|
325
|
-
uv run basedpyright
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
|
|
329
|
-
[CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
|
|
330
|
-
|
|
331
|
-
## License
|
|
332
|
-
|
|
333
|
-
MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
execution_timer/__init__.py,sha256=WBDnG2RxhKZmuhb5U4wgVV4WNgvZehegtfBuSh7AA6I,959
|
|
2
|
-
execution_timer/_timer.py,sha256=ermOhB2xWM4acsGgkHuN3MyeA9uqpa9FgNuoAABdGjY,17439
|
|
3
|
-
execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
executiontimer-1.0.2.dist-info/METADATA,sha256=7hk9S89FJ0b9H-O2IAWak-c_ef55RS1Vq1pJPh4gCGY,13900
|
|
5
|
-
executiontimer-1.0.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
-
executiontimer-1.0.2.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
|
|
7
|
-
executiontimer-1.0.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|