executiontimer 0.2.0__py3-none-any.whl → 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- execution_timer/__init__.py +1 -1
- execution_timer/_timer.py +68 -39
- {executiontimer-0.2.0.dist-info → executiontimer-1.0.1.dist-info}/METADATA +23 -11
- executiontimer-1.0.1.dist-info/RECORD +7 -0
- executiontimer-0.2.0.dist-info/RECORD +0 -7
- {executiontimer-0.2.0.dist-info → executiontimer-1.0.1.dist-info}/WHEEL +0 -0
- {executiontimer-0.2.0.dist-info → executiontimer-1.0.1.dist-info}/licenses/LICENSE +0 -0
execution_timer/__init__.py
CHANGED
execution_timer/_timer.py
CHANGED
|
@@ -14,6 +14,7 @@ import json
|
|
|
14
14
|
import logging
|
|
15
15
|
import threading
|
|
16
16
|
import time
|
|
17
|
+
import warnings
|
|
17
18
|
from collections.abc import Callable, Coroutine, Iterable, Iterator
|
|
18
19
|
from contextvars import ContextVar
|
|
19
20
|
from itertools import count
|
|
@@ -73,11 +74,13 @@ class _Frame(NamedTuple):
|
|
|
73
74
|
_ACTIVE_CONTEXT: ContextVar[_Frame | None] = ContextVar("execution_timer_context", default=None)
|
|
74
75
|
|
|
75
76
|
|
|
76
|
-
def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ...]]:
|
|
77
|
+
def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[tuple[str, ...], int]]:
|
|
77
78
|
"""Order section paths depth-first so children always follow their parent.
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
Returns each path with its depth in the recorded tree, which is shallower than the path
|
|
81
|
+
length when an ancestor is missing. Insertion order is preserved within each level, so a
|
|
82
|
+
parent revisited after an unrelated sibling still renders with its own children rather
|
|
83
|
+
than beneath the sibling.
|
|
81
84
|
"""
|
|
82
85
|
keys = list(keys)
|
|
83
86
|
known = set(keys)
|
|
@@ -91,16 +94,28 @@ def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ..
|
|
|
91
94
|
else:
|
|
92
95
|
roots.append(key)
|
|
93
96
|
|
|
94
|
-
ordered: list[tuple[str, ...]] = []
|
|
97
|
+
ordered: list[tuple[tuple[str, ...], int]] = []
|
|
95
98
|
# Explicit stack rather than recursion: nesting depth is user-controlled.
|
|
96
|
-
stack =
|
|
99
|
+
stack = [(key, 0) for key in reversed(roots)]
|
|
97
100
|
while stack:
|
|
98
|
-
key = stack.pop()
|
|
99
|
-
ordered.append(key)
|
|
100
|
-
stack.extend(reversed(children.get(key, [])))
|
|
101
|
+
key, depth = stack.pop()
|
|
102
|
+
ordered.append((key, depth))
|
|
103
|
+
stack.extend((child, depth + 1) for child in reversed(children.get(key, [])))
|
|
101
104
|
return ordered
|
|
102
105
|
|
|
103
106
|
|
|
107
|
+
def _top_level_time(timings: dict[tuple[str, ...], _TimesDict]) -> float:
|
|
108
|
+
"""Sum the sections with no recorded parent, matching the roots of ``_ordered_by_hierarchy``.
|
|
109
|
+
|
|
110
|
+
A parent goes missing when timings are cleared while it is active; its children that
|
|
111
|
+
finish afterwards are then top-level and must count toward the total.
|
|
112
|
+
"""
|
|
113
|
+
return sum(
|
|
114
|
+
(info["elapsed_time"] for key, info in timings.items() if len(key) == 1 or key[:-1] not in timings),
|
|
115
|
+
0.0,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
104
119
|
class _ExecutionTimer:
|
|
105
120
|
"""Registry of named, nestable timing sections, shared through ``_TIMER``."""
|
|
106
121
|
|
|
@@ -134,18 +149,34 @@ class _ExecutionTimer:
|
|
|
134
149
|
_ = _ACTIVE_CONTEXT.set(_Frame(full_name, category, time.perf_counter(), entry, parent))
|
|
135
150
|
|
|
136
151
|
def stop_timer(self, name: str) -> None:
|
|
137
|
-
"""Stop timing a section and accumulate its elapsed time.
|
|
152
|
+
"""Stop timing a section and accumulate its elapsed time.
|
|
153
|
+
|
|
154
|
+
Never raises, unless warnings are configured as errors: an exception here would replace
|
|
155
|
+
one already propagating from the timed block. Exiting past still-active inner sections
|
|
156
|
+
(typically a suspended generator that holds one open) discards them with a warning,
|
|
157
|
+
after recording the exit, so the stack cannot stay corrupted.
|
|
158
|
+
Exiting a section that is no longer active, such as one discarded that way when its
|
|
159
|
+
generator is finally closed, does nothing.
|
|
160
|
+
"""
|
|
138
161
|
end_time = time.perf_counter()
|
|
139
|
-
|
|
162
|
+
active = _ACTIVE_CONTEXT.get()
|
|
163
|
+
frame = active
|
|
164
|
+
while frame is not None and frame.path[-1] != name:
|
|
165
|
+
frame = frame.parent
|
|
140
166
|
if frame is None:
|
|
141
167
|
return
|
|
142
|
-
if frame.path[-1] != name:
|
|
143
|
-
raise RuntimeError(f"Cannot stop '{name}' while '{frame.path[-1]}' is active.")
|
|
144
168
|
with self._lock:
|
|
145
169
|
# A clear detaches this entry from the registry. Updating the detached object
|
|
146
170
|
# cannot resurrect an old sample or add it to a replacement at the same path.
|
|
147
171
|
frame.entry["elapsed_time"] += end_time - frame.start_time
|
|
148
172
|
_ = _ACTIVE_CONTEXT.set(frame.parent)
|
|
173
|
+
if frame is not active and active is not None:
|
|
174
|
+
# Warn only once the state is consistent: warnings configured as errors raise here.
|
|
175
|
+
msg = (
|
|
176
|
+
f"Section '{name}' exited while '{active.path[-1]}' was still active; discarding the "
|
|
177
|
+
"unfinished inner sections. Close sections before a generator yields."
|
|
178
|
+
)
|
|
179
|
+
warnings.warn(msg, RuntimeWarning, stacklevel=3)
|
|
149
180
|
|
|
150
181
|
def _resolve(self, *, flatten: bool) -> dict[tuple[str, ...], _TimesDict]:
|
|
151
182
|
snapshot = self.snapshot()
|
|
@@ -153,25 +184,25 @@ class _ExecutionTimer:
|
|
|
153
184
|
|
|
154
185
|
def report_timings(self, *, flatten: bool = True) -> str:
|
|
155
186
|
"""Build a report of all sections with duration and percentage of total time."""
|
|
156
|
-
|
|
157
|
-
if not
|
|
158
|
-
_LOGGER.warning("No timings to report.")
|
|
187
|
+
snapshot = self.snapshot()
|
|
188
|
+
if not snapshot:
|
|
159
189
|
return ""
|
|
160
190
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
191
|
+
# Total the unflattened paths, like get_total_time and the JSON export.
|
|
192
|
+
total_time = _top_level_time(snapshot)
|
|
193
|
+
timings = _flatten(snapshot) if flatten else snapshot
|
|
194
|
+
report = [f"Total time: {total_time:.4f} s.\n"]
|
|
195
|
+
for key, depth in _ordered_by_hierarchy(timings):
|
|
164
196
|
elapsed_time = timings[key]["elapsed_time"]
|
|
165
197
|
percentage = (elapsed_time / total_time) * 100 if total_time else 0.0
|
|
166
|
-
report.append(f"{'.. ' *
|
|
198
|
+
report.append(f"{'.. ' * depth}{key[-1]}: {elapsed_time:.4f} s ({percentage:.2f}%)")
|
|
167
199
|
return "\n".join(report)
|
|
168
200
|
|
|
169
|
-
def compute_total_time(self
|
|
201
|
+
def compute_total_time(self) -> float:
|
|
170
202
|
"""Compute total elapsed time across all top-level sections."""
|
|
171
|
-
# Counter merging cannot change the sum
|
|
172
|
-
_ = flatten
|
|
203
|
+
# Counter merging cannot change the sum, so there is no snapshot to copy or flatten.
|
|
173
204
|
with self._lock:
|
|
174
|
-
return
|
|
205
|
+
return _top_level_time(self.timings)
|
|
175
206
|
|
|
176
207
|
def compute_total_category_time(self, category: str) -> float:
|
|
177
208
|
"""Compute total elapsed time in a category, counting only top-most entries of that category."""
|
|
@@ -232,16 +263,16 @@ class TimerContext:
|
|
|
232
263
|
def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
|
|
233
264
|
self.name: str = _build_name_with_counter(name, counter)
|
|
234
265
|
self.category: str = category
|
|
235
|
-
self.
|
|
266
|
+
self._timer: _ExecutionTimer = _TIMER
|
|
236
267
|
|
|
237
268
|
def __enter__(self) -> TimerContext:
|
|
238
|
-
self.
|
|
269
|
+
self._timer.start_timer(self.name, self.category)
|
|
239
270
|
return self
|
|
240
271
|
|
|
241
272
|
def __exit__(
|
|
242
273
|
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
|
|
243
274
|
) -> None:
|
|
244
|
-
self.
|
|
275
|
+
self._timer.stop_timer(self.name)
|
|
245
276
|
|
|
246
277
|
def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
|
|
247
278
|
"""Decorate a function to time its execution under this context.
|
|
@@ -299,18 +330,20 @@ def _basic_name_without_counter(name: str) -> str:
|
|
|
299
330
|
|
|
300
331
|
|
|
301
332
|
def get_execution_times_report(*, flatten: bool = True) -> str:
|
|
302
|
-
"""Get a formatted report of all recorded sections; flatten counters if requested."""
|
|
333
|
+
"""Get a formatted report of all recorded sections (``""`` if none); flatten counters if requested."""
|
|
303
334
|
return _TIMER.report_timings(flatten=flatten)
|
|
304
335
|
|
|
305
336
|
|
|
306
337
|
def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
|
|
307
|
-
"""Log the execution-times report at INFO level
|
|
338
|
+
"""Log the execution-times report at INFO level, or a warning if there is nothing to report."""
|
|
308
339
|
target = logger if logger is not None else _LOGGER
|
|
309
340
|
if target.isEnabledFor(logging.INFO):
|
|
310
341
|
report = get_execution_times_report(flatten=flatten)
|
|
311
|
-
# An empty report has already been warned about; logging it would add a blank record.
|
|
312
342
|
if report:
|
|
313
|
-
|
|
343
|
+
# Start the multi-line report on its own line, after the log record's prefix.
|
|
344
|
+
target.info("\n%s", report)
|
|
345
|
+
else:
|
|
346
|
+
target.warning("No timings to report.")
|
|
314
347
|
|
|
315
348
|
|
|
316
349
|
def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
|
|
@@ -329,7 +362,7 @@ def _build_payload(*, flatten: bool = True) -> TimingsPayload:
|
|
|
329
362
|
"time": round(timings[key]["elapsed_time"], 6),
|
|
330
363
|
"category": timings[key]["category"],
|
|
331
364
|
}
|
|
332
|
-
for key in _ordered_by_hierarchy(timings)
|
|
365
|
+
for key, _ in _ordered_by_hierarchy(timings)
|
|
333
366
|
]
|
|
334
367
|
category_totals: dict[str, float] = {}
|
|
335
368
|
for key, info in snapshot.items():
|
|
@@ -337,7 +370,7 @@ def _build_payload(*, flatten: bool = True) -> TimingsPayload:
|
|
|
337
370
|
if not _has_ancestor_with_category(snapshot, key, category):
|
|
338
371
|
category_totals[category] = category_totals.get(category, 0.0) + info["elapsed_time"]
|
|
339
372
|
return {
|
|
340
|
-
"total_time": round(
|
|
373
|
+
"total_time": round(_top_level_time(snapshot), 6),
|
|
341
374
|
"total_category_time": {cat: round(category_totals[cat], 6) for cat in sorted(category_totals)},
|
|
342
375
|
"sections": sections,
|
|
343
376
|
}
|
|
@@ -355,13 +388,9 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
|
|
|
355
388
|
return out
|
|
356
389
|
|
|
357
390
|
|
|
358
|
-
def get_total_time(
|
|
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)
|
|
391
|
+
def get_total_time() -> float:
|
|
392
|
+
"""Get total elapsed seconds across all top-level sections."""
|
|
393
|
+
return _TIMER.compute_total_time()
|
|
365
394
|
|
|
366
395
|
|
|
367
396
|
def get_total_category_time(category: str) -> float:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: executiontimer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.1
|
|
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
|
|
@@ -11,7 +11,7 @@ Author: Sebastian Yde Madsen
|
|
|
11
11
|
License-Expression: MIT
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Keywords: benchmark,context-manager,decorator,execution-time,instrumentation,performance,profiling,timing
|
|
14
|
-
Classifier: Development Status ::
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
16
16
|
Classifier: Intended Audience :: Science/Research
|
|
17
17
|
Classifier: Operating System :: OS Independent
|
|
@@ -103,7 +103,7 @@ print(get_execution_times_report())
|
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
```text
|
|
106
|
-
Total
|
|
106
|
+
Total time: 0.3156 s.
|
|
107
107
|
|
|
108
108
|
load_data: 0.1219 s (38.62%)
|
|
109
109
|
solve: 0.1937 s (61.38%)
|
|
@@ -156,6 +156,11 @@ Flattening removes the final integer suffix (including negative counters). Other
|
|
|
156
156
|
bracketed names such as `array[index]` are preserved. If merged entries have different
|
|
157
157
|
categories, the category from the most recently entered section is used.
|
|
158
158
|
|
|
159
|
+
Each counter value is stored as its own section until `clear_execution_timings()` is
|
|
160
|
+
called, so a long-running process that times an unbounded loop with `counter=` keeps
|
|
161
|
+
growing the registry. Clear it periodically, or drop `counter=` to accumulate into one
|
|
162
|
+
section.
|
|
163
|
+
|
|
159
164
|
### Categories
|
|
160
165
|
|
|
161
166
|
Categories are plain strings — use whatever fits your domain:
|
|
@@ -252,6 +257,12 @@ across a `yield` therefore also contains whatever the caller times while the gen
|
|
|
252
257
|
paused, and its duration includes that paused time. Close sections before yielding, or
|
|
253
258
|
time the loop that consumes the generator instead.
|
|
254
259
|
|
|
260
|
+
If the caller's section exits while a paused generator's section is still open, the
|
|
261
|
+
timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
|
|
262
|
+
discards the generator's unfinished one, so later sections nest correctly. Closing that
|
|
263
|
+
generator afterwards does nothing. If warnings are configured as errors, the warning is
|
|
264
|
+
raised only after that cleanup, so the timings and nesting stay consistent.
|
|
265
|
+
|
|
255
266
|
### Reusing contexts and clearing timings
|
|
256
267
|
|
|
257
268
|
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
@@ -266,14 +277,15 @@ for item in items:
|
|
|
266
277
|
|
|
267
278
|
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
268
279
|
samples from sections that were already active, without disturbing their nesting stack.
|
|
269
|
-
Sections started after the clear are recorded normally
|
|
280
|
+
Sections started after the clear are recorded normally; if their parent was cleared, they
|
|
281
|
+
are reported as top-level sections and count toward the total. Reports include completed calls;
|
|
270
282
|
an active section's current duration is added only when it exits.
|
|
271
283
|
|
|
272
284
|
### Measuring overhead
|
|
273
285
|
|
|
274
286
|
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
275
287
|
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).
|
|
288
|
+
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
277
289
|
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
278
290
|
|
|
279
291
|
## API
|
|
@@ -281,12 +293,12 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
|
|
|
281
293
|
| Function | Description |
|
|
282
294
|
| --- | --- |
|
|
283
295
|
| `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
|
|
284
|
-
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections. |
|
|
285
|
-
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level. |
|
|
296
|
+
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
297
|
+
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
|
|
286
298
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
287
299
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
288
300
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
289
|
-
| `get_total_time(
|
|
301
|
+
| `get_total_time()` | Total seconds across all top-level sections. |
|
|
290
302
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
291
303
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
292
304
|
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
@@ -309,9 +321,9 @@ uv run ruff check --fix && uv run ruff format
|
|
|
309
321
|
uv run basedpyright
|
|
310
322
|
```
|
|
311
323
|
|
|
312
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, and
|
|
313
|
-
[CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
324
|
+
See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
|
|
325
|
+
[CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
|
|
314
326
|
|
|
315
327
|
## License
|
|
316
328
|
|
|
317
|
-
MIT — see [LICENSE](LICENSE).
|
|
329
|
+
MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
execution_timer/__init__.py,sha256=SDCT34iz3KKhr1CyiCpvMrjjdf1umy55sScSp4bKUBo,959
|
|
2
|
+
execution_timer/_timer.py,sha256=4RP4SDki9TasaCfk56lmJX54EiYnQsvHOd5WnZuuoxI,16778
|
|
3
|
+
execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
executiontimer-1.0.1.dist-info/METADATA,sha256=o5McC2gZg2rh2G65qv0Hh6tdmGo6Mk_TfPfym_OmKhA,13612
|
|
5
|
+
executiontimer-1.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
executiontimer-1.0.1.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
|
|
7
|
+
executiontimer-1.0.1.dist-info/RECORD,,
|
|
@@ -1,7 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
|
File without changes
|