executiontimer 1.0.2__py3-none-any.whl → 1.1.0__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.
@@ -18,7 +18,7 @@ from execution_timer._timer import (
18
18
  save_execution_timings_json,
19
19
  )
20
20
 
21
- __version__ = "1.0.2"
21
+ __version__ = "1.1.0"
22
22
 
23
23
  __all__ = [
24
24
  "DEFAULT_CATEGORY",
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, NamedTuple, ParamSpec, TypedDict, TypeVar, cast
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: elapsed seconds and its category."""
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 payload."""
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
- class _Frame(NamedTuple):
65
- """Per-invocation state, with a cached path and an immutable parent link."""
66
-
67
- path: tuple[str, ...]
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.path, name) if parent is not None else (name,)
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.category, category) in self.forbidden_nesting:
139
- msg = f"Category '{category}' is not allowed inside category '{parent.category}'."
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(_Frame(full_name, category, time.perf_counter(), entry, parent))
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.path[-1] != name:
165
- frame = frame.parent
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
- frame.entry["elapsed_time"] += end_time - frame.start_time
172
- _ = _ACTIVE_CONTEXT.set(frame.parent)
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.path[-1]}' was still active; discarding the "
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)
@@ -191,11 +198,11 @@ class _ExecutionTimer:
191
198
  # Total the unflattened paths, like get_total_time and the JSON export.
192
199
  total_time = _top_level_time(snapshot)
193
200
  timings = _flatten(snapshot) if flatten else snapshot
194
- report = [f"Total time: {total_time:.4f} s.\n"]
201
+ report = [f"Total time: {_format_duration(total_time)}.\n"]
195
202
  for key, depth in _ordered_by_hierarchy(timings):
196
203
  elapsed_time = timings[key]["elapsed_time"]
197
204
  percentage = (elapsed_time / total_time) * 100 if total_time else 0.0
198
- report.append(f"{'.. ' * depth}{key[-1]}: {elapsed_time:.4f} s ({percentage:.2f}%)")
205
+ report.append(f"{'.. ' * depth}{key[-1]}: {_format_duration(elapsed_time)} ({percentage:.2f}%)")
199
206
  return "\n".join(report)
200
207
 
201
208
  def compute_total_time(self) -> float:
@@ -236,6 +243,20 @@ class _ExecutionTimer:
236
243
  _TIMER: Final = _ExecutionTimer()
237
244
 
238
245
 
246
+ def _format_duration(seconds: float) -> str:
247
+ """Format seconds with three decimals in the largest of µs, ms and s that stays below 1000.
248
+
249
+ The unit is chosen after rounding, so 999.9996 µs reads ``1.000 ms`` rather than
250
+ ``1000.000 µs``. Sections of any length stay readable, where fixed seconds would show a
251
+ fast section as zero.
252
+ """
253
+ for scale, unit in ((1e6, "µs"), (1e3, "ms")):
254
+ scaled = seconds * scale
255
+ if round(scaled, 3) < 1000:
256
+ return f"{scaled:.3f} {unit}"
257
+ return f"{seconds:.3f} s"
258
+
259
+
239
260
  def _flatten(timings: dict[tuple[str, ...], _TimesDict]) -> dict[tuple[str, ...], _TimesDict]:
240
261
  flat_map: dict[tuple[str, ...], _TimesDict] = {}
241
262
  for key, info in timings.items():
