executiontimer 0.1.1__tar.gz → 1.0.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-1.0.0/CHANGELOG.md +136 -0
- {executiontimer-0.1.1 → executiontimer-1.0.0}/CONTRIBUTING.md +3 -2
- {executiontimer-0.1.1 → executiontimer-1.0.0}/PKG-INFO +42 -13
- {executiontimer-0.1.1 → executiontimer-1.0.0}/README.md +37 -10
- {executiontimer-0.1.1 → executiontimer-1.0.0}/pyproject.toml +6 -4
- {executiontimer-0.1.1 → executiontimer-1.0.0}/src/execution_timer/__init__.py +1 -1
- {executiontimer-0.1.1 → executiontimer-1.0.0}/src/execution_timer/_timer.py +44 -18
- {executiontimer-0.1.1 → executiontimer-1.0.0}/tests/execution_timer_test.py +80 -20
- executiontimer-0.1.1/CHANGELOG.md +0 -63
- {executiontimer-0.1.1 → executiontimer-1.0.0}/.gitignore +0 -0
- {executiontimer-0.1.1 → executiontimer-1.0.0}/LICENSE +0 -0
- {executiontimer-0.1.1 → executiontimer-1.0.0}/benchmarks/README.md +0 -0
- {executiontimer-0.1.1 → executiontimer-1.0.0}/benchmarks/overhead.py +0 -0
- {executiontimer-0.1.1 → executiontimer-1.0.0}/src/execution_timer/py.typed +0 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
First stable release. The public API is now covered by semantic versioning: breaking
|
|
13
|
+
changes will wait for 2.0. Three changes below are breaking; they clean up the API before
|
|
14
|
+
it is frozen.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Breaking:** the report from `get_execution_times_report()` no longer starts with a
|
|
19
|
+
blank line, and its header reads `Total time:` rather than `Total calculation time:`.
|
|
20
|
+
`log_execution_times()` still starts the report on its own line.
|
|
21
|
+
- **Breaking:** `TimerContext.timer` is now private (`_timer`). It exposed the internal
|
|
22
|
+
registry, which is not part of the public API.
|
|
23
|
+
- `get_execution_times_report()` no longer logs a warning when there are no timings; it
|
|
24
|
+
returns `""`. `log_execution_times()` warns instead, on the logger it was given, so
|
|
25
|
+
building an empty report no longer prints to stderr when logging is not configured.
|
|
26
|
+
- The package is classified as `Development Status :: 5 - Production/Stable`.
|
|
27
|
+
|
|
28
|
+
### Removed
|
|
29
|
+
|
|
30
|
+
- **Breaking:** the `flatten` parameter of `get_total_time()`, which had no effect.
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
- README links to the changelog, contributing guide, benchmarks and license are absolute,
|
|
35
|
+
so they work on PyPI.
|
|
36
|
+
- Note that each `counter=` value is kept as a separate section until the timings are
|
|
37
|
+
cleared.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- Exiting a section while an inner one is still active, typically because a paused
|
|
42
|
+
generator holds a section open, no longer raises `RuntimeError`. The raise replaced any
|
|
43
|
+
exception already propagating from the timed block and left the active stack corrupted
|
|
44
|
+
for the rest of the thread. The timer now emits a `RuntimeWarning`, records the exited
|
|
45
|
+
section, and discards the unfinished inner sections. Exiting a section that is no longer
|
|
46
|
+
active does nothing, so closing the paused generator later no longer raises either.
|
|
47
|
+
|
|
48
|
+
### Security
|
|
49
|
+
|
|
50
|
+
- Workflows pin every action to a full commit SHA, with the release as a comment that
|
|
51
|
+
Dependabot keeps current, and CI installs dependencies with `uv sync --locked` so a
|
|
52
|
+
stale lockfile fails the build.
|
|
53
|
+
|
|
54
|
+
## [0.2.0] - 2026-09-30
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- Decorating a generator function or an async generator function now raises `TypeError`.
|
|
59
|
+
Previously it silently timed only the creation of the generator object, which recorded
|
|
60
|
+
microseconds regardless of how long iteration took.
|
|
61
|
+
- Python 3.11 is now supported; the minimum was 3.12 although nothing required it.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- `log_execution_times()` no longer logs an empty `INFO` record, after its warning, when
|
|
66
|
+
there are no timings.
|
|
67
|
+
|
|
68
|
+
### Documentation
|
|
69
|
+
|
|
70
|
+
- Explain that threads do not inherit the timing context, and how to nest thread work under
|
|
71
|
+
a section.
|
|
72
|
+
- Explain how a section held open across a generator's `yield` absorbs the caller's sections.
|
|
73
|
+
- Note that `flatten` has no effect on `get_total_time`.
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
|
|
77
|
+
- CI tests Python 3.11 and free-threaded Python 3.14t, and the package declares
|
|
78
|
+
free-threading support.
|
|
79
|
+
- Workflows run with a read-only token by default and do not persist checkout credentials.
|
|
80
|
+
|
|
81
|
+
## [0.1.1] - 2026-09-20
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- Overlapping calls to the same section now accumulate each call's actual duration in
|
|
86
|
+
threads and asyncio tasks, including calls sharing one context or decorator.
|
|
87
|
+
- Clearing active timings cannot add a discarded sample to a new entry at the same path.
|
|
88
|
+
- JSON sections and totals now use one consistent snapshot, and category totals retain
|
|
89
|
+
categories that disappear when counter variants are merged.
|
|
90
|
+
- Flattened categories follow the most recently entered section, including revisited counters.
|
|
91
|
+
- Flattening preserves non-integer bracket suffixes such as `array[index]` and `empty[]`.
|
|
92
|
+
- An out-of-order context exit raises `RuntimeError` without changing another section's
|
|
93
|
+
elapsed time or active stack.
|
|
94
|
+
|
|
95
|
+
### Performance
|
|
96
|
+
|
|
97
|
+
- Cache each active section's path and parent, and reuse decorator contexts to reduce
|
|
98
|
+
recording allocations and avoid rebuilding paths on exit.
|
|
99
|
+
- Sum total time directly without copying and flattening the registry.
|
|
100
|
+
- Calculate all JSON category totals in one pass over a shared snapshot.
|
|
101
|
+
- Skip report generation when logging at `INFO` is disabled.
|
|
102
|
+
|
|
103
|
+
### Added
|
|
104
|
+
|
|
105
|
+
- Deterministic regression tests for concurrency, clearing, category attribution, and
|
|
106
|
+
snapshot consistency, plus coverage for recursion, cancellation, and deep nesting.
|
|
107
|
+
- A repeatable benchmark for recording and reporting overhead in `benchmarks/overhead.py`.
|
|
108
|
+
- Expanded the suite from 53 to 88 tests, achieving 100% statement and branch coverage.
|
|
109
|
+
|
|
110
|
+
## [0.1.0] - 2026-08-20
|
|
111
|
+
|
|
112
|
+
First public release on PyPI.
|
|
113
|
+
|
|
114
|
+
### Added
|
|
115
|
+
|
|
116
|
+
- `TimerContext` — context manager and decorator for timing a named section of code, with
|
|
117
|
+
optional `category` and `counter` arguments.
|
|
118
|
+
- Automatic hierarchical nesting: section names reflect the enclosing timing contexts.
|
|
119
|
+
- Native `async def` support — decorating a coroutine function times the whole `await`
|
|
120
|
+
rather than the creation of the coroutine object.
|
|
121
|
+
- Per-task and per-thread context isolation via `contextvars`, so concurrently recorded
|
|
122
|
+
sections nest independently and merge into one process-wide report.
|
|
123
|
+
- Reporting and export helpers: `get_execution_times_report`, `log_execution_times`,
|
|
124
|
+
`get_execution_timings`, `get_execution_times_json`, `save_execution_timings_json`,
|
|
125
|
+
`get_total_time`, `get_total_category_time`.
|
|
126
|
+
- Optional nesting rules via `register_forbidden_nesting` / `clear_forbidden_nesting`.
|
|
127
|
+
- `clear_execution_timings` to reset the registry.
|
|
128
|
+
- Exported `TimingReport`, `SectionRecord` and `TimingsPayload` typed dictionaries, plus a
|
|
129
|
+
`py.typed` marker so type checkers use the inline annotations.
|
|
130
|
+
- `__version__` attribute on the package.
|
|
131
|
+
|
|
132
|
+
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.0...HEAD
|
|
133
|
+
[1.0.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.2.0...v1.0.0
|
|
134
|
+
[0.2.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...v0.2.0
|
|
135
|
+
[0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
|
|
136
|
+
[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: 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).
|
|
@@ -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
|
>
|
|
@@ -71,7 +72,7 @@ print(get_execution_times_report())
|
|
|
71
72
|
```
|
|
72
73
|
|
|
73
74
|
```text
|
|
74
|
-
Total
|
|
75
|
+
Total time: 0.3156 s.
|
|
75
76
|
|
|
76
77
|
load_data: 0.1219 s (38.62%)
|
|
77
78
|
solve: 0.1937 s (61.38%)
|
|
@@ -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
|
|
@@ -120,6 +125,11 @@ Flattening removes the final integer suffix (including negative counters). Other
|
|
|
120
125
|
bracketed names such as `array[index]` are preserved. If merged entries have different
|
|
121
126
|
categories, the category from the most recently entered section is used.
|
|
122
127
|
|
|
128
|
+
Each counter value is stored as its own section until `clear_execution_timings()` is
|
|
129
|
+
called, so a long-running process that times an unbounded loop with `counter=` keeps
|
|
130
|
+
growing the registry. Clear it periodically, or drop `counter=` to accumulate into one
|
|
131
|
+
section.
|
|
132
|
+
|
|
123
133
|
### Categories
|
|
124
134
|
|
|
125
135
|
Categories are plain strings — use whatever fits your domain:
|
|
@@ -204,6 +214,23 @@ New asyncio tasks inherit the timing context in which they are created. Their se
|
|
|
204
214
|
nest under that parent; changes to each task's active stack remain independent. Await
|
|
205
215
|
child tasks inside the parent section if you want the parent duration to include them.
|
|
206
216
|
|
|
217
|
+
New threads do *not* inherit the timing context, so sections recorded in a thread
|
|
218
|
+
appear at the top level of the report, and their time is added to the total alongside
|
|
219
|
+
the section that started the thread. To nest thread work under the current section, run
|
|
220
|
+
it with `contextvars.copy_context().run(...)` or `asyncio.to_thread(...)`. Either way,
|
|
221
|
+
concurrent threads accumulate overlapping time. (Free-threaded builds of Python 3.14 make
|
|
222
|
+
threads inherit the context by default.)
|
|
223
|
+
|
|
224
|
+
Generators run in their caller's context. A `with TimerContext(...)` block that stays open
|
|
225
|
+
across a `yield` therefore also contains whatever the caller times while the generator is
|
|
226
|
+
paused, and its duration includes that paused time. Close sections before yielding, or
|
|
227
|
+
time the loop that consumes the generator instead.
|
|
228
|
+
|
|
229
|
+
If the caller's section exits while a paused generator's section is still open, the
|
|
230
|
+
timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
|
|
231
|
+
discards the generator's unfinished one, so later sections nest correctly. Closing that
|
|
232
|
+
generator afterwards does nothing.
|
|
233
|
+
|
|
207
234
|
### Reusing contexts and clearing timings
|
|
208
235
|
|
|
209
236
|
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
@@ -225,7 +252,7 @@ an active section's current duration is added only when it exits.
|
|
|
225
252
|
|
|
226
253
|
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
227
254
|
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
228
|
-
results using the same interpreter and machine; see [benchmarks/README.md](benchmarks/README.md).
|
|
255
|
+
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
229
256
|
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
230
257
|
|
|
231
258
|
## API
|
|
@@ -233,12 +260,12 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
|
|
|
233
260
|
| Function | Description |
|
|
234
261
|
| --- | --- |
|
|
235
262
|
| `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
|
|
236
|
-
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections. |
|
|
237
|
-
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level. |
|
|
263
|
+
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
264
|
+
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
|
|
238
265
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
239
266
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
240
267
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
241
|
-
| `get_total_time(
|
|
268
|
+
| `get_total_time()` | Total seconds across all top-level sections. |
|
|
242
269
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
243
270
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
244
271
|
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
@@ -261,9 +288,9 @@ uv run ruff check --fix && uv run ruff format
|
|
|
261
288
|
uv run basedpyright
|
|
262
289
|
```
|
|
263
290
|
|
|
264
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, and
|
|
265
|
-
[CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
291
|
+
See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
|
|
292
|
+
[CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
|
|
266
293
|
|
|
267
294
|
## License
|
|
268
295
|
|
|
269
|
-
MIT — see [LICENSE](LICENSE).
|
|
296
|
+
MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
|
|
@@ -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",
|
|
@@ -18,14 +18,16 @@ keywords = [
|
|
|
18
18
|
"decorator",
|
|
19
19
|
]
|
|
20
20
|
classifiers = [
|
|
21
|
-
"Development Status ::
|
|
21
|
+
"Development Status :: 5 - Production/Stable",
|
|
22
22
|
"Intended Audience :: Developers",
|
|
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"]
|
|
@@ -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:
|
|
@@ -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, Generator, Iterator
|
|
11
11
|
from concurrent.futures import ThreadPoolExecutor
|
|
12
12
|
from contextlib import ExitStack
|
|
13
13
|
from pathlib import Path
|
|
@@ -191,15 +191,17 @@ class TestReporting:
|
|
|
191
191
|
|
|
192
192
|
report = get_execution_times_report()
|
|
193
193
|
|
|
194
|
-
assert "Total
|
|
194
|
+
assert report.startswith("Total time: ")
|
|
195
195
|
assert "context_report:" in report
|
|
196
196
|
assert "context_report_nested:" in report
|
|
197
197
|
for i in range(3):
|
|
198
198
|
assert f".. context_sub_{i}:" in report
|
|
199
199
|
assert f".. .. context_subsub_{i}:" in report
|
|
200
200
|
|
|
201
|
-
def test_report_empty_when_no_timings(self) -> None:
|
|
202
|
-
|
|
201
|
+
def test_report_empty_when_no_timings(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
202
|
+
with caplog.at_level(logging.DEBUG):
|
|
203
|
+
assert get_execution_times_report() == ""
|
|
204
|
+
assert caplog.records == []
|
|
203
205
|
|
|
204
206
|
def test_report_flatten_flag(self) -> None:
|
|
205
207
|
with patch.object(time, "perf_counter", side_effect=[0, 1, 1, 2]):
|
|
@@ -271,7 +273,7 @@ class TestOutput:
|
|
|
271
273
|
with caplog.at_level(logging.INFO):
|
|
272
274
|
log_execution_times()
|
|
273
275
|
|
|
274
|
-
assert "Total
|
|
276
|
+
assert "Total time: " in caplog.text
|
|
275
277
|
assert "logged:" in caplog.text
|
|
276
278
|
|
|
277
279
|
def test_get_execution_times_json_is_valid_and_structured(self) -> None:
|
|
@@ -380,6 +382,32 @@ class TestTimerContextDecorator:
|
|
|
380
382
|
assert ("outer",) in timings
|
|
381
383
|
assert ("outer", "inner") in timings
|
|
382
384
|
|
|
385
|
+
def test_decorating_a_generator_function_raises(self) -> None:
|
|
386
|
+
def numbers() -> Generator[int]:
|
|
387
|
+
yield 1
|
|
388
|
+
|
|
389
|
+
with pytest.raises(TypeError, match=r"Cannot decorate generator function '.*numbers'"):
|
|
390
|
+
_ = TimerContext("gen")(numbers)
|
|
391
|
+
assert raw_timings() == {}
|
|
392
|
+
|
|
393
|
+
def test_decorating_an_async_generator_function_raises(self) -> None:
|
|
394
|
+
async def numbers() -> AsyncIterator[int]:
|
|
395
|
+
yield 1
|
|
396
|
+
|
|
397
|
+
with pytest.raises(TypeError, match=r"Cannot decorate generator function '.*numbers'"):
|
|
398
|
+
_ = TimerContext("agen")(numbers)
|
|
399
|
+
assert raw_timings() == {}
|
|
400
|
+
|
|
401
|
+
def test_decorator_accepts_a_function_returning_a_generator(self) -> None:
|
|
402
|
+
"""Only generator *functions* are rejected; a plain function may return an iterator."""
|
|
403
|
+
|
|
404
|
+
@TimerContext("factory")
|
|
405
|
+
def factory() -> Iterator[int]:
|
|
406
|
+
return iter([1, 2])
|
|
407
|
+
|
|
408
|
+
assert list(factory()) == [1, 2]
|
|
409
|
+
assert ("factory",) in raw_timings()
|
|
410
|
+
|
|
383
411
|
|
|
384
412
|
class TestExceptions:
|
|
385
413
|
def test_timing_recorded_when_body_raises(self) -> None:
|
|
@@ -690,11 +718,19 @@ class TestRobustness:
|
|
|
690
718
|
assert errors == []
|
|
691
719
|
|
|
692
720
|
def test_logging_uses_the_package_logger_not_the_root_logger(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
693
|
-
with caplog.at_level(logging.
|
|
694
|
-
|
|
721
|
+
with caplog.at_level(logging.INFO):
|
|
722
|
+
log_execution_times()
|
|
695
723
|
|
|
696
724
|
assert [record.name for record in caplog.records] == ["execution_timer._timer"]
|
|
697
725
|
|
|
726
|
+
def test_logging_an_empty_registry_emits_only_the_warning(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
727
|
+
with caplog.at_level(logging.INFO):
|
|
728
|
+
log_execution_times()
|
|
729
|
+
|
|
730
|
+
assert [(record.levelno, record.message) for record in caplog.records] == [
|
|
731
|
+
(logging.WARNING, "No timings to report.")
|
|
732
|
+
]
|
|
733
|
+
|
|
698
734
|
|
|
699
735
|
class TestRegressions:
|
|
700
736
|
@pytest.mark.parametrize("clear_between_calls", [False, True])
|
|
@@ -869,18 +905,42 @@ class TestLifecycle:
|
|
|
869
905
|
pass
|
|
870
906
|
assert ("gpu", "io", "cpu") in raw_timings()
|
|
871
907
|
|
|
872
|
-
def
|
|
908
|
+
def test_out_of_order_exit_unwinds_to_the_exited_section_and_warns(self) -> None:
|
|
873
909
|
outer = TimerContext("outer")
|
|
874
910
|
inner = TimerContext("inner")
|
|
875
|
-
with (
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
inner
|
|
879
|
-
|
|
880
|
-
):
|
|
881
|
-
|
|
882
|
-
assert raw_timings()["outer",]["time"] ==
|
|
883
|
-
assert raw_timings()["outer", "inner"]["time"] ==
|
|
911
|
+
with patch.object(time, "perf_counter", side_effect=[0, 1, 3]):
|
|
912
|
+
_ = outer.__enter__()
|
|
913
|
+
_ = inner.__enter__()
|
|
914
|
+
with pytest.warns(RuntimeWarning, match="'outer' exited while 'inner' was still active"):
|
|
915
|
+
outer.__exit__(None, None, None)
|
|
916
|
+
with TimerContext("after"):
|
|
917
|
+
pass
|
|
918
|
+
assert raw_timings()["outer",]["time"] == 3.0
|
|
919
|
+
assert raw_timings()["outer", "inner"]["time"] == 0.0
|
|
920
|
+
assert ("after",) in raw_timings()
|
|
921
|
+
|
|
922
|
+
def test_abandoned_generator_section_does_not_break_the_caller(self) -> None:
|
|
923
|
+
def numbers() -> Generator[int]:
|
|
924
|
+
with TimerContext("generator"):
|
|
925
|
+
yield 1
|
|
926
|
+
yield 2
|
|
927
|
+
|
|
928
|
+
paused = numbers()
|
|
929
|
+
with pytest.warns(RuntimeWarning), pytest.raises(KeyError, match="from the body"), TimerContext("caller"):
|
|
930
|
+
_ = next(paused)
|
|
931
|
+
raise KeyError("from the body")
|
|
932
|
+
# Closing the generator later exits a section that is no longer active: a no-op.
|
|
933
|
+
paused.close()
|
|
934
|
+
with TimerContext("after"):
|
|
935
|
+
pass
|
|
936
|
+
assert list(raw_timings()) == [("caller",), ("caller", "generator"), ("after",)]
|
|
937
|
+
|
|
938
|
+
def test_exiting_a_section_that_is_not_active_leaves_the_stack_intact(self) -> None:
|
|
939
|
+
with TimerContext("outer"):
|
|
940
|
+
TimerContext("stranger").__exit__(None, None, None)
|
|
941
|
+
with TimerContext("inner"):
|
|
942
|
+
pass
|
|
943
|
+
assert list(raw_timings()) == [("outer",), ("outer", "inner")]
|
|
884
944
|
|
|
885
945
|
def test_exit_without_an_active_section_is_a_noop(self) -> None:
|
|
886
946
|
TimerContext("unused").__exit__(None, None, None)
|
|
@@ -902,7 +962,7 @@ class TestLifecycle:
|
|
|
902
962
|
with ExitStack() as stack:
|
|
903
963
|
for _ in range(1_100):
|
|
904
964
|
_ = stack.enter_context(TimerContext("level"))
|
|
905
|
-
assert len(get_execution_times_report(flatten=False).splitlines()) ==
|
|
965
|
+
assert len(get_execution_times_report(flatten=False).splitlines()) == 1_102
|
|
906
966
|
|
|
907
967
|
def test_async_cancellation_records_time_and_restores_parent(self) -> None:
|
|
908
968
|
@TimerContext("cancelled")
|
|
@@ -962,7 +1022,7 @@ class TestSnapshotSemantics:
|
|
|
962
1022
|
payload = cast(TimingsPayload, json.loads(get_execution_times_json(flatten=flatten)))
|
|
963
1023
|
assert payload["total_category_time"] == {"cpu": 5.0, "io": 3.0}
|
|
964
1024
|
assert get_total_category_time("cpu") == 5.0
|
|
965
|
-
assert get_total_time(
|
|
1025
|
+
assert get_total_time() == 5.0
|
|
966
1026
|
|
|
967
1027
|
def test_empty_json(self) -> None:
|
|
968
1028
|
assert json.loads(get_execution_times_json(indent=None)) == {
|
|
@@ -990,5 +1050,5 @@ class TestSnapshotSemantics:
|
|
|
990
1050
|
with caplog.at_level(logging.INFO, logger=logger.name):
|
|
991
1051
|
log_execution_times(flatten=False, logger=logger)
|
|
992
1052
|
assert [(record.name, record.message) for record in caplog.records] == [
|
|
993
|
-
(logger.name, get_execution_times_report(flatten=False))
|
|
1053
|
+
(logger.name, "\n" + get_execution_times_report(flatten=False))
|
|
994
1054
|
]
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to this project will be documented in this file.
|
|
4
|
-
|
|
5
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
-
|
|
8
|
-
## [Unreleased]
|
|
9
|
-
|
|
10
|
-
## [0.1.1] - 2026-09-20
|
|
11
|
-
|
|
12
|
-
### Fixed
|
|
13
|
-
|
|
14
|
-
- Overlapping calls to the same section now accumulate each call's actual duration in
|
|
15
|
-
threads and asyncio tasks, including calls sharing one context or decorator.
|
|
16
|
-
- Clearing active timings cannot add a discarded sample to a new entry at the same path.
|
|
17
|
-
- JSON sections and totals now use one consistent snapshot, and category totals retain
|
|
18
|
-
categories that disappear when counter variants are merged.
|
|
19
|
-
- Flattened categories follow the most recently entered section, including revisited counters.
|
|
20
|
-
- Flattening preserves non-integer bracket suffixes such as `array[index]` and `empty[]`.
|
|
21
|
-
- An out-of-order context exit raises `RuntimeError` without changing another section's
|
|
22
|
-
elapsed time or active stack.
|
|
23
|
-
|
|
24
|
-
### Performance
|
|
25
|
-
|
|
26
|
-
- Cache each active section's path and parent, and reuse decorator contexts to reduce
|
|
27
|
-
recording allocations and avoid rebuilding paths on exit.
|
|
28
|
-
- Sum total time directly without copying and flattening the registry.
|
|
29
|
-
- Calculate all JSON category totals in one pass over a shared snapshot.
|
|
30
|
-
- Skip report generation when logging at `INFO` is disabled.
|
|
31
|
-
|
|
32
|
-
### Added
|
|
33
|
-
|
|
34
|
-
- Deterministic regression tests for concurrency, clearing, category attribution, and
|
|
35
|
-
snapshot consistency, plus coverage for recursion, cancellation, and deep nesting.
|
|
36
|
-
- A repeatable benchmark for recording and reporting overhead in `benchmarks/overhead.py`.
|
|
37
|
-
- Expanded the suite from 53 to 88 tests, achieving 100% statement and branch coverage.
|
|
38
|
-
|
|
39
|
-
## [0.1.0] - 2026-08-20
|
|
40
|
-
|
|
41
|
-
First public release on PyPI.
|
|
42
|
-
|
|
43
|
-
### Added
|
|
44
|
-
|
|
45
|
-
- `TimerContext` — context manager and decorator for timing a named section of code, with
|
|
46
|
-
optional `category` and `counter` arguments.
|
|
47
|
-
- Automatic hierarchical nesting: section names reflect the enclosing timing contexts.
|
|
48
|
-
- Native `async def` support — decorating a coroutine function times the whole `await`
|
|
49
|
-
rather than the creation of the coroutine object.
|
|
50
|
-
- Per-task and per-thread context isolation via `contextvars`, so concurrently recorded
|
|
51
|
-
sections nest independently and merge into one process-wide report.
|
|
52
|
-
- Reporting and export helpers: `get_execution_times_report`, `log_execution_times`,
|
|
53
|
-
`get_execution_timings`, `get_execution_times_json`, `save_execution_timings_json`,
|
|
54
|
-
`get_total_time`, `get_total_category_time`.
|
|
55
|
-
- Optional nesting rules via `register_forbidden_nesting` / `clear_forbidden_nesting`.
|
|
56
|
-
- `clear_execution_timings` to reset the registry.
|
|
57
|
-
- Exported `TimingReport`, `SectionRecord` and `TimingsPayload` typed dictionaries, plus a
|
|
58
|
-
`py.typed` marker so type checkers use the inline annotations.
|
|
59
|
-
- `__version__` attribute on the package.
|
|
60
|
-
|
|
61
|
-
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...HEAD
|
|
62
|
-
[0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
|
|
63
|
-
[0.1.0]: https://github.com/seba2390/ExecutionTimer/releases/tag/v0.1.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|