executiontimer 0.1.0__py3-none-any.whl → 0.2.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.
- execution_timer/__init__.py +1 -1
- execution_timer/_timer.py +109 -87
- {executiontimer-0.1.0.dist-info → executiontimer-0.2.0.dist-info}/METADATA +67 -8
- executiontimer-0.2.0.dist-info/RECORD +7 -0
- {executiontimer-0.1.0.dist-info → executiontimer-0.2.0.dist-info}/WHEEL +1 -1
- executiontimer-0.1.0.dist-info/RECORD +0 -7
- {executiontimer-0.1.0.dist-info → executiontimer-0.2.0.dist-info}/licenses/LICENSE +0 -0
execution_timer/__init__.py
CHANGED
execution_timer/_timer.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Timings are stored in a process-wide registry guarded by a lock. The active-context stack
|
|
4
4
|
lives in a :class:`~contextvars.ContextVar`, so it is isolated per thread *and* per asyncio
|
|
5
5
|
task: sections recorded concurrently nest independently and merge into one report.
|
|
6
|
-
|
|
6
|
+
Overlapping calls to the same section accumulate their individual durations.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
@@ -14,11 +14,12 @@ import json
|
|
|
14
14
|
import logging
|
|
15
15
|
import threading
|
|
16
16
|
import time
|
|
17
|
-
from collections.abc import Callable, Coroutine, Iterable
|
|
17
|
+
from collections.abc import Callable, Coroutine, Iterable, Iterator
|
|
18
18
|
from contextvars import ContextVar
|
|
19
|
+
from itertools import count
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from types import TracebackType
|
|
21
|
-
from typing import
|
|
22
|
+
from typing import Final, NamedTuple, ParamSpec, TypedDict, TypeVar, cast
|
|
22
23
|
|
|
23
24
|
P = ParamSpec("P")
|
|
24
25
|
R = TypeVar("R")
|
|
@@ -28,9 +29,6 @@ DEFAULT_CATEGORY: Final = "default"
|
|
|
28
29
|
|
|
29
30
|
_LOGGER: Final = logging.getLogger(__name__)
|
|
30
31
|
|
|
31
|
-
# Stack of (name, category) frames for the current thread / asyncio task.
|
|
32
|
-
_ACTIVE_CONTEXT: ContextVar[tuple[tuple[str, str], ...]] = ContextVar("execution_timer_context", default=())
|
|
33
|
-
|
|
34
32
|
|
|
35
33
|
class TimingReport(TypedDict):
|
|
36
34
|
"""Timing entry for one section: elapsed seconds and its category."""
|
|
@@ -57,11 +55,24 @@ class TimingsPayload(TypedDict):
|
|
|
57
55
|
|
|
58
56
|
|
|
59
57
|
class _TimesDict(TypedDict):
|
|
60
|
-
|
|
58
|
+
sequence: int
|
|
61
59
|
elapsed_time: float
|
|
62
60
|
category: str
|
|
63
61
|
|
|
64
62
|
|
|
63
|
+
class _Frame(NamedTuple):
|
|
64
|
+
"""Per-invocation state, with a cached path and an immutable parent link."""
|
|
65
|
+
|
|
66
|
+
path: tuple[str, ...]
|
|
67
|
+
category: str
|
|
68
|
+
start_time: float
|
|
69
|
+
entry: _TimesDict
|
|
70
|
+
parent: _Frame | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_ACTIVE_CONTEXT: ContextVar[_Frame | None] = ContextVar("execution_timer_context", default=None)
|
|
74
|
+
|
|
75
|
+
|
|
65
76
|
def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ...]]:
|
|
66
77
|
"""Order section paths depth-first so children always follow their parent.
|
|
67
78
|
|
|
@@ -91,81 +102,53 @@ def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ..
|
|
|
91
102
|
|
|
92
103
|
|
|
93
104
|
class _ExecutionTimer:
|
|
94
|
-
"""
|
|
105
|
+
"""Registry of named, nestable timing sections, shared through ``_TIMER``."""
|
|
95
106
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
self._lock: threading.Lock = threading.Lock()
|
|
109
|
+
self.timings: dict[tuple[str, ...], _TimesDict] = {}
|
|
110
|
+
self.forbidden_nesting: set[tuple[str, str]] = set()
|
|
111
|
+
self._sequence: Iterator[int] = count()
|
|
100
112
|
|
|
101
|
-
def
|
|
102
|
-
if cls._instance is None:
|
|
103
|
-
cls._instance = super().__new__(cls)
|
|
104
|
-
return cls._instance
|
|
105
|
-
|
|
106
|
-
@property
|
|
107
|
-
def _full_name(self) -> tuple[str, ...]:
|
|
108
|
-
return tuple(frame[0] for frame in _ACTIVE_CONTEXT.get())
|
|
109
|
-
|
|
110
|
-
def _snapshot(self) -> dict[tuple[str, ...], _TimesDict]:
|
|
113
|
+
def snapshot(self) -> dict[tuple[str, ...], _TimesDict]:
|
|
111
114
|
"""Copy the registry under the lock so readers never iterate a mutating dict."""
|
|
112
115
|
with self._lock:
|
|
113
116
|
return {key: info.copy() for key, info in self.timings.items()}
|
|
114
117
|
|
|
115
118
|
def start_timer(self, name: str, category: str) -> None:
|
|
116
119
|
"""Start timing a section under the given name within the active context."""
|
|
117
|
-
|
|
118
|
-
full_name =
|
|
119
|
-
start_time = time.perf_counter()
|
|
120
|
+
parent = _ACTIVE_CONTEXT.get()
|
|
121
|
+
full_name = (*parent.path, name) if parent is not None else (name,)
|
|
120
122
|
with self._lock:
|
|
121
|
-
|
|
123
|
+
if parent is not None and (parent.category, category) in self.forbidden_nesting:
|
|
124
|
+
msg = f"Category '{category}' is not allowed inside category '{parent.category}'."
|
|
125
|
+
raise ValueError(msg)
|
|
126
|
+
sequence = next(self._sequence)
|
|
127
|
+
entry: _TimesDict | None = self.timings.get(full_name)
|
|
122
128
|
if entry is None:
|
|
123
|
-
|
|
129
|
+
entry = {"sequence": sequence, "elapsed_time": 0.0, "category": category}
|
|
130
|
+
self.timings[full_name] = entry
|
|
124
131
|
else:
|
|
125
|
-
entry["
|
|
132
|
+
entry["sequence"] = sequence
|
|
126
133
|
entry["category"] = category
|
|
134
|
+
_ = _ACTIVE_CONTEXT.set(_Frame(full_name, category, time.perf_counter(), entry, parent))
|
|
127
135
|
|
|
128
136
|
def stop_timer(self, name: str) -> None:
|
|
129
137
|
"""Stop timing a section and accumulate its elapsed time."""
|
|
130
138
|
end_time = time.perf_counter()
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
entry = self.timings.get(full_name)
|
|
134
|
-
# The entry is gone if the registry was cleared while this section was running;
|
|
135
|
-
# dropping the sample is preferable to raising out of a ``with`` block.
|
|
136
|
-
if entry is not None:
|
|
137
|
-
entry["elapsed_time"] += end_time - entry["start_time"]
|
|
138
|
-
self._remove_context(name)
|
|
139
|
-
|
|
140
|
-
def _add_context(self, name: str, category: str) -> None:
|
|
141
|
-
stack = _ACTIVE_CONTEXT.get()
|
|
142
|
-
if stack and (stack[-1][1], category) in self.forbidden_nesting:
|
|
143
|
-
msg = f"Category '{category}' is not allowed inside category '{stack[-1][1]}'."
|
|
144
|
-
raise ValueError(msg)
|
|
145
|
-
_ = _ACTIVE_CONTEXT.set((*stack, (name, category)))
|
|
146
|
-
|
|
147
|
-
def _remove_context(self, name: str) -> None:
|
|
148
|
-
"""Pop the innermost context, restoring the stack to its state before ``name`` was entered."""
|
|
149
|
-
stack = _ACTIVE_CONTEXT.get()
|
|
150
|
-
if not stack:
|
|
139
|
+
frame = _ACTIVE_CONTEXT.get()
|
|
140
|
+
if frame is None:
|
|
151
141
|
return
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def compute_flattened_timings(self) -> dict[tuple[str, ...], _TimesDict]:
|
|
160
|
-
"""Aggregate elapsed times with counter suffixes removed from section names.
|
|
161
|
-
|
|
162
|
-
When counter variants of one section are merged, elapsed times are summed and the
|
|
163
|
-
most recently recorded category is kept.
|
|
164
|
-
"""
|
|
165
|
-
return _flatten(self._snapshot())
|
|
142
|
+
if frame.path[-1] != name:
|
|
143
|
+
raise RuntimeError(f"Cannot stop '{name}' while '{frame.path[-1]}' is active.")
|
|
144
|
+
with self._lock:
|
|
145
|
+
# A clear detaches this entry from the registry. Updating the detached object
|
|
146
|
+
# cannot resurrect an old sample or add it to a replacement at the same path.
|
|
147
|
+
frame.entry["elapsed_time"] += end_time - frame.start_time
|
|
148
|
+
_ = _ACTIVE_CONTEXT.set(frame.parent)
|
|
166
149
|
|
|
167
150
|
def _resolve(self, *, flatten: bool) -> dict[tuple[str, ...], _TimesDict]:
|
|
168
|
-
snapshot = self.
|
|
151
|
+
snapshot = self.snapshot()
|
|
169
152
|
return _flatten(snapshot) if flatten else snapshot
|
|
170
153
|
|
|
171
154
|
def report_timings(self, *, flatten: bool = True) -> str:
|
|
@@ -185,12 +168,14 @@ class _ExecutionTimer:
|
|
|
185
168
|
|
|
186
169
|
def compute_total_time(self, *, flatten: bool = True) -> float:
|
|
187
170
|
"""Compute total elapsed time across all top-level sections."""
|
|
188
|
-
|
|
189
|
-
|
|
171
|
+
# Counter merging cannot change the sum. Avoid allocating and flattening a snapshot.
|
|
172
|
+
_ = flatten
|
|
173
|
+
with self._lock:
|
|
174
|
+
return sum((info["elapsed_time"] for key, info in self.timings.items() if len(key) == 1), 0.0)
|
|
190
175
|
|
|
191
176
|
def compute_total_category_time(self, category: str) -> float:
|
|
192
177
|
"""Compute total elapsed time in a category, counting only top-most entries of that category."""
|
|
193
|
-
timings = self.
|
|
178
|
+
timings = self.snapshot()
|
|
194
179
|
total_time = 0.0
|
|
195
180
|
for key, info in timings.items():
|
|
196
181
|
if info["category"] != category or _has_ancestor_with_category(timings, key, category):
|
|
@@ -208,6 +193,17 @@ class _ExecutionTimer:
|
|
|
208
193
|
with self._lock:
|
|
209
194
|
self.timings.clear()
|
|
210
195
|
|
|
196
|
+
def register_forbidden_nesting(self, outer: str, inner: str) -> None:
|
|
197
|
+
with self._lock:
|
|
198
|
+
self.forbidden_nesting.add((outer, inner))
|
|
199
|
+
|
|
200
|
+
def clear_forbidden_nesting(self) -> None:
|
|
201
|
+
with self._lock:
|
|
202
|
+
self.forbidden_nesting.clear()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_TIMER: Final = _ExecutionTimer()
|
|
206
|
+
|
|
211
207
|
|
|
212
208
|
def _flatten(timings: dict[tuple[str, ...], _TimesDict]) -> dict[tuple[str, ...], _TimesDict]:
|
|
213
209
|
flat_map: dict[tuple[str, ...], _TimesDict] = {}
|
|
@@ -218,7 +214,9 @@ def _flatten(timings: dict[tuple[str, ...], _TimesDict]) -> dict[tuple[str, ...]
|
|
|
218
214
|
flat_map[flat_key] = info.copy()
|
|
219
215
|
else:
|
|
220
216
|
existing["elapsed_time"] += info["elapsed_time"]
|
|
221
|
-
|
|
217
|
+
if info["sequence"] > existing["sequence"]:
|
|
218
|
+
existing["category"] = info["category"]
|
|
219
|
+
existing["sequence"] = info["sequence"]
|
|
222
220
|
return flat_map
|
|
223
221
|
|
|
224
222
|
|
|
@@ -234,7 +232,7 @@ class TimerContext:
|
|
|
234
232
|
def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
|
|
235
233
|
self.name: str = _build_name_with_counter(name, counter)
|
|
236
234
|
self.category: str = category
|
|
237
|
-
self.timer: _ExecutionTimer =
|
|
235
|
+
self.timer: _ExecutionTimer = _TIMER
|
|
238
236
|
|
|
239
237
|
def __enter__(self) -> TimerContext:
|
|
240
238
|
self.timer.start_timer(self.name, self.category)
|
|
@@ -249,8 +247,16 @@ class TimerContext:
|
|
|
249
247
|
"""Decorate a function to time its execution under this context.
|
|
250
248
|
|
|
251
249
|
Coroutine functions are wrapped so the timing spans the entire ``await``, not just
|
|
252
|
-
creation of the coroutine object.
|
|
250
|
+
creation of the coroutine object. Generator functions are rejected: a wrapper would
|
|
251
|
+
time only creation of the generator object, not its iteration.
|
|
253
252
|
"""
|
|
253
|
+
if inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func):
|
|
254
|
+
msg = (
|
|
255
|
+
f"Cannot decorate generator function {func.__qualname__!r}: only creating the generator "
|
|
256
|
+
"would be timed. Time the loop that consumes it, or its body between yields, with a "
|
|
257
|
+
"'with TimerContext(...)' block instead."
|
|
258
|
+
)
|
|
259
|
+
raise TypeError(msg)
|
|
254
260
|
if inspect.iscoroutinefunction(func):
|
|
255
261
|
# ``iscoroutinefunction`` narrows nothing useful for the type checker, so bridge
|
|
256
262
|
# through an explicitly typed helper instead of leaking ``Any`` into the signature.
|
|
@@ -259,7 +265,7 @@ class TimerContext:
|
|
|
259
265
|
|
|
260
266
|
@functools.wraps(func)
|
|
261
267
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
262
|
-
with
|
|
268
|
+
with self:
|
|
263
269
|
return func(*args, **kwargs)
|
|
264
270
|
|
|
265
271
|
return wrapper
|
|
@@ -269,7 +275,7 @@ class TimerContext:
|
|
|
269
275
|
|
|
270
276
|
@functools.wraps(func)
|
|
271
277
|
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
272
|
-
with
|
|
278
|
+
with self:
|
|
273
279
|
return await func(*args, **kwargs)
|
|
274
280
|
|
|
275
281
|
return async_wrapper
|
|
@@ -285,42 +291,54 @@ def _build_name_with_counter(name: str, counter: int | None = None) -> str:
|
|
|
285
291
|
def _basic_name_without_counter(name: str) -> str:
|
|
286
292
|
"""Strip a trailing ``[counter]`` from a section name if present."""
|
|
287
293
|
if "[" in name and name.endswith("]"):
|
|
288
|
-
|
|
294
|
+
prefix, _, suffix = name.rpartition("[")
|
|
295
|
+
digits = suffix[:-1].removeprefix("-")
|
|
296
|
+
if digits.isascii() and digits.isdecimal():
|
|
297
|
+
return prefix
|
|
289
298
|
return name
|
|
290
299
|
|
|
291
300
|
|
|
292
301
|
def get_execution_times_report(*, flatten: bool = True) -> str:
|
|
293
302
|
"""Get a formatted report of all recorded sections; flatten counters if requested."""
|
|
294
|
-
return
|
|
303
|
+
return _TIMER.report_timings(flatten=flatten)
|
|
295
304
|
|
|
296
305
|
|
|
297
306
|
def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
|
|
298
307
|
"""Log the execution-times report at INFO level; flatten counters if requested."""
|
|
299
|
-
|
|
308
|
+
target = logger if logger is not None else _LOGGER
|
|
309
|
+
if target.isEnabledFor(logging.INFO):
|
|
310
|
+
report = get_execution_times_report(flatten=flatten)
|
|
311
|
+
# An empty report has already been warned about; logging it would add a blank record.
|
|
312
|
+
if report:
|
|
313
|
+
target.info(report)
|
|
300
314
|
|
|
301
315
|
|
|
302
316
|
def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
|
|
303
317
|
"""Get elapsed seconds and category for every recorded section; flatten counters if requested."""
|
|
304
|
-
return
|
|
318
|
+
return _TIMER.get_execution_timings(flatten=flatten)
|
|
305
319
|
|
|
306
320
|
|
|
307
321
|
def _build_payload(*, flatten: bool = True) -> TimingsPayload:
|
|
308
322
|
"""Build a JSON-serializable snapshot of all timings, including totals and per-category sums."""
|
|
309
|
-
|
|
310
|
-
timings =
|
|
323
|
+
snapshot = _TIMER.snapshot()
|
|
324
|
+
timings = _flatten(snapshot) if flatten else snapshot
|
|
311
325
|
sections: list[SectionRecord] = [
|
|
312
326
|
{
|
|
313
327
|
"name": key[-1],
|
|
314
328
|
"path": list(key),
|
|
315
|
-
"time": round(timings[key]["
|
|
329
|
+
"time": round(timings[key]["elapsed_time"], 6),
|
|
316
330
|
"category": timings[key]["category"],
|
|
317
331
|
}
|
|
318
332
|
for key in _ordered_by_hierarchy(timings)
|
|
319
333
|
]
|
|
320
|
-
|
|
334
|
+
category_totals: dict[str, float] = {}
|
|
335
|
+
for key, info in snapshot.items():
|
|
336
|
+
category = info["category"]
|
|
337
|
+
if not _has_ancestor_with_category(snapshot, key, category):
|
|
338
|
+
category_totals[category] = category_totals.get(category, 0.0) + info["elapsed_time"]
|
|
321
339
|
return {
|
|
322
|
-
"total_time": round(
|
|
323
|
-
"total_category_time": {cat: round(
|
|
340
|
+
"total_time": round(sum((info["elapsed_time"] for key, info in snapshot.items() if len(key) == 1), 0.0), 6),
|
|
341
|
+
"total_category_time": {cat: round(category_totals[cat], 6) for cat in sorted(category_totals)},
|
|
324
342
|
"sections": sections,
|
|
325
343
|
}
|
|
326
344
|
|
|
@@ -338,25 +356,29 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
|
|
|
338
356
|
|
|
339
357
|
|
|
340
358
|
def get_total_time(*, flatten: bool = True) -> float:
|
|
341
|
-
"""Get total elapsed seconds across all top-level sections.
|
|
342
|
-
|
|
359
|
+
"""Get total elapsed seconds across all top-level sections.
|
|
360
|
+
|
|
361
|
+
``flatten`` has no effect, because merging counter variants cannot change the total. It is
|
|
362
|
+
accepted for symmetry with the other reporting functions.
|
|
363
|
+
"""
|
|
364
|
+
return _TIMER.compute_total_time(flatten=flatten)
|
|
343
365
|
|
|
344
366
|
|
|
345
367
|
def get_total_category_time(category: str) -> float:
|
|
346
368
|
"""Get total elapsed seconds in a category, counting only top-most entries of that category."""
|
|
347
|
-
return
|
|
369
|
+
return _TIMER.compute_total_category_time(category)
|
|
348
370
|
|
|
349
371
|
|
|
350
372
|
def clear_execution_timings() -> None:
|
|
351
373
|
"""Reset all recorded timings."""
|
|
352
|
-
|
|
374
|
+
_TIMER.clear()
|
|
353
375
|
|
|
354
376
|
|
|
355
377
|
def register_forbidden_nesting(outer: str, inner: str) -> None:
|
|
356
378
|
"""Forbid timing sections of category ``inner`` directly inside sections of category ``outer``."""
|
|
357
|
-
|
|
379
|
+
_TIMER.register_forbidden_nesting(outer, inner)
|
|
358
380
|
|
|
359
381
|
|
|
360
382
|
def clear_forbidden_nesting() -> None:
|
|
361
383
|
"""Remove all forbidden-nesting rules."""
|
|
362
|
-
|
|
384
|
+
_TIMER.clear_forbidden_nesting()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: executiontimer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Hierarchical execution timing with user-defined categories.
|
|
5
5
|
Project-URL: Homepage, https://github.com/seba2390/ExecutionTimer
|
|
6
6
|
Project-URL: Documentation, https://github.com/seba2390/ExecutionTimer#readme
|
|
@@ -16,15 +16,17 @@ Classifier: Intended Audience :: Developers
|
|
|
16
16
|
Classifier: Intended Audience :: Science/Research
|
|
17
17
|
Classifier: Operating System :: OS Independent
|
|
18
18
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
21
22
|
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
22
24
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
23
25
|
Classifier: Topic :: Software Development
|
|
24
26
|
Classifier: Topic :: Software Development :: Testing
|
|
25
27
|
Classifier: Topic :: System :: Benchmark
|
|
26
28
|
Classifier: Typing :: Typed
|
|
27
|
-
Requires-Python: >=3.
|
|
29
|
+
Requires-Python: >=3.11
|
|
28
30
|
Description-Content-Type: text/markdown
|
|
29
31
|
|
|
30
32
|
<p align="center">
|
|
@@ -55,7 +57,8 @@ Zero dependencies. Fully type annotated. Works with threads and `asyncio`.
|
|
|
55
57
|
- 🌳 **Automatic hierarchy** — nesting `with` blocks nests the report, no wiring required
|
|
56
58
|
- 🏷️ **User-defined categories** — tag sections with any string (`"gpu"`, `"io"`, `"db"`) and get per-category totals
|
|
57
59
|
- ⚡ **Native async** — decorating an `async def` times the whole `await`, not the coroutine object
|
|
58
|
-
- 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task
|
|
60
|
+
- 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task,
|
|
61
|
+
including on free-threaded Python builds
|
|
59
62
|
- 🔢 **Loop counters** — time each iteration separately, then merge them back together
|
|
60
63
|
- 📤 **JSON export** — structured output for dashboards, CI, or an LLM
|
|
61
64
|
- 🚫 **Nesting rules** — optionally forbid one category inside another to catch mistakes early
|
|
@@ -71,7 +74,7 @@ pip install executiontimer
|
|
|
71
74
|
uv add executiontimer
|
|
72
75
|
```
|
|
73
76
|
|
|
74
|
-
Requires Python 3.
|
|
77
|
+
Requires Python 3.11+.
|
|
75
78
|
|
|
76
79
|
> **Note** — the install name is `executiontimer`, the import name is `execution_timer`:
|
|
77
80
|
>
|
|
@@ -131,6 +134,10 @@ Coroutine functions are supported natively — the timing spans the entire `awai
|
|
|
131
134
|
async def fetch(url: str) -> bytes: ...
|
|
132
135
|
```
|
|
133
136
|
|
|
137
|
+
Generator functions (including `async` generators) cannot be decorated and raise a
|
|
138
|
+
`TypeError`: the decorator would time only the creation of the generator object, not its
|
|
139
|
+
iteration. Time the loop that consumes the generator with a `with` block instead.
|
|
140
|
+
|
|
134
141
|
### Counters
|
|
135
142
|
|
|
136
143
|
Pass `counter=i` to time loop iterations separately. The report merges them by default
|
|
@@ -145,6 +152,10 @@ get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
|
|
|
145
152
|
get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
|
|
146
153
|
```
|
|
147
154
|
|
|
155
|
+
Flattening removes the final integer suffix (including negative counters). Other
|
|
156
|
+
bracketed names such as `array[index]` are preserved. If merged entries have different
|
|
157
|
+
categories, the category from the most recently entered section is used.
|
|
158
|
+
|
|
148
159
|
### Categories
|
|
149
160
|
|
|
150
161
|
Categories are plain strings — use whatever fits your domain:
|
|
@@ -161,6 +172,10 @@ get_total_category_time("gpu")
|
|
|
161
172
|
`get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
|
|
162
173
|
section nested inside another `gpu` section is not double-counted.
|
|
163
174
|
|
|
175
|
+
Repeated calls to the same path accumulate time. If its category changes, the latest
|
|
176
|
+
category applies to that path's entire accumulated time. Use consistent categories per
|
|
177
|
+
path when you need separate category totals.
|
|
178
|
+
|
|
164
179
|
You can also forbid a category from appearing inside another, which raises a `ValueError`
|
|
165
180
|
as soon as the invalid nesting happens:
|
|
166
181
|
|
|
@@ -198,6 +213,9 @@ save_execution_timings_json("timings.json")
|
|
|
198
213
|
}
|
|
199
214
|
```
|
|
200
215
|
|
|
216
|
+
Sections and totals come from one snapshot. Category totals use the original paths,
|
|
217
|
+
even when flattening merges sections with different categories.
|
|
218
|
+
|
|
201
219
|
### Concurrency
|
|
202
220
|
|
|
203
221
|
The recorded timings live in one process-wide registry guarded by a lock. The *active
|
|
@@ -213,9 +231,50 @@ async def worker(n: int) -> None:
|
|
|
213
231
|
await asyncio.gather(worker(0), worker(1))
|
|
214
232
|
```
|
|
215
233
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
234
|
+
Overlapping calls to the same section path are supported: each call keeps its own start
|
|
235
|
+
time, and their durations are added together. These totals measure accumulated elapsed
|
|
236
|
+
time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
|
|
237
|
+
concurrent calls separately.
|
|
238
|
+
|
|
239
|
+
New asyncio tasks inherit the timing context in which they are created. Their sections
|
|
240
|
+
nest under that parent; changes to each task's active stack remain independent. Await
|
|
241
|
+
child tasks inside the parent section if you want the parent duration to include them.
|
|
242
|
+
|
|
243
|
+
New threads do *not* inherit the timing context, so sections recorded in a thread
|
|
244
|
+
appear at the top level of the report, and their time is added to the total alongside
|
|
245
|
+
the section that started the thread. To nest thread work under the current section, run
|
|
246
|
+
it with `contextvars.copy_context().run(...)` or `asyncio.to_thread(...)`. Either way,
|
|
247
|
+
concurrent threads accumulate overlapping time. (Free-threaded builds of Python 3.14 make
|
|
248
|
+
threads inherit the context by default.)
|
|
249
|
+
|
|
250
|
+
Generators run in their caller's context. A `with TimerContext(...)` block that stays open
|
|
251
|
+
across a `yield` therefore also contains whatever the caller times while the generator is
|
|
252
|
+
paused, and its duration includes that paused time. Close sections before yielding, or
|
|
253
|
+
time the loop that consumes the generator instead.
|
|
254
|
+
|
|
255
|
+
### Reusing contexts and clearing timings
|
|
256
|
+
|
|
257
|
+
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
258
|
+
For a tight loop, reuse a context to avoid constructing one on every iteration:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
step_timer = TimerContext("step")
|
|
262
|
+
for item in items:
|
|
263
|
+
with step_timer:
|
|
264
|
+
process(item)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
268
|
+
samples from sections that were already active, without disturbing their nesting stack.
|
|
269
|
+
Sections started after the clear are recorded normally. Reports include completed calls;
|
|
270
|
+
an active section's current duration is added only when it exits.
|
|
271
|
+
|
|
272
|
+
### Measuring overhead
|
|
273
|
+
|
|
274
|
+
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
275
|
+
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
276
|
+
results using the same interpreter and machine; see [benchmarks/README.md](benchmarks/README.md).
|
|
277
|
+
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
219
278
|
|
|
220
279
|
## API
|
|
221
280
|
|
|
@@ -227,7 +286,7 @@ concurrent sections distinct names (or use `counter=`).
|
|
|
227
286
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
228
287
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
229
288
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
230
|
-
| `get_total_time(*, flatten=True)` | Total seconds across all top-level sections. |
|
|
289
|
+
| `get_total_time(*, flatten=True)` | Total seconds across all top-level sections (`flatten` has no effect on the sum). |
|
|
231
290
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
232
291
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
233
292
|
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
execution_timer/__init__.py,sha256=9A5FYIrOvXbohuQMyIis651hYdNmzvNLQxfR02pk92w,959
|
|
2
|
+
execution_timer/_timer.py,sha256=4PSyP501arREnqOKtXEMwjbsb5sFISVYFRc1EXOXGP0,15366
|
|
3
|
+
execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
executiontimer-0.2.0.dist-info/METADATA,sha256=B6G1utu8t7BPwVh8KFID_pPr-c9z2bW0M6hd8-eiPWg,12631
|
|
5
|
+
executiontimer-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
executiontimer-0.2.0.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
|
|
7
|
+
executiontimer-0.2.0.dist-info/RECORD,,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
execution_timer/__init__.py,sha256=6DKOLTSXY0gu_DjW_rIqBRZROUYnlp8haVYvLX6NzTQ,959
|
|
2
|
-
execution_timer/_timer.py,sha256=USfPpUdpW3jk1ffjIcTdOmB-FK-8HQHgcvZ44mJQ2f4,14504
|
|
3
|
-
execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
executiontimer-0.1.0.dist-info/METADATA,sha256=1ftfFmuikQRb6w1mmfZAUVdezEUUgyweDkxE8ljTLNY,9412
|
|
5
|
-
executiontimer-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
-
executiontimer-0.1.0.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
|
|
7
|
-
executiontimer-0.1.0.dist-info/RECORD,,
|
|
File without changes
|