@@ -258,7 +279,32 @@ def _has_ancestor_with_category(
258
279
 
259
280
 
260
281
  class TimerContext:
261
- """Context manager and decorator for timing a named section of code."""
282
+ """Context manager and decorator for timing a named section of code.
283
+
284
+ Sections entered inside another section are recorded beneath it, so nesting ``with``
285
+ blocks or decorated calls builds the hierarchy shown in reports. Repeated entries of the
286
+ same section accumulate their durations. A context can be reused, re-entered while it is
287
+ active, and shared between threads and asyncio tasks.
288
+
289
+ Args:
290
+ name: The section's name within its parent.
291
+ category: Any string used to group sections, for example ``"io"`` or ``"gpu"``.
292
+ counter: Records the section as ``name[counter]``, typically a loop index. Reports
293
+ merge these variants unless you pass ``flatten=False``.
294
+
295
+ Raises:
296
+ ValueError: On entry, if a rule from :func:`register_forbidden_nesting` forbids this
297
+ category directly inside the enclosing section's category.
298
+
299
+ Example:
300
+ .. code-block:: python
301
+
302
+ with TimerContext("load", category="io"):
303
+ data = load()
304
+
305
+ @TimerContext("solve")
306
+ def solve(data): ...
307
+ """
262
308
 
263
309
  def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
264
310
  self.name: str = _build_name_with_counter(name, counter)
@@ -279,8 +325,11 @@ class TimerContext:
279
325
 
280
326
  Coroutine functions are wrapped so the timing spans the entire ``await``, not just
281
327
  creation of the coroutine object. So is a coroutine returned by a plain function,
282
- typically another decorator stacked on an ``async def``. Generator functions are
283
- rejected: a wrapper would time only creation of the generator object, not its iteration.
328
+ typically another decorator stacked on an ``async def``.
329
+
330
+ Raises:
331
+ TypeError: If ``func`` is a generator or async generator function. A wrapper
332
+ would time only creation of the generator object, not its iteration.
284
333
  """
285
334
  if inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func):
286
335
  msg = (
@@ -341,12 +390,31 @@ def _basic_name_without_counter(name: str) -> str:
341
390
 
342
391
 
343
392
  def get_execution_times_report(*, flatten: bool = True) -> str:
344
- """Get a formatted report of all recorded sections (``""`` if none); flatten counters if requested."""
393
+ """Get a formatted, indented report of all recorded sections.
394
+
395
+ Each line shows a section's accumulated time and its share of the total time.
396
+ Indentation reflects nesting. Times have three decimals, in ``µs``, ``ms`` or ``s``,
397
+ whichever keeps the number below 1000.
398
+
399
+ Args:
400
+ flatten: Merge ``counter`` variants such as ``step[0]`` and ``step[1]`` into ``step``.
401
+
402
+ Returns:
403
+ The report, or ``""`` if nothing has been recorded.
404
+ """
345
405
  return _TIMER.report_timings(flatten=flatten)
346
406
 
347
407
 
348
408
  def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
349
- """Log the execution-times report at INFO level, or a warning if there is nothing to report."""
409
+ """Log the report from :func:`get_execution_times_report` at ``INFO`` level.
410
+
411
+ Logs a warning instead if nothing has been recorded. Does nothing, and skips building
412
+ the report, if the logger has ``INFO`` disabled.
413
+
414
+ Args:
415
+ flatten: Merge ``counter`` variants of a section.
416
+ logger: The logger to use. Defaults to the ``execution_timer._timer`` logger.
417
+ """
350
418
  target = logger if logger is not None else _LOGGER
351
419
  if target.isEnabledFor(logging.INFO):
352
420
  report = get_execution_times_report(flatten=flatten)
@@ -358,7 +426,15 @@ def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None =
358
426
 
359
427
 
360
428
  def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
361
- """Get elapsed seconds and category for every recorded section; flatten counters if requested."""
429
+ """Get elapsed seconds and category for every recorded section.
430
+
431
+ Args:
432
+ flatten: Merge ``counter`` variants of a section.
433
+
434
+ Returns:
435
+ A new dictionary keyed by section path, such as ``("solve", "step")``. Changing it
436
+ does not affect the recorded timings.
437
+ """
362
438
  return _TIMER.get_execution_timings(flatten=flatten)
363
439
 
364
440
 
@@ -388,34 +464,79 @@ def _build_payload(*, flatten: bool = True) -> TimingsPayload:
388
464
 
389
465
 
390
466
  def get_execution_times_json(*, flatten: bool = True, indent: int | None = 2) -> str:
391
- """Get all timings as a JSON string (LLM-friendly); flatten counters if requested."""
467
+ """Get all timings as a JSON document shaped like :class:`TimingsPayload`.
468
+
469
+ Args:
470
+ flatten: Merge ``counter`` variants of a section.
471
+ indent: Passed to :func:`json.dumps`; ``None`` gives the most compact output.
472
+
473
+ Returns:
474
+ The JSON text.
475
+ """
392
476
  return json.dumps(_build_payload(flatten=flatten), indent=indent)
393
477
 
394
478
 
395
479
  def save_execution_timings_json(path: str | Path, *, flatten: bool = True, indent: int | None = 2) -> Path:
396
- """Write all timings to a JSON file and return its path; flatten counters if requested."""
480
+ """Write the JSON from :func:`get_execution_times_json` to a UTF-8 file.
481
+
482
+ Args:
483
+ path: The file to write, replacing it if it exists. Its directory must exist.
484
+ flatten: Merge ``counter`` variants of a section.
485
+ indent: Passed to :func:`json.dumps`.
486
+
487
+ Returns:
488
+ The path written to.
489
+ """
397
490
  out = Path(path)
398
491
  _ = out.write_text(get_execution_times_json(flatten=flatten, indent=indent) + "\n", encoding="utf-8")
399
492
  return out
400
493
 
401
494
 
402
495
  def get_total_time() -> float:
403
- """Get total elapsed seconds across all top-level sections."""
496
+ """Get total elapsed seconds across all top-level sections.
497
+
498
+ Nested sections are part of their parent's time, so they are not added again.
499
+
500
+ Returns:
501
+ The total, or ``0.0`` if nothing has been recorded.
502
+ """
404
503
  return _TIMER.compute_total_time()
405
504
 
406
505
 
407
506
  def get_total_category_time(category: str) -> float:
408
- """Get total elapsed seconds in a category, counting only top-most entries of that category."""
507
+ """Get total elapsed seconds in a category.
508
+
509
+ Only the top-most section of the category counts: a section nested inside another of
510
+ the same category is part of that section's time and is not added again.
511
+
512
+ Args:
513
+ category: The category to total.
514
+
515
+ Returns:
516
+ The total, or ``0.0`` for a category with no sections.
517
+ """
409
518
  return _TIMER.compute_total_category_time(category)
410
519
 
411
520
 
412
521
  def clear_execution_timings() -> None:
413
- """Reset all recorded timings."""
522
+ """Discard all recorded timings.
523
+
524
+ A section that is active during the clear is not recorded when it exits. Sections
525
+ started inside it afterwards are recorded as top-level sections. Nesting rules are kept;
526
+ see :func:`clear_forbidden_nesting`.
527
+ """
414
528
  _TIMER.clear()
415
529
 
416
530
 
417
531
  def register_forbidden_nesting(outer: str, inner: str) -> None:
418
- """Forbid timing sections of category ``inner`` directly inside sections of category ``outer``."""
532
+ """Forbid sections of category ``inner`` directly inside sections of category ``outer``.
533
+
534
+ Entering such a section raises :class:`ValueError`. Only the direct parent is checked.
535
+
536
+ Args:
537
+ outer: The enclosing section's category.
538
+ inner: The category that may not appear directly inside it.
539
+ """
419
540
  _TIMER.register_forbidden_nesting(outer, inner)
420
541
 
421
542
 
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.5
2
+ Name: executiontimer
3
+ Version: 1.1.0
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: 312.838 ms.
88
+
89
+ load_data: 124.682 ms (39.85%)
90
+ solve: 188.157 ms (60.15%)
91
+ .. step: 154.718 ms (49.46%)
92
+ .. postprocess: 33.295 ms (10.64%)
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=HrXnAPwlQzzTpSRBVk_j-OLc-RNRS3WluU9XpUxLRh4,959
2
+ execution_timer/_timer.py,sha256=oLbiDVmmojeijwPMy99wVFwCLTGQ8ND2Gck3wh_SVRs,22043
3
+ execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ executiontimer-1.1.0.dist-info/METADATA,sha256=YAEpYJjuaKvtbdKyyhje1lLgqyKlkYKYb-7hYoDt7q0,4966
5
+ executiontimer-1.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ executiontimer-1.1.0.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
7
+ executiontimer-1.1.0.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,,