executiontimer 0.1.1__tar.gz → 0.2.0__tar.gz
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.
- {executiontimer-0.1.1 → executiontimer-0.2.0}/CHANGELOG.md +29 -1
- {executiontimer-0.1.1 → executiontimer-0.2.0}/CONTRIBUTING.md +3 -2
- {executiontimer-0.1.1 → executiontimer-0.2.0}/PKG-INFO +24 -5
- {executiontimer-0.1.1 → executiontimer-0.2.0}/README.md +20 -3
- {executiontimer-0.1.1 → executiontimer-0.2.0}/pyproject.toml +5 -3
- {executiontimer-0.1.1 → executiontimer-0.2.0}/src/execution_timer/__init__.py +1 -1
- {executiontimer-0.1.1 → executiontimer-0.2.0}/src/execution_timer/_timer.py +18 -3
- {executiontimer-0.1.1 → executiontimer-0.2.0}/tests/execution_timer_test.py +35 -1
- {executiontimer-0.1.1 → executiontimer-0.2.0}/.gitignore +0 -0
- {executiontimer-0.1.1 → executiontimer-0.2.0}/LICENSE +0 -0
- {executiontimer-0.1.1 → executiontimer-0.2.0}/benchmarks/README.md +0 -0
- {executiontimer-0.1.1 → executiontimer-0.2.0}/benchmarks/overhead.py +0 -0
- {executiontimer-0.1.1 → executiontimer-0.2.0}/src/execution_timer/py.typed +0 -0
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Decorating a generator function or an async generator function now raises `TypeError`.
|
|
15
|
+
Previously it silently timed only the creation of the generator object, which recorded
|
|
16
|
+
microseconds regardless of how long iteration took.
|
|
17
|
+
- Python 3.11 is now supported; the minimum was 3.12 although nothing required it.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- `log_execution_times()` no longer logs an empty `INFO` record, after its warning, when
|
|
22
|
+
there are no timings.
|
|
23
|
+
|
|
24
|
+
### Documentation
|
|
25
|
+
|
|
26
|
+
- Explain that threads do not inherit the timing context, and how to nest thread work under
|
|
27
|
+
a section.
|
|
28
|
+
- Explain how a section held open across a generator's `yield` absorbs the caller's sections.
|
|
29
|
+
- Note that `flatten` has no effect on `get_total_time`.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- CI tests Python 3.11 and free-threaded Python 3.14t, and the package declares
|
|
34
|
+
free-threading support.
|
|
35
|
+
- Workflows run with a read-only token by default and do not persist checkout credentials.
|
|
36
|
+
|
|
10
37
|
## [0.1.1] - 2026-09-20
|
|
11
38
|
|
|
12
39
|
### Fixed
|
|
@@ -58,6 +85,7 @@ First public release on PyPI.
|
|
|
58
85
|
`py.typed` marker so type checkers use the inline annotations.
|
|
59
86
|
- `__version__` attribute on the package.
|
|
60
87
|
|
|
61
|
-
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v0.
|
|
88
|
+
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v0.2.0...HEAD
|
|
89
|
+
[0.2.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...v0.2.0
|
|
62
90
|
[0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
|
|
63
91
|
[0.1.0]: https://github.com/seba2390/ExecutionTimer/releases/tag/v0.1.0
|
|
@@ -26,11 +26,12 @@ uv run basedpyright
|
|
|
26
26
|
All four must pass. Coverage is enforced at 95%; the current suite has 100% statement
|
|
27
27
|
and branch coverage. Preserve coverage when adding or changing behavior.
|
|
28
28
|
|
|
29
|
-
The test suite is also run against Python 3.12, 3.13
|
|
29
|
+
The test suite is also run against Python 3.11, 3.12, 3.13, 3.14 and free-threaded 3.14t
|
|
30
|
+
on Linux, macOS and Windows.
|
|
30
31
|
To check another interpreter locally:
|
|
31
32
|
|
|
32
33
|
```bash
|
|
33
|
-
uv run --python 3.
|
|
34
|
+
uv run --python 3.11 pytest
|
|
34
35
|
```
|
|
35
36
|
|
|
36
37
|
For changes to recording or reporting performance, compare the benchmark on the same
|
|
@@ -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
|
|
@@ -233,6 +240,18 @@ New asyncio tasks inherit the timing context in which they are created. Their se
|
|
|
233
240
|
nest under that parent; changes to each task's active stack remain independent. Await
|
|
234
241
|
child tasks inside the parent section if you want the parent duration to include them.
|
|
235
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
|
+
|
|
236
255
|
### Reusing contexts and clearing timings
|
|
237
256
|
|
|
238
257
|
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
@@ -267,7 +286,7 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
|
|
|
267
286
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
268
287
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
269
288
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
270
|
-
| `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). |
|
|
271
290
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
272
291
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
273
292
|
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
@@ -26,7 +26,8 @@ Zero dependencies. Fully type annotated. Works with threads and `asyncio`.
|
|
|
26
26
|
- 🌳 **Automatic hierarchy** — nesting `with` blocks nests the report, no wiring required
|
|
27
27
|
- 🏷️ **User-defined categories** — tag sections with any string (`"gpu"`, `"io"`, `"db"`) and get per-category totals
|
|
28
28
|
- ⚡ **Native async** — decorating an `async def` times the whole `await`, not the coroutine object
|
|
29
|
-
- 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task
|
|
29
|
+
- 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task,
|
|
30
|
+
including on free-threaded Python builds
|
|
30
31
|
- 🔢 **Loop counters** — time each iteration separately, then merge them back together
|
|
31
32
|
- 📤 **JSON export** — structured output for dashboards, CI, or an LLM
|
|
32
33
|
- 🚫 **Nesting rules** — optionally forbid one category inside another to catch mistakes early
|
|
@@ -42,7 +43,7 @@ pip install executiontimer
|
|
|
42
43
|
uv add executiontimer
|
|
43
44
|
```
|
|
44
45
|
|
|
45
|
-
Requires Python 3.
|
|
46
|
+
Requires Python 3.11+.
|
|
46
47
|
|
|
47
48
|
> **Note** — the install name is `executiontimer`, the import name is `execution_timer`:
|
|
48
49
|
>
|
|
@@ -102,6 +103,10 @@ Coroutine functions are supported natively — the timing spans the entire `awai
|
|
|
102
103
|
async def fetch(url: str) -> bytes: ...
|
|
103
104
|
```
|
|
104
105
|
|
|
106
|
+
Generator functions (including `async` generators) cannot be decorated and raise a
|
|
107
|
+
`TypeError`: the decorator would time only the creation of the generator object, not its
|
|
108
|
+
iteration. Time the loop that consumes the generator with a `with` block instead.
|
|
109
|
+
|
|
105
110
|
### Counters
|
|
106
111
|
|
|
107
112
|
Pass `counter=i` to time loop iterations separately. The report merges them by default
|
|
@@ -204,6 +209,18 @@ New asyncio tasks inherit the timing context in which they are created. Their se
|
|
|
204
209
|
nest under that parent; changes to each task's active stack remain independent. Await
|
|
205
210
|
child tasks inside the parent section if you want the parent duration to include them.
|
|
206
211
|
|
|
212
|
+
New threads do *not* inherit the timing context, so sections recorded in a thread
|
|
213
|
+
appear at the top level of the report, and their time is added to the total alongside
|
|
214
|
+
the section that started the thread. To nest thread work under the current section, run
|
|
215
|
+
it with `contextvars.copy_context().run(...)` or `asyncio.to_thread(...)`. Either way,
|
|
216
|
+
concurrent threads accumulate overlapping time. (Free-threaded builds of Python 3.14 make
|
|
217
|
+
threads inherit the context by default.)
|
|
218
|
+
|
|
219
|
+
Generators run in their caller's context. A `with TimerContext(...)` block that stays open
|
|
220
|
+
across a `yield` therefore also contains whatever the caller times while the generator is
|
|
221
|
+
paused, and its duration includes that paused time. Close sections before yielding, or
|
|
222
|
+
time the loop that consumes the generator instead.
|
|
223
|
+
|
|
207
224
|
### Reusing contexts and clearing timings
|
|
208
225
|
|
|
209
226
|
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
@@ -238,7 +255,7 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
|
|
|
238
255
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
239
256
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
240
257
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
241
|
-
| `get_total_time(*, flatten=True)` | Total seconds across all top-level sections. |
|
|
258
|
+
| `get_total_time(*, flatten=True)` | Total seconds across all top-level sections (`flatten` has no effect on the sum). |
|
|
242
259
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
243
260
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
244
261
|
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
@@ -5,7 +5,7 @@ description = "Hierarchical execution timing with user-defined categories."
|
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
8
|
-
requires-python = ">=3.
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
9
|
authors = [{ name = "Sebastian Yde Madsen" }]
|
|
10
10
|
keywords = [
|
|
11
11
|
"timing",
|
|
@@ -23,9 +23,11 @@ classifiers = [
|
|
|
23
23
|
"Intended Audience :: Science/Research",
|
|
24
24
|
"Operating System :: OS Independent",
|
|
25
25
|
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
26
27
|
"Programming Language :: Python :: 3.12",
|
|
27
28
|
"Programming Language :: Python :: 3.13",
|
|
28
29
|
"Programming Language :: Python :: 3.14",
|
|
30
|
+
"Programming Language :: Python :: Free Threading :: 3 - Stable",
|
|
29
31
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
30
32
|
"Topic :: Software Development",
|
|
31
33
|
"Topic :: Software Development :: Testing",
|
|
@@ -85,7 +87,7 @@ fail_under = 95
|
|
|
85
87
|
exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]
|
|
86
88
|
|
|
87
89
|
[tool.ruff]
|
|
88
|
-
target-version = "
|
|
90
|
+
target-version = "py311"
|
|
89
91
|
line-length = 120
|
|
90
92
|
src = ["src", "tests"]
|
|
91
93
|
|
|
@@ -103,5 +105,5 @@ select = [
|
|
|
103
105
|
|
|
104
106
|
[tool.basedpyright]
|
|
105
107
|
typeCheckingMode = "recommended"
|
|
106
|
-
pythonVersion = "3.
|
|
108
|
+
pythonVersion = "3.11"
|
|
107
109
|
include = ["src", "tests"]
|
|
@@ -247,8 +247,16 @@ class TimerContext:
|
|
|
247
247
|
"""Decorate a function to time its execution under this context.
|
|
248
248
|
|
|
249
249
|
Coroutine functions are wrapped so the timing spans the entire ``await``, not just
|
|
250
|
-
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.
|
|
251
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)
|
|
252
260
|
if inspect.iscoroutinefunction(func):
|
|
253
261
|
# ``iscoroutinefunction`` narrows nothing useful for the type checker, so bridge
|
|
254
262
|
# through an explicitly typed helper instead of leaking ``Any`` into the signature.
|
|
@@ -299,7 +307,10 @@ def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None =
|
|
|
299
307
|
"""Log the execution-times report at INFO level; flatten counters if requested."""
|
|
300
308
|
target = logger if logger is not None else _LOGGER
|
|
301
309
|
if target.isEnabledFor(logging.INFO):
|
|
302
|
-
|
|
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)
|
|
303
314
|
|
|
304
315
|
|
|
305
316
|
def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
|
|
@@ -345,7 +356,11 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
|
|
|
345
356
|
|
|
346
357
|
|
|
347
358
|
def get_total_time(*, flatten: bool = True) -> float:
|
|
348
|
-
"""Get total elapsed seconds across all top-level sections.
|
|
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
|
+
"""
|
|
349
364
|
return _TIMER.compute_total_time(flatten=flatten)
|
|
350
365
|
|
|
351
366
|
|
|
@@ -7,7 +7,7 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
import threading
|
|
9
9
|
import time
|
|
10
|
-
from collections.abc import Iterator
|
|
10
|
+
from collections.abc import AsyncIterator, Iterator
|
|
11
11
|
from concurrent.futures import ThreadPoolExecutor
|
|
12
12
|
from contextlib import ExitStack
|
|
13
13
|
from pathlib import Path
|
|
@@ -380,6 +380,32 @@ class TestTimerContextDecorator:
|
|
|
380
380
|
assert ("outer",) in timings
|
|
381
381
|
assert ("outer", "inner") in timings
|
|
382
382
|
|
|
383
|
+
def test_decorating_a_generator_function_raises(self) -> None:
|
|
384
|
+
def numbers() -> Iterator[int]:
|
|
385
|
+
yield 1
|
|
386
|
+
|
|
387
|
+
with pytest.raises(TypeError, match=r"Cannot decorate generator function '.*numbers'"):
|
|
388
|
+
_ = TimerContext("gen")(numbers)
|
|
389
|
+
assert raw_timings() == {}
|
|
390
|
+
|
|
391
|
+
def test_decorating_an_async_generator_function_raises(self) -> None:
|
|
392
|
+
async def numbers() -> AsyncIterator[int]:
|
|
393
|
+
yield 1
|
|
394
|
+
|
|
395
|
+
with pytest.raises(TypeError, match=r"Cannot decorate generator function '.*numbers'"):
|
|
396
|
+
_ = TimerContext("agen")(numbers)
|
|
397
|
+
assert raw_timings() == {}
|
|
398
|
+
|
|
399
|
+
def test_decorator_accepts_a_function_returning_a_generator(self) -> None:
|
|
400
|
+
"""Only generator *functions* are rejected; a plain function may return an iterator."""
|
|
401
|
+
|
|
402
|
+
@TimerContext("factory")
|
|
403
|
+
def factory() -> Iterator[int]:
|
|
404
|
+
return iter([1, 2])
|
|
405
|
+
|
|
406
|
+
assert list(factory()) == [1, 2]
|
|
407
|
+
assert ("factory",) in raw_timings()
|
|
408
|
+
|
|
383
409
|
|
|
384
410
|
class TestExceptions:
|
|
385
411
|
def test_timing_recorded_when_body_raises(self) -> None:
|
|
@@ -695,6 +721,14 @@ class TestRobustness:
|
|
|
695
721
|
|
|
696
722
|
assert [record.name for record in caplog.records] == ["execution_timer._timer"]
|
|
697
723
|
|
|
724
|
+
def test_logging_an_empty_registry_emits_only_the_warning(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
725
|
+
with caplog.at_level(logging.INFO):
|
|
726
|
+
log_execution_times()
|
|
727
|
+
|
|
728
|
+
assert [(record.levelno, record.message) for record in caplog.records] == [
|
|
729
|
+
(logging.WARNING, "No timings to report.")
|
|
730
|
+
]
|
|
731
|
+
|
|
698
732
|
|
|
699
733
|
class TestRegressions:
|
|
700
734
|
@pytest.mark.parametrize("clear_between_calls", [False, True])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|