executiontimer 1.0.1__tar.gz → 1.0.2__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-1.0.1 → executiontimer-1.0.2}/CHANGELOG.md +25 -1
- {executiontimer-1.0.1 → executiontimer-1.0.2}/CONTRIBUTING.md +5 -4
- {executiontimer-1.0.1 → executiontimer-1.0.2}/PKG-INFO +8 -4
- {executiontimer-1.0.1 → executiontimer-1.0.2}/README.md +7 -3
- {executiontimer-1.0.1 → executiontimer-1.0.2}/benchmarks/README.md +12 -12
- {executiontimer-1.0.1 → executiontimer-1.0.2}/pyproject.toml +4 -1
- {executiontimer-1.0.1 → executiontimer-1.0.2}/src/execution_timer/__init__.py +1 -1
- {executiontimer-1.0.1 → executiontimer-1.0.2}/src/execution_timer/_timer.py +14 -3
- {executiontimer-1.0.1 → executiontimer-1.0.2}/tests/execution_timer_test.py +89 -2
- {executiontimer-1.0.1 → executiontimer-1.0.2}/.gitignore +0 -0
- {executiontimer-1.0.1 → executiontimer-1.0.2}/LICENSE +0 -0
- {executiontimer-1.0.1 → executiontimer-1.0.2}/benchmarks/overhead.py +0 -0
- {executiontimer-1.0.1 → executiontimer-1.0.2}/src/execution_timer/py.typed +0 -0
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.2] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Decorating a function that returns a coroutine, typically because another decorator
|
|
15
|
+
sits between `TimerContext` and an `async def`, now times the `await`. Previously the
|
|
16
|
+
section recorded only the call that created the coroutine, a few microseconds however
|
|
17
|
+
long the coroutine ran. Other awaitables, such as futures and tasks, are returned
|
|
18
|
+
unchanged.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- The test suite fails on any warning, and CI requires 100% statement and branch coverage
|
|
23
|
+
rather than 95%.
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
- Note that flattening also merges sections whose own names end in an integer, such as
|
|
28
|
+
`"row[1]"` and `"row[2]"`.
|
|
29
|
+
- The release steps in the contributing guide describe the pull-request flow that the
|
|
30
|
+
protected `main` branch requires.
|
|
31
|
+
- Refresh the overhead benchmark against version 1.0.2.
|
|
32
|
+
|
|
10
33
|
## [1.0.1] - 2026-09-30
|
|
11
34
|
|
|
12
35
|
### Fixed
|
|
@@ -149,7 +172,8 @@ First public release on PyPI.
|
|
|
149
172
|
`py.typed` marker so type checkers use the inline annotations.
|
|
150
173
|
- `__version__` attribute on the package.
|
|
151
174
|
|
|
152
|
-
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.
|
|
175
|
+
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.2...HEAD
|
|
176
|
+
[1.0.2]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.1...v1.0.2
|
|
153
177
|
[1.0.1]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.0...v1.0.1
|
|
154
178
|
[1.0.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.2.0...v1.0.0
|
|
155
179
|
[0.2.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...v0.2.0
|
|
@@ -23,8 +23,8 @@ uv run ruff format
|
|
|
23
23
|
uv run basedpyright
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
All four must pass.
|
|
27
|
-
|
|
26
|
+
All four must pass. The suite has 100% statement and branch coverage, and CI enforces it.
|
|
27
|
+
Any warning raised during the tests fails them.
|
|
28
28
|
|
|
29
29
|
The test suite is also run against Python 3.11, 3.12, 3.13, 3.14 and free-threaded 3.14t
|
|
30
30
|
on Linux, macOS and Windows.
|
|
@@ -63,8 +63,9 @@ Maintainers only:
|
|
|
63
63
|
3. Run the checks above, build with `uv build`, and validate metadata with
|
|
64
64
|
`uvx twine check --strict dist/*`. Use a clean output directory so old versions are
|
|
65
65
|
not included in release artifacts.
|
|
66
|
-
4. Commit
|
|
67
|
-
|
|
66
|
+
4. Commit on a branch and open a pull request. `main` is protected: it only accepts
|
|
67
|
+
pull requests whose `CI passed` check succeeds. Squash-merge once CI is green.
|
|
68
|
+
5. Publish a GitHub release tagged `vX.Y.Z`, targeting the merged commit on `main`.
|
|
68
69
|
Use that version's changelog entries as release notes.
|
|
69
70
|
|
|
70
71
|
The [Publish to PyPI workflow](.github/workflows/publish.yml) runs when a GitHub release
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: executiontimer
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
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
|
|
@@ -134,6 +134,9 @@ Coroutine functions are supported natively — the timing spans the entire `awai
|
|
|
134
134
|
async def fetch(url: str) -> bytes: ...
|
|
135
135
|
```
|
|
136
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
|
+
|
|
137
140
|
Generator functions (including `async` generators) cannot be decorated and raise a
|
|
138
141
|
`TypeError`: the decorator would time only the creation of the generator object, not its
|
|
139
142
|
iteration. Time the loop that consumes the generator with a `with` block instead.
|
|
@@ -153,7 +156,9 @@ get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., .
|
|
|
153
156
|
```
|
|
154
157
|
|
|
155
158
|
Flattening removes the final integer suffix (including negative counters). Other
|
|
156
|
-
bracketed names such as `array[index]` are preserved.
|
|
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
|
|
157
162
|
categories, the category from the most recently entered section is used.
|
|
158
163
|
|
|
159
164
|
Each counter value is stored as its own section until `clear_execution_timings()` is
|
|
@@ -286,7 +291,6 @@ an active section's current duration is added only when it exits.
|
|
|
286
291
|
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
287
292
|
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
288
293
|
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
289
|
-
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
290
294
|
|
|
291
295
|
## API
|
|
292
296
|
|
|
@@ -294,7 +298,7 @@ results using the same interpreter and machine; see [benchmarks/README.md](https
|
|
|
294
298
|
| --- | --- |
|
|
295
299
|
| `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
|
|
296
300
|
| `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). |
|
|
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. |
|
|
298
302
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
299
303
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
300
304
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
@@ -103,6 +103,9 @@ Coroutine functions are supported natively — the timing spans the entire `awai
|
|
|
103
103
|
async def fetch(url: str) -> bytes: ...
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
This also holds when another decorator sits between `TimerContext` and the `async def`
|
|
107
|
+
and returns its coroutine: the section covers both the call and the `await`.
|
|
108
|
+
|
|
106
109
|
Generator functions (including `async` generators) cannot be decorated and raise a
|
|
107
110
|
`TypeError`: the decorator would time only the creation of the generator object, not its
|
|
108
111
|
iteration. Time the loop that consumes the generator with a `with` block instead.
|
|
@@ -122,7 +125,9 @@ get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., .
|
|
|
122
125
|
```
|
|
123
126
|
|
|
124
127
|
Flattening removes the final integer suffix (including negative counters). Other
|
|
125
|
-
bracketed names such as `array[index]` are preserved.
|
|
128
|
+
bracketed names such as `array[index]` are preserved. Flattening cannot tell a counter
|
|
129
|
+
from a name you wrote yourself, so sections named `"row[1]"` and `"row[2]"` are also merged
|
|
130
|
+
into `row`; use `flatten=False` to keep them apart. If merged entries have different
|
|
126
131
|
categories, the category from the most recently entered section is used.
|
|
127
132
|
|
|
128
133
|
Each counter value is stored as its own section until `clear_execution_timings()` is
|
|
@@ -255,7 +260,6 @@ an active section's current duration is added only when it exits.
|
|
|
255
260
|
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
256
261
|
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
257
262
|
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
258
|
-
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
259
263
|
|
|
260
264
|
## API
|
|
261
265
|
|
|
@@ -263,7 +267,7 @@ results using the same interpreter and machine; see [benchmarks/README.md](https
|
|
|
263
267
|
| --- | --- |
|
|
264
268
|
| `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
|
|
265
269
|
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
266
|
-
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
|
|
270
|
+
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty); a no-op if `INFO` is disabled. |
|
|
267
271
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
268
272
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
269
273
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
@@ -18,21 +18,21 @@ make instrumentation costs visible; application speedups depend on the work bein
|
|
|
18
18
|
## Local comparison
|
|
19
19
|
|
|
20
20
|
Measured on macOS 26.6.2, ARM64, CPython 3.14.5, using 100,000 operations and nine repeats.
|
|
21
|
-
The baseline is commit `008494c` (version 0.1.0); the updated column
|
|
22
|
-
Both versions ran the same script. Values below are microseconds per
|
|
23
|
-
include the benchmark function call; plain calls cost approximately
|
|
24
|
-
awaits 0.
|
|
21
|
+
The baseline is commit `008494c` (version 0.1.0); the updated column is version 1.0.2.
|
|
22
|
+
Both versions ran the same script, one after the other. Values below are microseconds per
|
|
23
|
+
operation and include the benchmark function call; plain calls cost approximately
|
|
24
|
+
0.02 µs and plain awaits 0.06 µs in both runs.
|
|
25
25
|
|
|
26
26
|
| Operation | Baseline (µs) | Updated (µs) | Reduction |
|
|
27
27
|
| --- | ---: | ---: | ---: |
|
|
28
|
-
| New context | 1.
|
|
29
|
-
| Reused context | 1.
|
|
30
|
-
| Decorated call | 1.
|
|
31
|
-
| Decorated await | 1.
|
|
32
|
-
| Five nested contexts | 7.
|
|
33
|
-
| Total time, 1,000 sections |
|
|
34
|
-
| JSON, 1,000 sections | 1,
|
|
35
|
-
| Disabled logging, 1,000 sections |
|
|
28
|
+
| New context | 1.558 | 1.101 | 29% |
|
|
29
|
+
| Reused context | 1.390 | 1.039 | 25% |
|
|
30
|
+
| Decorated call | 1.623 | 1.129 | 30% |
|
|
31
|
+
| Decorated await | 1.711 | 1.133 | 34% |
|
|
32
|
+
| Five nested contexts | 7.961 | 5.380 | 32% |
|
|
33
|
+
| Total time, 1,000 sections | 519.131 | 43.188 | 92% |
|
|
34
|
+
| JSON, 1,000 sections | 1,235.188 | 1,064.559 | 14% |
|
|
35
|
+
| Disabled logging, 1,000 sections | 572.003 | 0.098 | >99.9% |
|
|
36
36
|
|
|
37
37
|
Recording keeps each invocation's start time and cached path in a context-local frame.
|
|
38
38
|
Decorators reuse their context instead of constructing one per call. Total-time queries
|
|
@@ -77,6 +77,9 @@ dev = [
|
|
|
77
77
|
[tool.pytest.ini_options]
|
|
78
78
|
testpaths = ["tests"]
|
|
79
79
|
addopts = "--strict-markers --strict-config"
|
|
80
|
+
# Any warning fails the suite, so code paths that warn are tested as they behave under
|
|
81
|
+
# warnings-as-errors, a common configuration in projects that use this package.
|
|
82
|
+
filterwarnings = ["error"]
|
|
80
83
|
|
|
81
84
|
[tool.coverage.run]
|
|
82
85
|
source = ["src/execution_timer"]
|
|
@@ -84,7 +87,7 @@ branch = true
|
|
|
84
87
|
|
|
85
88
|
[tool.coverage.report]
|
|
86
89
|
show_missing = true
|
|
87
|
-
fail_under =
|
|
90
|
+
fail_under = 100
|
|
88
91
|
exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]
|
|
89
92
|
|
|
90
93
|
[tool.ruff]
|
|
@@ -278,8 +278,9 @@ class TimerContext:
|
|
|
278
278
|
"""Decorate a function to time its execution under this context.
|
|
279
279
|
|
|
280
280
|
Coroutine functions are wrapped so the timing spans the entire ``await``, not just
|
|
281
|
-
creation of the coroutine object.
|
|
282
|
-
|
|
281
|
+
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.
|
|
283
284
|
"""
|
|
284
285
|
if inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func):
|
|
285
286
|
msg = (
|
|
@@ -297,10 +298,20 @@ class TimerContext:
|
|
|
297
298
|
@functools.wraps(func)
|
|
298
299
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
299
300
|
with self:
|
|
300
|
-
|
|
301
|
+
result = func(*args, **kwargs)
|
|
302
|
+
if inspect.iscoroutine(result):
|
|
303
|
+
# A decorator between this one and an ``async def`` hides the coroutine function,
|
|
304
|
+
# so the call above only created the coroutine. Time awaiting it as well.
|
|
305
|
+
return cast("R", self._time_await(result))
|
|
306
|
+
return result
|
|
301
307
|
|
|
302
308
|
return wrapper
|
|
303
309
|
|
|
310
|
+
async def _time_await(self, coroutine: Coroutine[object, object, T]) -> T:
|
|
311
|
+
"""Await a coroutine that was created outside this context, timing the whole await."""
|
|
312
|
+
with self:
|
|
313
|
+
return await coroutine
|
|
314
|
+
|
|
304
315
|
def _wrap_async(self, func: Callable[P, Coroutine[object, object, T]]) -> Callable[P, Coroutine[object, object, T]]:
|
|
305
316
|
"""Wrap a coroutine function so the timing spans the whole await."""
|
|
306
317
|
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import builtins
|
|
5
|
+
import functools
|
|
5
6
|
import inspect
|
|
6
7
|
import json
|
|
7
8
|
import logging
|
|
8
9
|
import threading
|
|
9
10
|
import time
|
|
10
11
|
import warnings
|
|
11
|
-
from collections.abc import AsyncIterator, Generator, Iterator
|
|
12
|
+
from collections.abc import AsyncIterator, Callable, Generator, Iterator
|
|
12
13
|
from concurrent.futures import ThreadPoolExecutor
|
|
13
14
|
from contextlib import ExitStack
|
|
14
15
|
from pathlib import Path
|
|
15
|
-
from typing import cast
|
|
16
|
+
from typing import ParamSpec, TypeVar, cast
|
|
16
17
|
from unittest.mock import patch
|
|
17
18
|
|
|
18
19
|
import pytest
|
|
@@ -34,6 +35,9 @@ from execution_timer import (
|
|
|
34
35
|
save_execution_timings_json,
|
|
35
36
|
)
|
|
36
37
|
|
|
38
|
+
P = ParamSpec("P")
|
|
39
|
+
R = TypeVar("R")
|
|
40
|
+
|
|
37
41
|
|
|
38
42
|
@pytest.fixture(autouse=True)
|
|
39
43
|
def reset_timer() -> Iterator[None]:
|
|
@@ -642,6 +646,89 @@ class TestAsyncDecorator:
|
|
|
642
646
|
assert ("child",) not in timings
|
|
643
647
|
|
|
644
648
|
|
|
649
|
+
def passthrough(func: Callable[P, R]) -> Callable[P, R]:
|
|
650
|
+
"""A plain decorator that hides whether ``func`` is a coroutine function."""
|
|
651
|
+
|
|
652
|
+
@functools.wraps(func)
|
|
653
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
654
|
+
return func(*args, **kwargs)
|
|
655
|
+
|
|
656
|
+
return wrapper
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class TestStackedAsyncDecorator:
|
|
660
|
+
"""A plain decorator between ``TimerContext`` and an ``async def`` returns its coroutine."""
|
|
661
|
+
|
|
662
|
+
def test_times_the_await_not_just_the_call(self) -> None:
|
|
663
|
+
@TimerContext("stacked")
|
|
664
|
+
@passthrough
|
|
665
|
+
async def work() -> None:
|
|
666
|
+
await asyncio.sleep(0)
|
|
667
|
+
|
|
668
|
+
# The call that creates the coroutine takes 1 s, and awaiting it takes 4 s.
|
|
669
|
+
with patch.object(time, "perf_counter", side_effect=[0, 1, 1, 5]):
|
|
670
|
+
asyncio.run(work())
|
|
671
|
+
|
|
672
|
+
assert raw_timings() == {("stacked",): {"time": 5, "category": DEFAULT_CATEGORY}}
|
|
673
|
+
|
|
674
|
+
def test_real_await_is_covered(self) -> None:
|
|
675
|
+
@TimerContext("stacked")
|
|
676
|
+
@passthrough
|
|
677
|
+
async def work() -> None:
|
|
678
|
+
await asyncio.sleep(0.05)
|
|
679
|
+
|
|
680
|
+
asyncio.run(work())
|
|
681
|
+
|
|
682
|
+
# Same threshold rationale as the plain async decorator test above.
|
|
683
|
+
assert raw_timings()["stacked",]["time"] >= 0.01
|
|
684
|
+
|
|
685
|
+
def test_sections_inside_the_coroutine_nest_under_it(self) -> None:
|
|
686
|
+
@TimerContext("stacked")
|
|
687
|
+
@passthrough
|
|
688
|
+
async def work() -> None:
|
|
689
|
+
with TimerContext("inner"):
|
|
690
|
+
await asyncio.sleep(0)
|
|
691
|
+
|
|
692
|
+
async def main() -> None:
|
|
693
|
+
with TimerContext("outer"):
|
|
694
|
+
await work()
|
|
695
|
+
|
|
696
|
+
asyncio.run(main())
|
|
697
|
+
|
|
698
|
+
assert set(raw_timings()) == {("outer",), ("outer", "stacked"), ("outer", "stacked", "inner")}
|
|
699
|
+
|
|
700
|
+
def test_preserves_return_value_and_exceptions(self) -> None:
|
|
701
|
+
@TimerContext("compute")
|
|
702
|
+
@passthrough
|
|
703
|
+
async def compute() -> int:
|
|
704
|
+
await asyncio.sleep(0)
|
|
705
|
+
return 42
|
|
706
|
+
|
|
707
|
+
@TimerContext("failing")
|
|
708
|
+
@passthrough
|
|
709
|
+
async def failing() -> None:
|
|
710
|
+
await asyncio.sleep(0)
|
|
711
|
+
raise ValueError("bad")
|
|
712
|
+
|
|
713
|
+
assert asyncio.run(compute()) == 42
|
|
714
|
+
with pytest.raises(ValueError, match="bad"):
|
|
715
|
+
asyncio.run(failing())
|
|
716
|
+
assert set(raw_timings()) == {("compute",), ("failing",)}
|
|
717
|
+
|
|
718
|
+
def test_other_awaitables_are_returned_unchanged(self) -> None:
|
|
719
|
+
loop = asyncio.new_event_loop()
|
|
720
|
+
try:
|
|
721
|
+
future: asyncio.Future[int] = loop.create_future()
|
|
722
|
+
|
|
723
|
+
@TimerContext("returns_future")
|
|
724
|
+
def get_future() -> asyncio.Future[int]:
|
|
725
|
+
return future
|
|
726
|
+
|
|
727
|
+
assert get_future() is future
|
|
728
|
+
finally:
|
|
729
|
+
loop.close()
|
|
730
|
+
|
|
731
|
+
|
|
645
732
|
class TestContextManagerProtocol:
|
|
646
733
|
def test_enter_returns_the_context(self) -> None:
|
|
647
734
|
with TimerContext("named", category="gpu", counter=2) as ctx:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|