executiontimer 0.1.1__py3-none-any.whl → 1.0.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 +44 -18
- {executiontimer-0.1.1.dist-info → executiontimer-1.0.0.dist-info}/METADATA +42 -13
- executiontimer-1.0.0.dist-info/RECORD +7 -0
- {executiontimer-0.1.1.dist-info → executiontimer-1.0.0.dist-info}/WHEEL +1 -1
- executiontimer-0.1.1.dist-info/RECORD +0 -7
- {executiontimer-0.1.1.dist-info → executiontimer-1.0.0.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
|
|
@@ -134,13 +135,27 @@ class _ExecutionTimer:
|
|
|
134
135
|
_ = _ACTIVE_CONTEXT.set(_Frame(full_name, category, time.perf_counter(), entry, parent))
|
|
135
136
|
|
|
136
137
|
def stop_timer(self, name: str) -> None:
|
|
137
|
-
"""Stop timing a section and accumulate its elapsed time.
|
|
138
|
+
"""Stop timing a section and accumulate its elapsed time.
|
|
139
|
+
|
|
140
|
+
Never raises: an exception here would replace one already propagating from the timed
|
|
141
|
+
block. Exiting past still-active inner sections (typically a suspended generator that
|
|
142
|
+
holds one open) discards them with a warning, so the stack cannot stay corrupted.
|
|
143
|
+
Exiting a section that is no longer active, such as one discarded that way when its
|
|
144
|
+
generator is finally closed, does nothing.
|
|
145
|
+
"""
|
|
138
146
|
end_time = time.perf_counter()
|
|
139
|
-
|
|
147
|
+
active = _ACTIVE_CONTEXT.get()
|
|
148
|
+
frame = active
|
|
149
|
+
while frame is not None and frame.path[-1] != name:
|
|
150
|
+
frame = frame.parent
|
|
140
151
|
if frame is None:
|
|
141
152
|
return
|
|
142
|
-
if frame
|
|
143
|
-
|
|
153
|
+
if frame is not active and active is not None:
|
|
154
|
+
msg = (
|
|
155
|
+
f"Section '{name}' exited while '{active.path[-1]}' was still active; discarding the "
|
|
156
|
+
"unfinished inner sections. Close sections before a generator yields."
|
|
157
|
+
)
|
|
158
|
+
warnings.warn(msg, RuntimeWarning, stacklevel=3)
|
|
144
159
|
with self._lock:
|
|
145
160
|
# A clear detaches this entry from the registry. Updating the detached object
|
|
146
161
|
# cannot resurrect an old sample or add it to a replacement at the same path.
|
|
@@ -155,21 +170,19 @@ class _ExecutionTimer:
|
|
|
155
170
|
"""Build a report of all sections with duration and percentage of total time."""
|
|
156
171
|
timings = self._resolve(flatten=flatten)
|
|
157
172
|
if not timings:
|
|
158
|
-
_LOGGER.warning("No timings to report.")
|
|
159
173
|
return ""
|
|
160
174
|
|
|
161
175
|
total_time = sum(info["elapsed_time"] for key, info in timings.items() if len(key) == 1)
|
|
162
|
-
report = [f"
|
|
176
|
+
report = [f"Total time: {total_time:.4f} s.\n"]
|
|
163
177
|
for key in _ordered_by_hierarchy(timings):
|
|
164
178
|
elapsed_time = timings[key]["elapsed_time"]
|
|
165
179
|
percentage = (elapsed_time / total_time) * 100 if total_time else 0.0
|
|
166
180
|
report.append(f"{'.. ' * (len(key) - 1)}{key[-1]}: {elapsed_time:.4f} s ({percentage:.2f}%)")
|
|
167
181
|
return "\n".join(report)
|
|
168
182
|
|
|
169
|
-
def compute_total_time(self
|
|
183
|
+
def compute_total_time(self) -> float:
|
|
170
184
|
"""Compute total elapsed time across all top-level sections."""
|
|
171
|
-
# Counter merging cannot change the sum
|
|
172
|
-
_ = flatten
|
|
185
|
+
# Counter merging cannot change the sum, so there is no snapshot to copy or flatten.
|
|
173
186
|
with self._lock:
|
|
174
187
|
return sum((info["elapsed_time"] for key, info in self.timings.items() if len(key) == 1), 0.0)
|
|
175
188
|
|
|
@@ -232,23 +245,31 @@ class TimerContext:
|
|
|
232
245
|
def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
|
|
233
246
|
self.name: str = _build_name_with_counter(name, counter)
|
|
234
247
|
self.category: str = category
|
|
235
|
-
self.
|
|
248
|
+
self._timer: _ExecutionTimer = _TIMER
|
|
236
249
|
|
|
237
250
|
def __enter__(self) -> TimerContext:
|
|
238
|
-
self.
|
|
251
|
+
self._timer.start_timer(self.name, self.category)
|
|
239
252
|
return self
|
|
240
253
|
|
|
241
254
|
def __exit__(
|
|
242
255
|
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
|
|
243
256
|
) -> None:
|
|
244
|
-
self.
|
|
257
|
+
self._timer.stop_timer(self.name)
|
|
245
258
|
|
|
246
259
|
def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
|
|
247
260
|
"""Decorate a function to time its execution under this context.
|
|
248
261
|
|
|
249
262
|
Coroutine functions are wrapped so the timing spans the entire ``await``, not just
|
|
250
|
-
creation of the coroutine object.
|
|
263
|
+
creation of the coroutine object. Generator functions are rejected: a wrapper would
|
|
264
|
+
time only creation of the generator object, not its iteration.
|
|
251
265
|
"""
|
|
266
|
+
if inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func):
|
|
267
|
+
msg = (
|
|
268
|
+
f"Cannot decorate generator function {func.__qualname__!r}: only creating the generator "
|
|
269
|
+
"would be timed. Time the loop that consumes it, or its body between yields, with a "
|
|
270
|
+
"'with TimerContext(...)' block instead."
|
|
271
|
+
)
|
|
272
|
+
raise TypeError(msg)
|
|
252
273
|
if inspect.iscoroutinefunction(func):
|
|
253
274
|
# ``iscoroutinefunction`` narrows nothing useful for the type checker, so bridge
|
|
254
275
|
# through an explicitly typed helper instead of leaking ``Any`` into the signature.
|
|
@@ -291,15 +312,20 @@ def _basic_name_without_counter(name: str) -> str:
|
|
|
291
312
|
|
|
292
313
|
|
|
293
314
|
def get_execution_times_report(*, flatten: bool = True) -> str:
|
|
294
|
-
"""Get a formatted report of all recorded sections; flatten counters if requested."""
|
|
315
|
+
"""Get a formatted report of all recorded sections (``""`` if none); flatten counters if requested."""
|
|
295
316
|
return _TIMER.report_timings(flatten=flatten)
|
|
296
317
|
|
|
297
318
|
|
|
298
319
|
def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
|
|
299
|
-
"""Log the execution-times report at INFO level
|
|
320
|
+
"""Log the execution-times report at INFO level, or a warning if there is nothing to report."""
|
|
300
321
|
target = logger if logger is not None else _LOGGER
|
|
301
322
|
if target.isEnabledFor(logging.INFO):
|
|
302
|
-
|
|
323
|
+
report = get_execution_times_report(flatten=flatten)
|
|
324
|
+
if report:
|
|
325
|
+
# Start the multi-line report on its own line, after the log record's prefix.
|
|
326
|
+
target.info("\n%s", report)
|
|
327
|
+
else:
|
|
328
|
+
target.warning("No timings to report.")
|
|
303
329
|
|
|
304
330
|
|
|
305
331
|
def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
|
|
@@ -344,9 +370,9 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
|
|
|
344
370
|
return out
|
|
345
371
|
|
|
346
372
|
|
|
347
|
-
def get_total_time(
|
|
373
|
+
def get_total_time() -> float:
|
|
348
374
|
"""Get total elapsed seconds across all top-level sections."""
|
|
349
|
-
return _TIMER.compute_total_time(
|
|
375
|
+
return _TIMER.compute_total_time()
|
|
350
376
|
|
|
351
377
|
|
|
352
378
|
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.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
|
|
@@ -11,20 +11,22 @@ 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
|
|
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
|
>
|
|
@@ -100,7 +103,7 @@ print(get_execution_times_report())
|
|
|
100
103
|
```
|
|
101
104
|
|
|
102
105
|
```text
|
|
103
|
-
Total
|
|
106
|
+
Total time: 0.3156 s.
|
|
104
107
|
|
|
105
108
|
load_data: 0.1219 s (38.62%)
|
|
106
109
|
solve: 0.1937 s (61.38%)
|
|
@@ -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
|
|
@@ -149,6 +156,11 @@ Flattening removes the final integer suffix (including negative counters). Other
|
|
|
149
156
|
bracketed names such as `array[index]` are preserved. If merged entries have different
|
|
150
157
|
categories, the category from the most recently entered section is used.
|
|
151
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
|
+
|
|
152
164
|
### Categories
|
|
153
165
|
|
|
154
166
|
Categories are plain strings — use whatever fits your domain:
|
|
@@ -233,6 +245,23 @@ New asyncio tasks inherit the timing context in which they are created. Their se
|
|
|
233
245
|
nest under that parent; changes to each task's active stack remain independent. Await
|
|
234
246
|
child tasks inside the parent section if you want the parent duration to include them.
|
|
235
247
|
|
|
248
|
+
New threads do *not* inherit the timing context, so sections recorded in a thread
|
|
249
|
+
appear at the top level of the report, and their time is added to the total alongside
|
|
250
|
+
the section that started the thread. To nest thread work under the current section, run
|
|
251
|
+
it with `contextvars.copy_context().run(...)` or `asyncio.to_thread(...)`. Either way,
|
|
252
|
+
concurrent threads accumulate overlapping time. (Free-threaded builds of Python 3.14 make
|
|
253
|
+
threads inherit the context by default.)
|
|
254
|
+
|
|
255
|
+
Generators run in their caller's context. A `with TimerContext(...)` block that stays open
|
|
256
|
+
across a `yield` therefore also contains whatever the caller times while the generator is
|
|
257
|
+
paused, and its duration includes that paused time. Close sections before yielding, or
|
|
258
|
+
time the loop that consumes the generator instead.
|
|
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.
|
|
264
|
+
|
|
236
265
|
### Reusing contexts and clearing timings
|
|
237
266
|
|
|
238
267
|
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
@@ -254,7 +283,7 @@ an active section's current duration is added only when it exits.
|
|
|
254
283
|
|
|
255
284
|
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
256
285
|
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
257
|
-
results using the same interpreter and machine; see [benchmarks/README.md](benchmarks/README.md).
|
|
286
|
+
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
258
287
|
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
259
288
|
|
|
260
289
|
## API
|
|
@@ -262,12 +291,12 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
|
|
|
262
291
|
| Function | Description |
|
|
263
292
|
| --- | --- |
|
|
264
293
|
| `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
|
|
265
|
-
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections. |
|
|
266
|
-
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level. |
|
|
294
|
+
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
295
|
+
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
|
|
267
296
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
268
297
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
269
298
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
270
|
-
| `get_total_time(
|
|
299
|
+
| `get_total_time()` | Total seconds across all top-level sections. |
|
|
271
300
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
272
301
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
273
302
|
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
@@ -290,9 +319,9 @@ uv run ruff check --fix && uv run ruff format
|
|
|
290
319
|
uv run basedpyright
|
|
291
320
|
```
|
|
292
321
|
|
|
293
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, and
|
|
294
|
-
[CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
322
|
+
See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
|
|
323
|
+
[CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
|
|
295
324
|
|
|
296
325
|
## License
|
|
297
326
|
|
|
298
|
-
MIT — see [LICENSE](LICENSE).
|
|
327
|
+
MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
execution_timer/__init__.py,sha256=gv9IbtkZkesfgWFh1oDSJ5DDr2BJFVxmKtY32jcUzbI,959
|
|
2
|
+
execution_timer/_timer.py,sha256=iylgfJ1vBeKimsW5A3NVN5KSUvZs75Km-N4guoHKFPo,15932
|
|
3
|
+
execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
executiontimer-1.0.0.dist-info/METADATA,sha256=ZtN7WHMrB8gTaGIy0VQLHDtu2ySFvd8oZx56KmA5F8s,13386
|
|
5
|
+
executiontimer-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
executiontimer-1.0.0.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
|
|
7
|
+
executiontimer-1.0.0.dist-info/RECORD,,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
execution_timer/__init__.py,sha256=WC6Cn6-mGTEHh1386OJ--hrAFaa9Z6eSBFze21brEfw,959
|
|
2
|
-
execution_timer/_timer.py,sha256=RbLD7HshcDBlcz0II7JiLZduuNr6Dao0D_d0gs9cuww,14526
|
|
3
|
-
execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
executiontimer-0.1.1.dist-info/METADATA,sha256=sAJT1E9gFJHlfd2oVwAwcrWYebEhq3xhgmjMIvv3NS4,11374
|
|
5
|
-
executiontimer-0.1.1.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
6
|
-
executiontimer-0.1.1.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
|
|
7
|
-
executiontimer-0.1.1.dist-info/RECORD,,
|
|
File without changes
|