executiontimer 0.1.0__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.2.0/CHANGELOG.md +91 -0
- executiontimer-0.2.0/CONTRIBUTING.md +78 -0
- {executiontimer-0.1.0 → executiontimer-0.2.0}/PKG-INFO +67 -8
- {executiontimer-0.1.0 → executiontimer-0.2.0}/README.md +63 -6
- executiontimer-0.2.0/benchmarks/README.md +40 -0
- executiontimer-0.2.0/benchmarks/overhead.py +120 -0
- {executiontimer-0.1.0 → executiontimer-0.2.0}/pyproject.toml +15 -4
- {executiontimer-0.1.0 → executiontimer-0.2.0}/src/execution_timer/__init__.py +1 -1
- {executiontimer-0.1.0 → executiontimer-0.2.0}/src/execution_timer/_timer.py +109 -87
- {executiontimer-0.1.0 → executiontimer-0.2.0}/tests/execution_timer_test.py +342 -2
- executiontimer-0.1.0/CHANGELOG.md +0 -33
- {executiontimer-0.1.0 → executiontimer-0.2.0}/.gitignore +0 -0
- {executiontimer-0.1.0 → executiontimer-0.2.0}/LICENSE +0 -0
- {executiontimer-0.1.0 → executiontimer-0.2.0}/src/execution_timer/py.typed +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
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.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
|
+
|
|
37
|
+
## [0.1.1] - 2026-09-20
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- Overlapping calls to the same section now accumulate each call's actual duration in
|
|
42
|
+
threads and asyncio tasks, including calls sharing one context or decorator.
|
|
43
|
+
- Clearing active timings cannot add a discarded sample to a new entry at the same path.
|
|
44
|
+
- JSON sections and totals now use one consistent snapshot, and category totals retain
|
|
45
|
+
categories that disappear when counter variants are merged.
|
|
46
|
+
- Flattened categories follow the most recently entered section, including revisited counters.
|
|
47
|
+
- Flattening preserves non-integer bracket suffixes such as `array[index]` and `empty[]`.
|
|
48
|
+
- An out-of-order context exit raises `RuntimeError` without changing another section's
|
|
49
|
+
elapsed time or active stack.
|
|
50
|
+
|
|
51
|
+
### Performance
|
|
52
|
+
|
|
53
|
+
- Cache each active section's path and parent, and reuse decorator contexts to reduce
|
|
54
|
+
recording allocations and avoid rebuilding paths on exit.
|
|
55
|
+
- Sum total time directly without copying and flattening the registry.
|
|
56
|
+
- Calculate all JSON category totals in one pass over a shared snapshot.
|
|
57
|
+
- Skip report generation when logging at `INFO` is disabled.
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- Deterministic regression tests for concurrency, clearing, category attribution, and
|
|
62
|
+
snapshot consistency, plus coverage for recursion, cancellation, and deep nesting.
|
|
63
|
+
- A repeatable benchmark for recording and reporting overhead in `benchmarks/overhead.py`.
|
|
64
|
+
- Expanded the suite from 53 to 88 tests, achieving 100% statement and branch coverage.
|
|
65
|
+
|
|
66
|
+
## [0.1.0] - 2026-08-20
|
|
67
|
+
|
|
68
|
+
First public release on PyPI.
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- `TimerContext` — context manager and decorator for timing a named section of code, with
|
|
73
|
+
optional `category` and `counter` arguments.
|
|
74
|
+
- Automatic hierarchical nesting: section names reflect the enclosing timing contexts.
|
|
75
|
+
- Native `async def` support — decorating a coroutine function times the whole `await`
|
|
76
|
+
rather than the creation of the coroutine object.
|
|
77
|
+
- Per-task and per-thread context isolation via `contextvars`, so concurrently recorded
|
|
78
|
+
sections nest independently and merge into one process-wide report.
|
|
79
|
+
- Reporting and export helpers: `get_execution_times_report`, `log_execution_times`,
|
|
80
|
+
`get_execution_timings`, `get_execution_times_json`, `save_execution_timings_json`,
|
|
81
|
+
`get_total_time`, `get_total_category_time`.
|
|
82
|
+
- Optional nesting rules via `register_forbidden_nesting` / `clear_forbidden_nesting`.
|
|
83
|
+
- `clear_execution_timings` to reset the registry.
|
|
84
|
+
- Exported `TimingReport`, `SectionRecord` and `TimingsPayload` typed dictionaries, plus a
|
|
85
|
+
`py.typed` marker so type checkers use the inline annotations.
|
|
86
|
+
- `__version__` attribute on the package.
|
|
87
|
+
|
|
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
|
|
90
|
+
[0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
|
|
91
|
+
[0.1.0]: https://github.com/seba2390/ExecutionTimer/releases/tag/v0.1.0
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for taking the time to contribute.
|
|
4
|
+
|
|
5
|
+
## Getting set up
|
|
6
|
+
|
|
7
|
+
This project uses [uv](https://docs.astral.sh/uv/) for dependency management.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
git clone https://github.com/seba2390/ExecutionTimer.git
|
|
11
|
+
cd ExecutionTimer
|
|
12
|
+
uv sync
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Before opening a pull request
|
|
16
|
+
|
|
17
|
+
Run the same checks CI runs:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv run pytest --cov
|
|
21
|
+
uv run ruff check --fix
|
|
22
|
+
uv run ruff format
|
|
23
|
+
uv run basedpyright
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
All four must pass. Coverage is enforced at 95%; the current suite has 100% statement
|
|
27
|
+
and branch coverage. Preserve coverage when adding or changing behavior.
|
|
28
|
+
|
|
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.
|
|
31
|
+
To check another interpreter locally:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv run --python 3.11 pytest
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For changes to recording or reporting performance, compare the benchmark on the same
|
|
38
|
+
machine and Python version, without coverage instrumentation:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv run python benchmarks/overhead.py --number 100000 --repeat 9
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
See [benchmarks/README.md](benchmarks/README.md) for methodology and reference results.
|
|
45
|
+
Timing results are advisory rather than CI pass/fail thresholds.
|
|
46
|
+
|
|
47
|
+
## Guidelines
|
|
48
|
+
|
|
49
|
+
- Tests exercise the **public API** only — import from `execution_timer`, not
|
|
50
|
+
`execution_timer._timer`. This keeps internals free to change.
|
|
51
|
+
- Every behaviour change needs a test that fails before the fix and passes after it.
|
|
52
|
+
- Public functions carry type annotations and a one-line docstring.
|
|
53
|
+
- Add an entry under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md).
|
|
54
|
+
|
|
55
|
+
## Releasing
|
|
56
|
+
|
|
57
|
+
Maintainers only:
|
|
58
|
+
|
|
59
|
+
1. Move the `## [Unreleased]` entries into a new version section in `CHANGELOG.md`, and
|
|
60
|
+
update the link definitions at the bottom.
|
|
61
|
+
2. Bump `__version__` in `src/execution_timer/__init__.py`, then run `uv lock --check`
|
|
62
|
+
to verify the lockfile. Package metadata reads the version from `__version__`.
|
|
63
|
+
3. Run the checks above, build with `uv build`, and validate metadata with
|
|
64
|
+
`uvx twine check --strict dist/*`. Use a clean output directory so old versions are
|
|
65
|
+
not included in release artifacts.
|
|
66
|
+
4. Commit, then push to `main` and wait for CI to pass.
|
|
67
|
+
5. Publish a GitHub release tagged `vX.Y.Z`, targeting the validated commit on `main`.
|
|
68
|
+
Use that version's changelog entries as release notes.
|
|
69
|
+
|
|
70
|
+
The [Publish to PyPI workflow](.github/workflows/publish.yml) runs when a GitHub release
|
|
71
|
+
is **published**. Pushing to `main`, pushing a tag alone, or saving a draft release does
|
|
72
|
+
not trigger it. The workflow builds and validates the distributions, checks that the tag
|
|
73
|
+
matches `__version__`, and uploads them to PyPI via Trusted Publishing. No manual
|
|
74
|
+
`twine upload` or PyPI API token is needed. If the `pypi` GitHub environment requires
|
|
75
|
+
approval, approve the publishing job there.
|
|
76
|
+
|
|
77
|
+
After publishing the release, check that the workflow succeeds and the new version
|
|
78
|
+
appears on [PyPI](https://pypi.org/project/executiontimer/).
|
|
@@ -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
|
|
@@ -145,6 +152,10 @@ get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
|
|
|
145
152
|
get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
|
|
146
153
|
```
|
|
147
154
|
|
|
155
|
+
Flattening removes the final integer suffix (including negative counters). Other
|
|
156
|
+
bracketed names such as `array[index]` are preserved. If merged entries have different
|
|
157
|
+
categories, the category from the most recently entered section is used.
|
|
158
|
+
|
|
148
159
|
### Categories
|
|
149
160
|
|
|
150
161
|
Categories are plain strings — use whatever fits your domain:
|
|
@@ -161,6 +172,10 @@ get_total_category_time("gpu")
|
|
|
161
172
|
`get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
|
|
162
173
|
section nested inside another `gpu` section is not double-counted.
|
|
163
174
|
|
|
175
|
+
Repeated calls to the same path accumulate time. If its category changes, the latest
|
|
176
|
+
category applies to that path's entire accumulated time. Use consistent categories per
|
|
177
|
+
path when you need separate category totals.
|
|
178
|
+
|
|
164
179
|
You can also forbid a category from appearing inside another, which raises a `ValueError`
|
|
165
180
|
as soon as the invalid nesting happens:
|
|
166
181
|
|
|
@@ -198,6 +213,9 @@ save_execution_timings_json("timings.json")
|
|
|
198
213
|
}
|
|
199
214
|
```
|
|
200
215
|
|
|
216
|
+
Sections and totals come from one snapshot. Category totals use the original paths,
|
|
217
|
+
even when flattening merges sections with different categories.
|
|
218
|
+
|
|
201
219
|
### Concurrency
|
|
202
220
|
|
|
203
221
|
The recorded timings live in one process-wide registry guarded by a lock. The *active
|
|
@@ -213,9 +231,50 @@ async def worker(n: int) -> None:
|
|
|
213
231
|
await asyncio.gather(worker(0), worker(1))
|
|
214
232
|
```
|
|
215
233
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
234
|
+
Overlapping calls to the same section path are supported: each call keeps its own start
|
|
235
|
+
time, and their durations are added together. These totals measure accumulated elapsed
|
|
236
|
+
time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
|
|
237
|
+
concurrent calls separately.
|
|
238
|
+
|
|
239
|
+
New asyncio tasks inherit the timing context in which they are created. Their sections
|
|
240
|
+
nest under that parent; changes to each task's active stack remain independent. Await
|
|
241
|
+
child tasks inside the parent section if you want the parent duration to include them.
|
|
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
|
+
|
|
255
|
+
### Reusing contexts and clearing timings
|
|
256
|
+
|
|
257
|
+
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
258
|
+
For a tight loop, reuse a context to avoid constructing one on every iteration:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
step_timer = TimerContext("step")
|
|
262
|
+
for item in items:
|
|
263
|
+
with step_timer:
|
|
264
|
+
process(item)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
268
|
+
samples from sections that were already active, without disturbing their nesting stack.
|
|
269
|
+
Sections started after the clear are recorded normally. Reports include completed calls;
|
|
270
|
+
an active section's current duration is added only when it exits.
|
|
271
|
+
|
|
272
|
+
### Measuring overhead
|
|
273
|
+
|
|
274
|
+
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
275
|
+
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
276
|
+
results using the same interpreter and machine; see [benchmarks/README.md](benchmarks/README.md).
|
|
277
|
+
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
219
278
|
|
|
220
279
|
## API
|
|
221
280
|
|
|
@@ -227,7 +286,7 @@ concurrent sections distinct names (or use `counter=`).
|
|
|
227
286
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
228
287
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
229
288
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
230
|
-
| `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). |
|
|
231
290
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
232
291
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
233
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
|
|
@@ -116,6 +121,10 @@ get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
|
|
|
116
121
|
get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
|
|
117
122
|
```
|
|
118
123
|
|
|
124
|
+
Flattening removes the final integer suffix (including negative counters). Other
|
|
125
|
+
bracketed names such as `array[index]` are preserved. If merged entries have different
|
|
126
|
+
categories, the category from the most recently entered section is used.
|
|
127
|
+
|
|
119
128
|
### Categories
|
|
120
129
|
|
|
121
130
|
Categories are plain strings — use whatever fits your domain:
|
|
@@ -132,6 +141,10 @@ get_total_category_time("gpu")
|
|
|
132
141
|
`get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
|
|
133
142
|
section nested inside another `gpu` section is not double-counted.
|
|
134
143
|
|
|
144
|
+
Repeated calls to the same path accumulate time. If its category changes, the latest
|
|
145
|
+
category applies to that path's entire accumulated time. Use consistent categories per
|
|
146
|
+
path when you need separate category totals.
|
|
147
|
+
|
|
135
148
|
You can also forbid a category from appearing inside another, which raises a `ValueError`
|
|
136
149
|
as soon as the invalid nesting happens:
|
|
137
150
|
|
|
@@ -169,6 +182,9 @@ save_execution_timings_json("timings.json")
|
|
|
169
182
|
}
|
|
170
183
|
```
|
|
171
184
|
|
|
185
|
+
Sections and totals come from one snapshot. Category totals use the original paths,
|
|
186
|
+
even when flattening merges sections with different categories.
|
|
187
|
+
|
|
172
188
|
### Concurrency
|
|
173
189
|
|
|
174
190
|
The recorded timings live in one process-wide registry guarded by a lock. The *active
|
|
@@ -184,9 +200,50 @@ async def worker(n: int) -> None:
|
|
|
184
200
|
await asyncio.gather(worker(0), worker(1))
|
|
185
201
|
```
|
|
186
202
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
203
|
+
Overlapping calls to the same section path are supported: each call keeps its own start
|
|
204
|
+
time, and their durations are added together. These totals measure accumulated elapsed
|
|
205
|
+
time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
|
|
206
|
+
concurrent calls separately.
|
|
207
|
+
|
|
208
|
+
New asyncio tasks inherit the timing context in which they are created. Their sections
|
|
209
|
+
nest under that parent; changes to each task's active stack remain independent. Await
|
|
210
|
+
child tasks inside the parent section if you want the parent duration to include them.
|
|
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
|
+
|
|
224
|
+
### Reusing contexts and clearing timings
|
|
225
|
+
|
|
226
|
+
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
227
|
+
For a tight loop, reuse a context to avoid constructing one on every iteration:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
step_timer = TimerContext("step")
|
|
231
|
+
for item in items:
|
|
232
|
+
with step_timer:
|
|
233
|
+
process(item)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
237
|
+
samples from sections that were already active, without disturbing their nesting stack.
|
|
238
|
+
Sections started after the clear are recorded normally. Reports include completed calls;
|
|
239
|
+
an active section's current duration is added only when it exits.
|
|
240
|
+
|
|
241
|
+
### Measuring overhead
|
|
242
|
+
|
|
243
|
+
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
244
|
+
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
245
|
+
results using the same interpreter and machine; see [benchmarks/README.md](benchmarks/README.md).
|
|
246
|
+
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
190
247
|
|
|
191
248
|
## API
|
|
192
249
|
|
|
@@ -198,7 +255,7 @@ concurrent sections distinct names (or use `counter=`).
|
|
|
198
255
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
199
256
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
200
257
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
201
|
-
| `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). |
|
|
202
259
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
203
260
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
204
261
|
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Overhead benchmarks
|
|
2
|
+
|
|
3
|
+
From a repository checkout, run:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv run python benchmarks/overhead.py --number 100000 --repeat 9
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The script prints JSON with the Python version, platform, and median nanoseconds per
|
|
10
|
+
operation. Recording cases run `--number` operations per repeat. Reporting cases run
|
|
11
|
+
`max(1, number // 1000)` operations against 1,000 recorded counter variants with ten
|
|
12
|
+
categories. Async calls run in one event loop, so loop startup is excluded.
|
|
13
|
+
|
|
14
|
+
Compare the same interpreter and machine under similar load, without coverage or a
|
|
15
|
+
profiler enabled. These are microbenchmarks, not CI timing thresholds. The no-op bodies
|
|
16
|
+
make instrumentation costs visible; application speedups depend on the work being timed.
|
|
17
|
+
|
|
18
|
+
## Local comparison
|
|
19
|
+
|
|
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 uses version 0.1.1.
|
|
22
|
+
Both versions ran the same script. Values below are microseconds per operation and
|
|
23
|
+
include the benchmark function call; plain calls cost approximately 0.017 µs and plain
|
|
24
|
+
awaits 0.056 µs in both runs.
|
|
25
|
+
|
|
26
|
+
| Operation | Baseline (µs) | Updated (µs) | Reduction |
|
|
27
|
+
| --- | ---: | ---: | ---: |
|
|
28
|
+
| New context | 1.550 | 1.028 | 34% |
|
|
29
|
+
| Reused context | 1.425 | 0.947 | 34% |
|
|
30
|
+
| Decorated call | 1.604 | 1.011 | 37% |
|
|
31
|
+
| Decorated await | 1.696 | 1.093 | 36% |
|
|
32
|
+
| Five nested contexts | 7.792 | 5.116 | 34% |
|
|
33
|
+
| Total time, 1,000 sections | 524.809 | 43.068 | 92% |
|
|
34
|
+
| JSON, 1,000 sections | 1,177.635 | 1,005.600 | 15% |
|
|
35
|
+
| Disabled logging, 1,000 sections | 520.167 | 0.099 | >99.9% |
|
|
36
|
+
|
|
37
|
+
Recording keeps each invocation's start time and cached path in a context-local frame.
|
|
38
|
+
Decorators reuse their context instead of constructing one per call. Total-time queries
|
|
39
|
+
avoid copying and flattening entries, JSON exports share one snapshot, and disabled
|
|
40
|
+
logging returns before building a report.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Measure recording and reporting costs; run with ``uv run python benchmarks/overhead.py``.
|
|
2
|
+
|
|
3
|
+
Use the same interpreter, machine, iteration count, and repeat count for comparisons.
|
|
4
|
+
Results are medians in nanoseconds per operation; they are not CI pass/fail thresholds.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import platform
|
|
14
|
+
import statistics
|
|
15
|
+
import time
|
|
16
|
+
import timeit
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
from execution_timer import (
|
|
20
|
+
TimerContext,
|
|
21
|
+
clear_execution_timings,
|
|
22
|
+
get_execution_times_json,
|
|
23
|
+
get_total_time,
|
|
24
|
+
log_execution_times,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> None:
|
|
29
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
30
|
+
_ = parser.add_argument("--number", type=int, default=100_000)
|
|
31
|
+
_ = parser.add_argument("--repeat", type=int, default=7)
|
|
32
|
+
args = parser.parse_args()
|
|
33
|
+
number = int(args.number)
|
|
34
|
+
repeat = int(args.repeat)
|
|
35
|
+
if number < 1 or repeat < 1:
|
|
36
|
+
parser.error("--number and --repeat must be positive")
|
|
37
|
+
|
|
38
|
+
results: dict[str, float] = {}
|
|
39
|
+
|
|
40
|
+
def measure(name: str, operation: Callable[[], object], count: int = number) -> None:
|
|
41
|
+
clear_execution_timings()
|
|
42
|
+
results[name] = statistics.median(timeit.repeat(operation, number=count, repeat=repeat)) * 1e9 / count
|
|
43
|
+
|
|
44
|
+
def plain() -> None:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def context() -> None:
|
|
48
|
+
with TimerContext("section"):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
shared = TimerContext("section")
|
|
52
|
+
|
|
53
|
+
def reused_context() -> None:
|
|
54
|
+
with shared:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@TimerContext("section")
|
|
58
|
+
def decorated() -> None:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def nested() -> None:
|
|
62
|
+
with shared, shared, shared, shared, shared:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
measure("plain_call", plain)
|
|
66
|
+
measure("new_context", context)
|
|
67
|
+
measure("reused_context", reused_context)
|
|
68
|
+
measure("decorated_call", decorated)
|
|
69
|
+
measure("five_nested_contexts", nested)
|
|
70
|
+
|
|
71
|
+
async def plain_async() -> None:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
decorated_async = TimerContext("async_section")(plain_async)
|
|
75
|
+
|
|
76
|
+
async def measure_async() -> None:
|
|
77
|
+
for name, function in (("plain_await", plain_async), ("decorated_await", decorated_async)):
|
|
78
|
+
samples: list[float] = []
|
|
79
|
+
for _ in range(repeat):
|
|
80
|
+
clear_execution_timings()
|
|
81
|
+
started = time.perf_counter()
|
|
82
|
+
for _ in range(number):
|
|
83
|
+
await function()
|
|
84
|
+
samples.append((time.perf_counter() - started) * 1e9 / number)
|
|
85
|
+
results[name] = statistics.median(samples)
|
|
86
|
+
|
|
87
|
+
asyncio.run(measure_async())
|
|
88
|
+
|
|
89
|
+
clear_execution_timings()
|
|
90
|
+
for index in range(1_000):
|
|
91
|
+
with TimerContext("section", category=f"category{index % 10}", counter=index):
|
|
92
|
+
pass
|
|
93
|
+
quiet_logger = logging.getLogger("executiontimer.benchmark")
|
|
94
|
+
quiet_logger.setLevel(logging.WARNING)
|
|
95
|
+
reporting_count = max(1, number // 1_000)
|
|
96
|
+
for name, operation in (
|
|
97
|
+
("total_1000_sections", get_total_time),
|
|
98
|
+
("json_1000_sections", get_execution_times_json),
|
|
99
|
+
("disabled_logging_1000_sections", lambda: log_execution_times(logger=quiet_logger)),
|
|
100
|
+
):
|
|
101
|
+
results[name] = (
|
|
102
|
+
statistics.median(timeit.repeat(operation, number=reporting_count, repeat=repeat)) * 1e9 / reporting_count
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
print(
|
|
106
|
+
json.dumps(
|
|
107
|
+
{
|
|
108
|
+
"python": platform.python_version(),
|
|
109
|
+
"platform": platform.platform(),
|
|
110
|
+
"number": number,
|
|
111
|
+
"repeat": repeat,
|
|
112
|
+
"nanoseconds_per_operation": {name: round(value, 1) for name, value in results.items()},
|
|
113
|
+
},
|
|
114
|
+
indent=2,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
main()
|
|
@@ -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",
|
|
@@ -52,7 +54,16 @@ path = "src/execution_timer/__init__.py"
|
|
|
52
54
|
packages = ["src/execution_timer"]
|
|
53
55
|
|
|
54
56
|
[tool.hatch.build.targets.sdist]
|
|
55
|
-
include = [
|
|
57
|
+
include = [
|
|
58
|
+
"/src",
|
|
59
|
+
"/tests",
|
|
60
|
+
"/benchmarks",
|
|
61
|
+
"/README.md",
|
|
62
|
+
"/LICENSE",
|
|
63
|
+
"/CHANGELOG.md",
|
|
64
|
+
"/CONTRIBUTING.md",
|
|
65
|
+
"/pyproject.toml",
|
|
66
|
+
]
|
|
56
67
|
|
|
57
68
|
[dependency-groups]
|
|
58
69
|
dev = [
|
|
@@ -76,7 +87,7 @@ fail_under = 95
|
|
|
76
87
|
exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]
|
|
77
88
|
|
|
78
89
|
[tool.ruff]
|
|
79
|
-
target-version = "
|
|
90
|
+
target-version = "py311"
|
|
80
91
|
line-length = 120
|
|
81
92
|
src = ["src", "tests"]
|
|
82
93
|
|
|
@@ -94,5 +105,5 @@ select = [
|
|
|
94
105
|
|
|
95
106
|
[tool.basedpyright]
|
|
96
107
|
typeCheckingMode = "recommended"
|
|
97
|
-
pythonVersion = "3.
|
|
108
|
+
pythonVersion = "3.11"
|
|
98
109
|
include = ["src", "tests"]
|