executiontimer 0.1.0__tar.gz → 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- executiontimer-0.1.1/CHANGELOG.md +63 -0
- executiontimer-0.1.1/CONTRIBUTING.md +77 -0
- {executiontimer-0.1.0 → executiontimer-0.1.1}/PKG-INFO +44 -4
- {executiontimer-0.1.0 → executiontimer-0.1.1}/README.md +43 -3
- executiontimer-0.1.1/benchmarks/README.md +40 -0
- executiontimer-0.1.1/benchmarks/overhead.py +120 -0
- {executiontimer-0.1.0 → executiontimer-0.1.1}/pyproject.toml +10 -1
- {executiontimer-0.1.0 → executiontimer-0.1.1}/src/execution_timer/__init__.py +1 -1
- {executiontimer-0.1.0 → executiontimer-0.1.1}/src/execution_timer/_timer.py +92 -85
- {executiontimer-0.1.0 → executiontimer-0.1.1}/tests/execution_timer_test.py +307 -1
- executiontimer-0.1.0/CHANGELOG.md +0 -33
- {executiontimer-0.1.0 → executiontimer-0.1.1}/.gitignore +0 -0
- {executiontimer-0.1.0 → executiontimer-0.1.1}/LICENSE +0 -0
- {executiontimer-0.1.0 → executiontimer-0.1.1}/src/execution_timer/py.typed +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
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
|
|
@@ -0,0 +1,77 @@
|
|
|
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.12, 3.13 and 3.14 on Linux, macOS and Windows.
|
|
30
|
+
To check another interpreter locally:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv run --python 3.12 pytest
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For changes to recording or reporting performance, compare the benchmark on the same
|
|
37
|
+
machine and Python version, without coverage instrumentation:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv run python benchmarks/overhead.py --number 100000 --repeat 9
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
See [benchmarks/README.md](benchmarks/README.md) for methodology and reference results.
|
|
44
|
+
Timing results are advisory rather than CI pass/fail thresholds.
|
|
45
|
+
|
|
46
|
+
## Guidelines
|
|
47
|
+
|
|
48
|
+
- Tests exercise the **public API** only — import from `execution_timer`, not
|
|
49
|
+
`execution_timer._timer`. This keeps internals free to change.
|
|
50
|
+
- Every behaviour change needs a test that fails before the fix and passes after it.
|
|
51
|
+
- Public functions carry type annotations and a one-line docstring.
|
|
52
|
+
- Add an entry under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md).
|
|
53
|
+
|
|
54
|
+
## Releasing
|
|
55
|
+
|
|
56
|
+
Maintainers only:
|
|
57
|
+
|
|
58
|
+
1. Move the `## [Unreleased]` entries into a new version section in `CHANGELOG.md`, and
|
|
59
|
+
update the link definitions at the bottom.
|
|
60
|
+
2. Bump `__version__` in `src/execution_timer/__init__.py`, then run `uv lock --check`
|
|
61
|
+
to verify the lockfile. Package metadata reads the version from `__version__`.
|
|
62
|
+
3. Run the checks above, build with `uv build`, and validate metadata with
|
|
63
|
+
`uvx twine check --strict dist/*`. Use a clean output directory so old versions are
|
|
64
|
+
not included in release artifacts.
|
|
65
|
+
4. Commit, then push to `main` and wait for CI to pass.
|
|
66
|
+
5. Publish a GitHub release tagged `vX.Y.Z`, targeting the validated commit on `main`.
|
|
67
|
+
Use that version's changelog entries as release notes.
|
|
68
|
+
|
|
69
|
+
The [Publish to PyPI workflow](.github/workflows/publish.yml) runs when a GitHub release
|
|
70
|
+
is **published**. Pushing to `main`, pushing a tag alone, or saving a draft release does
|
|
71
|
+
not trigger it. The workflow builds and validates the distributions, checks that the tag
|
|
72
|
+
matches `__version__`, and uploads them to PyPI via Trusted Publishing. No manual
|
|
73
|
+
`twine upload` or PyPI API token is needed. If the `pypi` GitHub environment requires
|
|
74
|
+
approval, approve the publishing job there.
|
|
75
|
+
|
|
76
|
+
After publishing the release, check that the workflow succeeds and the new version
|
|
77
|
+
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.1.
|
|
3
|
+
Version: 0.1.1
|
|
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
|
|
@@ -145,6 +145,10 @@ get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
|
|
|
145
145
|
get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
Flattening removes the final integer suffix (including negative counters). Other
|
|
149
|
+
bracketed names such as `array[index]` are preserved. If merged entries have different
|
|
150
|
+
categories, the category from the most recently entered section is used.
|
|
151
|
+
|
|
148
152
|
### Categories
|
|
149
153
|
|
|
150
154
|
Categories are plain strings — use whatever fits your domain:
|
|
@@ -161,6 +165,10 @@ get_total_category_time("gpu")
|
|
|
161
165
|
`get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
|
|
162
166
|
section nested inside another `gpu` section is not double-counted.
|
|
163
167
|
|
|
168
|
+
Repeated calls to the same path accumulate time. If its category changes, the latest
|
|
169
|
+
category applies to that path's entire accumulated time. Use consistent categories per
|
|
170
|
+
path when you need separate category totals.
|
|
171
|
+
|
|
164
172
|
You can also forbid a category from appearing inside another, which raises a `ValueError`
|
|
165
173
|
as soon as the invalid nesting happens:
|
|
166
174
|
|
|
@@ -198,6 +206,9 @@ save_execution_timings_json("timings.json")
|
|
|
198
206
|
}
|
|
199
207
|
```
|
|
200
208
|
|
|
209
|
+
Sections and totals come from one snapshot. Category totals use the original paths,
|
|
210
|
+
even when flattening merges sections with different categories.
|
|
211
|
+
|
|
201
212
|
### Concurrency
|
|
202
213
|
|
|
203
214
|
The recorded timings live in one process-wide registry guarded by a lock. The *active
|
|
@@ -213,9 +224,38 @@ async def worker(n: int) -> None:
|
|
|
213
224
|
await asyncio.gather(worker(0), worker(1))
|
|
214
225
|
```
|
|
215
226
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
227
|
+
Overlapping calls to the same section path are supported: each call keeps its own start
|
|
228
|
+
time, and their durations are added together. These totals measure accumulated elapsed
|
|
229
|
+
time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
|
|
230
|
+
concurrent calls separately.
|
|
231
|
+
|
|
232
|
+
New asyncio tasks inherit the timing context in which they are created. Their sections
|
|
233
|
+
nest under that parent; changes to each task's active stack remain independent. Await
|
|
234
|
+
child tasks inside the parent section if you want the parent duration to include them.
|
|
235
|
+
|
|
236
|
+
### Reusing contexts and clearing timings
|
|
237
|
+
|
|
238
|
+
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
239
|
+
For a tight loop, reuse a context to avoid constructing one on every iteration:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
step_timer = TimerContext("step")
|
|
243
|
+
for item in items:
|
|
244
|
+
with step_timer:
|
|
245
|
+
process(item)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
249
|
+
samples from sections that were already active, without disturbing their nesting stack.
|
|
250
|
+
Sections started after the clear are recorded normally. Reports include completed calls;
|
|
251
|
+
an active section's current duration is added only when it exits.
|
|
252
|
+
|
|
253
|
+
### Measuring overhead
|
|
254
|
+
|
|
255
|
+
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
256
|
+
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).
|
|
258
|
+
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
219
259
|
|
|
220
260
|
## API
|
|
221
261
|
|
|
@@ -116,6 +116,10 @@ get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
|
|
|
116
116
|
get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
Flattening removes the final integer suffix (including negative counters). Other
|
|
120
|
+
bracketed names such as `array[index]` are preserved. If merged entries have different
|
|
121
|
+
categories, the category from the most recently entered section is used.
|
|
122
|
+
|
|
119
123
|
### Categories
|
|
120
124
|
|
|
121
125
|
Categories are plain strings — use whatever fits your domain:
|
|
@@ -132,6 +136,10 @@ get_total_category_time("gpu")
|
|
|
132
136
|
`get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
|
|
133
137
|
section nested inside another `gpu` section is not double-counted.
|
|
134
138
|
|
|
139
|
+
Repeated calls to the same path accumulate time. If its category changes, the latest
|
|
140
|
+
category applies to that path's entire accumulated time. Use consistent categories per
|
|
141
|
+
path when you need separate category totals.
|
|
142
|
+
|
|
135
143
|
You can also forbid a category from appearing inside another, which raises a `ValueError`
|
|
136
144
|
as soon as the invalid nesting happens:
|
|
137
145
|
|
|
@@ -169,6 +177,9 @@ save_execution_timings_json("timings.json")
|
|
|
169
177
|
}
|
|
170
178
|
```
|
|
171
179
|
|
|
180
|
+
Sections and totals come from one snapshot. Category totals use the original paths,
|
|
181
|
+
even when flattening merges sections with different categories.
|
|
182
|
+
|
|
172
183
|
### Concurrency
|
|
173
184
|
|
|
174
185
|
The recorded timings live in one process-wide registry guarded by a lock. The *active
|
|
@@ -184,9 +195,38 @@ async def worker(n: int) -> None:
|
|
|
184
195
|
await asyncio.gather(worker(0), worker(1))
|
|
185
196
|
```
|
|
186
197
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
198
|
+
Overlapping calls to the same section path are supported: each call keeps its own start
|
|
199
|
+
time, and their durations are added together. These totals measure accumulated elapsed
|
|
200
|
+
time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
|
|
201
|
+
concurrent calls separately.
|
|
202
|
+
|
|
203
|
+
New asyncio tasks inherit the timing context in which they are created. Their sections
|
|
204
|
+
nest under that parent; changes to each task's active stack remain independent. Await
|
|
205
|
+
child tasks inside the parent section if you want the parent duration to include them.
|
|
206
|
+
|
|
207
|
+
### Reusing contexts and clearing timings
|
|
208
|
+
|
|
209
|
+
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
210
|
+
For a tight loop, reuse a context to avoid constructing one on every iteration:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
step_timer = TimerContext("step")
|
|
214
|
+
for item in items:
|
|
215
|
+
with step_timer:
|
|
216
|
+
process(item)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
220
|
+
samples from sections that were already active, without disturbing their nesting stack.
|
|
221
|
+
Sections started after the clear are recorded normally. Reports include completed calls;
|
|
222
|
+
an active section's current duration is added only when it exits.
|
|
223
|
+
|
|
224
|
+
### Measuring overhead
|
|
225
|
+
|
|
226
|
+
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
227
|
+
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).
|
|
229
|
+
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
190
230
|
|
|
191
231
|
## API
|
|
192
232
|
|
|
@@ -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()
|
|
@@ -52,7 +52,16 @@ path = "src/execution_timer/__init__.py"
|
|
|
52
52
|
packages = ["src/execution_timer"]
|
|
53
53
|
|
|
54
54
|
[tool.hatch.build.targets.sdist]
|
|
55
|
-
include = [
|
|
55
|
+
include = [
|
|
56
|
+
"/src",
|
|
57
|
+
"/tests",
|
|
58
|
+
"/benchmarks",
|
|
59
|
+
"/README.md",
|
|
60
|
+
"/LICENSE",
|
|
61
|
+
"/CHANGELOG.md",
|
|
62
|
+
"/CONTRIBUTING.md",
|
|
63
|
+
"/pyproject.toml",
|
|
64
|
+
]
|
|
56
65
|
|
|
57
66
|
[dependency-groups]
|
|
58
67
|
dev = [
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Timings are stored in a process-wide registry guarded by a lock. The active-context stack
|
|
4
4
|
lives in a :class:`~contextvars.ContextVar`, so it is isolated per thread *and* per asyncio
|
|
5
5
|
task: sections recorded concurrently nest independently and merge into one report.
|
|
6
|
-
|
|
6
|
+
Overlapping calls to the same section accumulate their individual durations.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
@@ -14,11 +14,12 @@ import json
|
|
|
14
14
|
import logging
|
|
15
15
|
import threading
|
|
16
16
|
import time
|
|
17
|
-
from collections.abc import Callable, Coroutine, Iterable
|
|
17
|
+
from collections.abc import Callable, Coroutine, Iterable, Iterator
|
|
18
18
|
from contextvars import ContextVar
|
|
19
|
+
from itertools import count
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from types import TracebackType
|
|
21
|
-
from typing import
|
|
22
|
+
from typing import Final, NamedTuple, ParamSpec, TypedDict, TypeVar, cast
|
|
22
23
|
|
|
23
24
|
P = ParamSpec("P")
|
|
24
25
|
R = TypeVar("R")
|
|
@@ -28,9 +29,6 @@ DEFAULT_CATEGORY: Final = "default"
|
|
|
28
29
|
|
|
29
30
|
_LOGGER: Final = logging.getLogger(__name__)
|
|
30
31
|
|
|
31
|
-
# Stack of (name, category) frames for the current thread / asyncio task.
|
|
32
|
-
_ACTIVE_CONTEXT: ContextVar[tuple[tuple[str, str], ...]] = ContextVar("execution_timer_context", default=())
|
|
33
|
-
|
|
34
32
|
|
|
35
33
|
class TimingReport(TypedDict):
|
|
36
34
|
"""Timing entry for one section: elapsed seconds and its category."""
|
|
@@ -57,11 +55,24 @@ class TimingsPayload(TypedDict):
|
|
|
57
55
|
|
|
58
56
|
|
|
59
57
|
class _TimesDict(TypedDict):
|
|
60
|
-
|
|
58
|
+
sequence: int
|
|
61
59
|
elapsed_time: float
|
|
62
60
|
category: str
|
|
63
61
|
|
|
64
62
|
|
|
63
|
+
class _Frame(NamedTuple):
|
|
64
|
+
"""Per-invocation state, with a cached path and an immutable parent link."""
|
|
65
|
+
|
|
66
|
+
path: tuple[str, ...]
|
|
67
|
+
category: str
|
|
68
|
+
start_time: float
|
|
69
|
+
entry: _TimesDict
|
|
70
|
+
parent: _Frame | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_ACTIVE_CONTEXT: ContextVar[_Frame | None] = ContextVar("execution_timer_context", default=None)
|
|
74
|
+
|
|
75
|
+
|
|
65
76
|
def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ...]]:
|
|
66
77
|
"""Order section paths depth-first so children always follow their parent.
|
|
67
78
|
|
|
@@ -91,81 +102,53 @@ def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ..
|
|
|
91
102
|
|
|
92
103
|
|
|
93
104
|
class _ExecutionTimer:
|
|
94
|
-
"""
|
|
95
|
-
|
|
96
|
-
_instance: ClassVar[_ExecutionTimer | None] = None
|
|
97
|
-
_lock: ClassVar[threading.Lock] = threading.Lock()
|
|
98
|
-
timings: ClassVar[dict[tuple[str, ...], _TimesDict]] = {}
|
|
99
|
-
forbidden_nesting: ClassVar[set[tuple[str, str]]] = set()
|
|
100
|
-
|
|
101
|
-
def __new__(cls) -> _ExecutionTimer:
|
|
102
|
-
if cls._instance is None:
|
|
103
|
-
cls._instance = super().__new__(cls)
|
|
104
|
-
return cls._instance
|
|
105
|
+
"""Registry of named, nestable timing sections, shared through ``_TIMER``."""
|
|
105
106
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
self._lock: threading.Lock = threading.Lock()
|
|
109
|
+
self.timings: dict[tuple[str, ...], _TimesDict] = {}
|
|
110
|
+
self.forbidden_nesting: set[tuple[str, str]] = set()
|
|
111
|
+
self._sequence: Iterator[int] = count()
|
|
109
112
|
|
|
110
|
-
def
|
|
113
|
+
def snapshot(self) -> dict[tuple[str, ...], _TimesDict]:
|
|
111
114
|
"""Copy the registry under the lock so readers never iterate a mutating dict."""
|
|
112
115
|
with self._lock:
|
|
113
116
|
return {key: info.copy() for key, info in self.timings.items()}
|
|
114
117
|
|
|
115
118
|
def start_timer(self, name: str, category: str) -> None:
|
|
116
119
|
"""Start timing a section under the given name within the active context."""
|
|
117
|
-
|
|
118
|
-
full_name =
|
|
119
|
-
start_time = time.perf_counter()
|
|
120
|
+
parent = _ACTIVE_CONTEXT.get()
|
|
121
|
+
full_name = (*parent.path, name) if parent is not None else (name,)
|
|
120
122
|
with self._lock:
|
|
121
|
-
|
|
123
|
+
if parent is not None and (parent.category, category) in self.forbidden_nesting:
|
|
124
|
+
msg = f"Category '{category}' is not allowed inside category '{parent.category}'."
|
|
125
|
+
raise ValueError(msg)
|
|
126
|
+
sequence = next(self._sequence)
|
|
127
|
+
entry: _TimesDict | None = self.timings.get(full_name)
|
|
122
128
|
if entry is None:
|
|
123
|
-
|
|
129
|
+
entry = {"sequence": sequence, "elapsed_time": 0.0, "category": category}
|
|
130
|
+
self.timings[full_name] = entry
|
|
124
131
|
else:
|
|
125
|
-
entry["
|
|
132
|
+
entry["sequence"] = sequence
|
|
126
133
|
entry["category"] = category
|
|
134
|
+
_ = _ACTIVE_CONTEXT.set(_Frame(full_name, category, time.perf_counter(), entry, parent))
|
|
127
135
|
|
|
128
136
|
def stop_timer(self, name: str) -> None:
|
|
129
137
|
"""Stop timing a section and accumulate its elapsed time."""
|
|
130
138
|
end_time = time.perf_counter()
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
entry = self.timings.get(full_name)
|
|
134
|
-
# The entry is gone if the registry was cleared while this section was running;
|
|
135
|
-
# dropping the sample is preferable to raising out of a ``with`` block.
|
|
136
|
-
if entry is not None:
|
|
137
|
-
entry["elapsed_time"] += end_time - entry["start_time"]
|
|
138
|
-
self._remove_context(name)
|
|
139
|
-
|
|
140
|
-
def _add_context(self, name: str, category: str) -> None:
|
|
141
|
-
stack = _ACTIVE_CONTEXT.get()
|
|
142
|
-
if stack and (stack[-1][1], category) in self.forbidden_nesting:
|
|
143
|
-
msg = f"Category '{category}' is not allowed inside category '{stack[-1][1]}'."
|
|
144
|
-
raise ValueError(msg)
|
|
145
|
-
_ = _ACTIVE_CONTEXT.set((*stack, (name, category)))
|
|
146
|
-
|
|
147
|
-
def _remove_context(self, name: str) -> None:
|
|
148
|
-
"""Pop the innermost context, restoring the stack to its state before ``name`` was entered."""
|
|
149
|
-
stack = _ACTIVE_CONTEXT.get()
|
|
150
|
-
if not stack:
|
|
139
|
+
frame = _ACTIVE_CONTEXT.get()
|
|
140
|
+
if frame is None:
|
|
151
141
|
return
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def compute_flattened_timings(self) -> dict[tuple[str, ...], _TimesDict]:
|
|
160
|
-
"""Aggregate elapsed times with counter suffixes removed from section names.
|
|
161
|
-
|
|
162
|
-
When counter variants of one section are merged, elapsed times are summed and the
|
|
163
|
-
most recently recorded category is kept.
|
|
164
|
-
"""
|
|
165
|
-
return _flatten(self._snapshot())
|
|
142
|
+
if frame.path[-1] != name:
|
|
143
|
+
raise RuntimeError(f"Cannot stop '{name}' while '{frame.path[-1]}' is active.")
|
|
144
|
+
with self._lock:
|
|
145
|
+
# A clear detaches this entry from the registry. Updating the detached object
|
|
146
|
+
# cannot resurrect an old sample or add it to a replacement at the same path.
|
|
147
|
+
frame.entry["elapsed_time"] += end_time - frame.start_time
|
|
148
|
+
_ = _ACTIVE_CONTEXT.set(frame.parent)
|
|
166
149
|
|
|
167
150
|
def _resolve(self, *, flatten: bool) -> dict[tuple[str, ...], _TimesDict]:
|
|
168
|
-
snapshot = self.
|
|
151
|
+
snapshot = self.snapshot()
|
|
169
152
|
return _flatten(snapshot) if flatten else snapshot
|
|
170
153
|
|
|
171
154
|
def report_timings(self, *, flatten: bool = True) -> str:
|
|
@@ -185,12 +168,14 @@ class _ExecutionTimer:
|
|
|
185
168
|
|
|
186
169
|
def compute_total_time(self, *, flatten: bool = True) -> float:
|
|
187
170
|
"""Compute total elapsed time across all top-level sections."""
|
|
188
|
-
|
|
189
|
-
|
|
171
|
+
# Counter merging cannot change the sum. Avoid allocating and flattening a snapshot.
|
|
172
|
+
_ = flatten
|
|
173
|
+
with self._lock:
|
|
174
|
+
return sum((info["elapsed_time"] for key, info in self.timings.items() if len(key) == 1), 0.0)
|
|
190
175
|
|
|
191
176
|
def compute_total_category_time(self, category: str) -> float:
|
|
192
177
|
"""Compute total elapsed time in a category, counting only top-most entries of that category."""
|
|
193
|
-
timings = self.
|
|
178
|
+
timings = self.snapshot()
|
|
194
179
|
total_time = 0.0
|
|
195
180
|
for key, info in timings.items():
|
|
196
181
|
if info["category"] != category or _has_ancestor_with_category(timings, key, category):
|
|
@@ -208,6 +193,17 @@ class _ExecutionTimer:
|
|
|
208
193
|
with self._lock:
|
|
209
194
|
self.timings.clear()
|
|
210
195
|
|
|
196
|
+
def register_forbidden_nesting(self, outer: str, inner: str) -> None:
|
|
197
|
+
with self._lock:
|
|
198
|
+
self.forbidden_nesting.add((outer, inner))
|
|
199
|
+
|
|
200
|
+
def clear_forbidden_nesting(self) -> None:
|
|
201
|
+
with self._lock:
|
|
202
|
+
self.forbidden_nesting.clear()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_TIMER: Final = _ExecutionTimer()
|
|
206
|
+
|
|
211
207
|
|
|
212
208
|
def _flatten(timings: dict[tuple[str, ...], _TimesDict]) -> dict[tuple[str, ...], _TimesDict]:
|
|
213
209
|
flat_map: dict[tuple[str, ...], _TimesDict] = {}
|
|
@@ -218,7 +214,9 @@ def _flatten(timings: dict[tuple[str, ...], _TimesDict]) -> dict[tuple[str, ...]
|
|
|
218
214
|
flat_map[flat_key] = info.copy()
|
|
219
215
|
else:
|
|
220
216
|
existing["elapsed_time"] += info["elapsed_time"]
|
|
221
|
-
|
|
217
|
+
if info["sequence"] > existing["sequence"]:
|
|
218
|
+
existing["category"] = info["category"]
|
|
219
|
+
existing["sequence"] = info["sequence"]
|
|
222
220
|
return flat_map
|
|
223
221
|
|
|
224
222
|
|
|
@@ -234,7 +232,7 @@ class TimerContext:
|
|
|
234
232
|
def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
|
|
235
233
|
self.name: str = _build_name_with_counter(name, counter)
|
|
236
234
|
self.category: str = category
|
|
237
|
-
self.timer: _ExecutionTimer =
|
|
235
|
+
self.timer: _ExecutionTimer = _TIMER
|
|
238
236
|
|
|
239
237
|
def __enter__(self) -> TimerContext:
|
|
240
238
|
self.timer.start_timer(self.name, self.category)
|
|
@@ -259,7 +257,7 @@ class TimerContext:
|
|
|
259
257
|
|
|
260
258
|
@functools.wraps(func)
|
|
261
259
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
262
|
-
with
|
|
260
|
+
with self:
|
|
263
261
|
return func(*args, **kwargs)
|
|
264
262
|
|
|
265
263
|
return wrapper
|
|
@@ -269,7 +267,7 @@ class TimerContext:
|
|
|
269
267
|
|
|
270
268
|
@functools.wraps(func)
|
|
271
269
|
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
272
|
-
with
|
|
270
|
+
with self:
|
|
273
271
|
return await func(*args, **kwargs)
|
|
274
272
|
|
|
275
273
|
return async_wrapper
|
|
@@ -285,42 +283,51 @@ def _build_name_with_counter(name: str, counter: int | None = None) -> str:
|
|
|
285
283
|
def _basic_name_without_counter(name: str) -> str:
|
|
286
284
|
"""Strip a trailing ``[counter]`` from a section name if present."""
|
|
287
285
|
if "[" in name and name.endswith("]"):
|
|
288
|
-
|
|
286
|
+
prefix, _, suffix = name.rpartition("[")
|
|
287
|
+
digits = suffix[:-1].removeprefix("-")
|
|
288
|
+
if digits.isascii() and digits.isdecimal():
|
|
289
|
+
return prefix
|
|
289
290
|
return name
|
|
290
291
|
|
|
291
292
|
|
|
292
293
|
def get_execution_times_report(*, flatten: bool = True) -> str:
|
|
293
294
|
"""Get a formatted report of all recorded sections; flatten counters if requested."""
|
|
294
|
-
return
|
|
295
|
+
return _TIMER.report_timings(flatten=flatten)
|
|
295
296
|
|
|
296
297
|
|
|
297
298
|
def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
|
|
298
299
|
"""Log the execution-times report at INFO level; flatten counters if requested."""
|
|
299
|
-
|
|
300
|
+
target = logger if logger is not None else _LOGGER
|
|
301
|
+
if target.isEnabledFor(logging.INFO):
|
|
302
|
+
target.info(get_execution_times_report(flatten=flatten))
|
|
300
303
|
|
|
301
304
|
|
|
302
305
|
def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
|
|
303
306
|
"""Get elapsed seconds and category for every recorded section; flatten counters if requested."""
|
|
304
|
-
return
|
|
307
|
+
return _TIMER.get_execution_timings(flatten=flatten)
|
|
305
308
|
|
|
306
309
|
|
|
307
310
|
def _build_payload(*, flatten: bool = True) -> TimingsPayload:
|
|
308
311
|
"""Build a JSON-serializable snapshot of all timings, including totals and per-category sums."""
|
|
309
|
-
|
|
310
|
-
timings =
|
|
312
|
+
snapshot = _TIMER.snapshot()
|
|
313
|
+
timings = _flatten(snapshot) if flatten else snapshot
|
|
311
314
|
sections: list[SectionRecord] = [
|
|
312
315
|
{
|
|
313
316
|
"name": key[-1],
|
|
314
317
|
"path": list(key),
|
|
315
|
-
"time": round(timings[key]["
|
|
318
|
+
"time": round(timings[key]["elapsed_time"], 6),
|
|
316
319
|
"category": timings[key]["category"],
|
|
317
320
|
}
|
|
318
321
|
for key in _ordered_by_hierarchy(timings)
|
|
319
322
|
]
|
|
320
|
-
|
|
323
|
+
category_totals: dict[str, float] = {}
|
|
324
|
+
for key, info in snapshot.items():
|
|
325
|
+
category = info["category"]
|
|
326
|
+
if not _has_ancestor_with_category(snapshot, key, category):
|
|
327
|
+
category_totals[category] = category_totals.get(category, 0.0) + info["elapsed_time"]
|
|
321
328
|
return {
|
|
322
|
-
"total_time": round(
|
|
323
|
-
"total_category_time": {cat: round(
|
|
329
|
+
"total_time": round(sum((info["elapsed_time"] for key, info in snapshot.items() if len(key) == 1), 0.0), 6),
|
|
330
|
+
"total_category_time": {cat: round(category_totals[cat], 6) for cat in sorted(category_totals)},
|
|
324
331
|
"sections": sections,
|
|
325
332
|
}
|
|
326
333
|
|
|
@@ -339,24 +346,24 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
|
|
|
339
346
|
|
|
340
347
|
def get_total_time(*, flatten: bool = True) -> float:
|
|
341
348
|
"""Get total elapsed seconds across all top-level sections."""
|
|
342
|
-
return
|
|
349
|
+
return _TIMER.compute_total_time(flatten=flatten)
|
|
343
350
|
|
|
344
351
|
|
|
345
352
|
def get_total_category_time(category: str) -> float:
|
|
346
353
|
"""Get total elapsed seconds in a category, counting only top-most entries of that category."""
|
|
347
|
-
return
|
|
354
|
+
return _TIMER.compute_total_category_time(category)
|
|
348
355
|
|
|
349
356
|
|
|
350
357
|
def clear_execution_timings() -> None:
|
|
351
358
|
"""Reset all recorded timings."""
|
|
352
|
-
|
|
359
|
+
_TIMER.clear()
|
|
353
360
|
|
|
354
361
|
|
|
355
362
|
def register_forbidden_nesting(outer: str, inner: str) -> None:
|
|
356
363
|
"""Forbid timing sections of category ``inner`` directly inside sections of category ``outer``."""
|
|
357
|
-
|
|
364
|
+
_TIMER.register_forbidden_nesting(outer, inner)
|
|
358
365
|
|
|
359
366
|
|
|
360
367
|
def clear_forbidden_nesting() -> None:
|
|
361
368
|
"""Remove all forbidden-nesting rules."""
|
|
362
|
-
|
|
369
|
+
_TIMER.clear_forbidden_nesting()
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
"""Tests for the execution timer, using only the public API."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import builtins
|
|
4
5
|
import inspect
|
|
5
6
|
import json
|
|
6
7
|
import logging
|
|
8
|
+
import threading
|
|
7
9
|
import time
|
|
8
10
|
from collections.abc import Iterator
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from contextlib import ExitStack
|
|
9
13
|
from pathlib import Path
|
|
10
14
|
from typing import cast
|
|
11
15
|
from unittest.mock import patch
|
|
@@ -543,7 +547,11 @@ class TestAsyncDecorator:
|
|
|
543
547
|
|
|
544
548
|
asyncio.run(work())
|
|
545
549
|
|
|
546
|
-
|
|
550
|
+
# Timing only the coroutine object's creation records ~1e-06 s, so any threshold
|
|
551
|
+
# orders of magnitude above that proves the await was covered. Kept well below the
|
|
552
|
+
# sleep duration because Windows' ~15.6 ms timer granularity lets short sleeps
|
|
553
|
+
# return early -- a threshold near 0.05 makes this test flaky rather than stricter.
|
|
554
|
+
assert raw_timings()["async_work",]["time"] >= 0.01
|
|
547
555
|
|
|
548
556
|
def test_async_decorator_preserves_return_value(self) -> None:
|
|
549
557
|
@TimerContext("async_compute")
|
|
@@ -686,3 +694,301 @@ class TestRobustness:
|
|
|
686
694
|
assert get_execution_times_report() == ""
|
|
687
695
|
|
|
688
696
|
assert [record.name for record in caplog.records] == ["execution_timer._timer"]
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
class TestRegressions:
|
|
700
|
+
@pytest.mark.parametrize("clear_between_calls", [False, True])
|
|
701
|
+
def test_overlapping_tasks_keep_independent_start_times(self, clear_between_calls: bool) -> None:
|
|
702
|
+
now = 0.0
|
|
703
|
+
shared = TimerContext("shared")
|
|
704
|
+
|
|
705
|
+
async def main() -> None:
|
|
706
|
+
nonlocal now
|
|
707
|
+
started = [asyncio.Event(), asyncio.Event()]
|
|
708
|
+
release = [asyncio.Event(), asyncio.Event()]
|
|
709
|
+
|
|
710
|
+
async def worker(index: int) -> None:
|
|
711
|
+
with shared:
|
|
712
|
+
started[index].set()
|
|
713
|
+
_ = await release[index].wait()
|
|
714
|
+
|
|
715
|
+
first = asyncio.create_task(worker(0))
|
|
716
|
+
_ = await started[0].wait()
|
|
717
|
+
if clear_between_calls:
|
|
718
|
+
clear_execution_timings()
|
|
719
|
+
now = 2.0
|
|
720
|
+
second = asyncio.create_task(worker(1))
|
|
721
|
+
_ = await started[1].wait()
|
|
722
|
+
now = 5.0
|
|
723
|
+
release[0].set()
|
|
724
|
+
await first
|
|
725
|
+
now = 9.0
|
|
726
|
+
release[1].set()
|
|
727
|
+
await second
|
|
728
|
+
|
|
729
|
+
with patch.object(time, "perf_counter", side_effect=lambda: now):
|
|
730
|
+
asyncio.run(main())
|
|
731
|
+
|
|
732
|
+
assert raw_timings() == {("shared",): {"time": 7.0 if clear_between_calls else 12.0, "category": "default"}}
|
|
733
|
+
|
|
734
|
+
def test_overlapping_thread_decorator_calls_accumulate_each_duration(self) -> None:
|
|
735
|
+
now = 0.0
|
|
736
|
+
started = [threading.Event(), threading.Event()]
|
|
737
|
+
release = [threading.Event(), threading.Event()]
|
|
738
|
+
|
|
739
|
+
@TimerContext("shared")
|
|
740
|
+
def worker(index: int) -> None:
|
|
741
|
+
started[index].set()
|
|
742
|
+
assert release[index].wait(timeout=5)
|
|
743
|
+
|
|
744
|
+
with patch.object(time, "perf_counter", side_effect=lambda: now), ThreadPoolExecutor(max_workers=2) as pool:
|
|
745
|
+
first = pool.submit(worker, 0)
|
|
746
|
+
try:
|
|
747
|
+
assert started[0].wait(timeout=5)
|
|
748
|
+
now = 2.0
|
|
749
|
+
second = pool.submit(worker, 1)
|
|
750
|
+
assert started[1].wait(timeout=5)
|
|
751
|
+
now = 5.0
|
|
752
|
+
release[0].set()
|
|
753
|
+
first.result(timeout=5)
|
|
754
|
+
now = 9.0
|
|
755
|
+
release[1].set()
|
|
756
|
+
second.result(timeout=5)
|
|
757
|
+
finally:
|
|
758
|
+
for event in release:
|
|
759
|
+
event.set()
|
|
760
|
+
|
|
761
|
+
assert raw_timings()["shared",]["time"] == 12.0
|
|
762
|
+
|
|
763
|
+
def test_flatten_category_follows_latest_revisit_even_with_equal_clock_readings(self) -> None:
|
|
764
|
+
with patch.object(time, "perf_counter", return_value=0.0):
|
|
765
|
+
for counter, category in [(0, "gpu"), (1, "cpu"), (0, "io")]:
|
|
766
|
+
with TimerContext("step", counter=counter, category=category):
|
|
767
|
+
pass
|
|
768
|
+
|
|
769
|
+
assert get_execution_timings()["step",]["category"] == "io"
|
|
770
|
+
|
|
771
|
+
@pytest.mark.parametrize("name", ["array[index]", "empty[]", "label[+1]", "label[1.5]", "label[\uff11\uff12]"])
|
|
772
|
+
def test_flatten_preserves_non_counter_brackets(self, name: str) -> None:
|
|
773
|
+
with TimerContext(name):
|
|
774
|
+
pass
|
|
775
|
+
assert list(get_execution_timings()) == [(name,)]
|
|
776
|
+
|
|
777
|
+
@pytest.mark.parametrize("counter", [-12, 0, 12])
|
|
778
|
+
def test_signed_counters_flatten_only_the_last_suffix(self, counter: int) -> None:
|
|
779
|
+
with TimerContext("array[index]", counter=counter):
|
|
780
|
+
pass
|
|
781
|
+
assert list(get_execution_timings()) == [("array[index]",)]
|
|
782
|
+
|
|
783
|
+
@pytest.mark.parametrize("flatten", [False, True])
|
|
784
|
+
def test_json_keeps_categories_hidden_by_flattening(self, flatten: bool) -> None:
|
|
785
|
+
with patch.object(time, "perf_counter", side_effect=[0, 2, 3, 6]):
|
|
786
|
+
with TimerContext("step", counter=0, category="gpu"):
|
|
787
|
+
pass
|
|
788
|
+
with TimerContext("step", counter=1, category="cpu"):
|
|
789
|
+
pass
|
|
790
|
+
|
|
791
|
+
payload = cast(TimingsPayload, json.loads(get_execution_times_json(flatten=flatten)))
|
|
792
|
+
assert payload["total_category_time"] == {"gpu": 2.0, "cpu": 3.0}
|
|
793
|
+
assert payload["total_time"] == 5.0
|
|
794
|
+
|
|
795
|
+
@pytest.mark.parametrize("flatten", [False, True])
|
|
796
|
+
def test_json_uses_one_snapshot_for_sections_and_totals(self, flatten: bool) -> None:
|
|
797
|
+
with patch.object(time, "perf_counter", side_effect=[0, 2]), TimerContext("section", category="cpu"):
|
|
798
|
+
pass
|
|
799
|
+
|
|
800
|
+
original_round = round
|
|
801
|
+
|
|
802
|
+
def clear_while_formatting(value: float, digits: int) -> float:
|
|
803
|
+
# Deterministically simulate another thread clearing the registry after the
|
|
804
|
+
# sections were read but before totals are formatted, through the public API.
|
|
805
|
+
clear_execution_timings()
|
|
806
|
+
return original_round(value, digits)
|
|
807
|
+
|
|
808
|
+
with patch.object(builtins, "round", side_effect=clear_while_formatting):
|
|
809
|
+
payload = cast(TimingsPayload, json.loads(get_execution_times_json(flatten=flatten)))
|
|
810
|
+
|
|
811
|
+
assert payload["sections"] == [{"name": "section", "path": ["section"], "time": 2.0, "category": "cpu"}]
|
|
812
|
+
assert payload["total_time"] == 2.0
|
|
813
|
+
assert payload["total_category_time"] == {"cpu": 2.0}
|
|
814
|
+
|
|
815
|
+
def test_disabled_logging_does_not_warn_about_empty_report(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
816
|
+
logger = logging.getLogger("executiontimer.test.disabled")
|
|
817
|
+
with caplog.at_level(logging.WARNING), patch.object(logger, "isEnabledFor", return_value=False):
|
|
818
|
+
log_execution_times(logger=logger)
|
|
819
|
+
assert caplog.records == []
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
class TestLifecycle:
|
|
823
|
+
def test_reusing_one_context_accumulates_exact_durations(self) -> None:
|
|
824
|
+
context = TimerContext("reused")
|
|
825
|
+
with patch.object(time, "perf_counter", side_effect=[0, 2, 5, 8]):
|
|
826
|
+
with context:
|
|
827
|
+
pass
|
|
828
|
+
with context:
|
|
829
|
+
pass
|
|
830
|
+
assert raw_timings()["reused",]["time"] == 5.0
|
|
831
|
+
|
|
832
|
+
def test_one_context_can_be_reentered(self) -> None:
|
|
833
|
+
context = TimerContext("recursive")
|
|
834
|
+
with patch.object(time, "perf_counter", side_effect=[0, 1, 3, 7]), context, context:
|
|
835
|
+
pass
|
|
836
|
+
assert raw_timings() == {
|
|
837
|
+
("recursive",): {"time": 7.0, "category": "default"},
|
|
838
|
+
("recursive", "recursive"): {"time": 2.0, "category": "default"},
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
def test_recursive_decorator_preserves_arguments_and_hierarchy(self) -> None:
|
|
842
|
+
@TimerContext("recursive", category="cpu", counter=0)
|
|
843
|
+
def recurse(depth: int, *, value: int) -> int:
|
|
844
|
+
return recurse(depth - 1, value=value + 1) if depth else value
|
|
845
|
+
|
|
846
|
+
with patch.object(time, "perf_counter", side_effect=range(6)):
|
|
847
|
+
assert recurse(2, value=40) == 42
|
|
848
|
+
assert [info["time"] for info in raw_timings().values()] == [5.0, 3.0, 1.0]
|
|
849
|
+
assert list(raw_timings()) == [("recursive[0]",) * depth for depth in range(1, 4)]
|
|
850
|
+
|
|
851
|
+
def test_failed_entry_leaves_parent_and_its_next_child_intact(self) -> None:
|
|
852
|
+
register_forbidden_nesting("gpu", "cpu")
|
|
853
|
+
with TimerContext("parent", category="gpu"):
|
|
854
|
+
with pytest.raises(ValueError), TimerContext("forbidden", category="cpu"):
|
|
855
|
+
pytest.fail("A forbidden section must not be entered")
|
|
856
|
+
with TimerContext("allowed", category="io"):
|
|
857
|
+
pass
|
|
858
|
+
with TimerContext("after"):
|
|
859
|
+
pass
|
|
860
|
+
assert list(raw_timings()) == [("parent",), ("parent", "allowed"), ("after",)]
|
|
861
|
+
|
|
862
|
+
def test_nesting_rules_only_apply_to_the_direct_parent(self) -> None:
|
|
863
|
+
register_forbidden_nesting("gpu", "cpu")
|
|
864
|
+
with (
|
|
865
|
+
TimerContext("gpu", category="gpu"),
|
|
866
|
+
TimerContext("io", category="io"),
|
|
867
|
+
TimerContext("cpu", category="cpu"),
|
|
868
|
+
):
|
|
869
|
+
pass
|
|
870
|
+
assert ("gpu", "io", "cpu") in raw_timings()
|
|
871
|
+
|
|
872
|
+
def test_out_of_order_exit_raises_without_changing_the_active_section(self) -> None:
|
|
873
|
+
outer = TimerContext("outer")
|
|
874
|
+
inner = TimerContext("inner")
|
|
875
|
+
with (
|
|
876
|
+
patch.object(time, "perf_counter", side_effect=[0, 1, 2, 3, 4]),
|
|
877
|
+
outer,
|
|
878
|
+
inner,
|
|
879
|
+
pytest.raises(RuntimeError, match="Cannot stop 'outer' while 'inner' is active"),
|
|
880
|
+
):
|
|
881
|
+
outer.__exit__(None, None, None)
|
|
882
|
+
assert raw_timings()["outer",]["time"] == 4.0
|
|
883
|
+
assert raw_timings()["outer", "inner"]["time"] == 2.0
|
|
884
|
+
|
|
885
|
+
def test_exit_without_an_active_section_is_a_noop(self) -> None:
|
|
886
|
+
TimerContext("unused").__exit__(None, None, None)
|
|
887
|
+
assert raw_timings() == {}
|
|
888
|
+
|
|
889
|
+
def test_reports_preserve_orphaned_children_after_clear(self) -> None:
|
|
890
|
+
with patch.object(time, "perf_counter", side_effect=[0, 1, 3, 5]), TimerContext("parent"):
|
|
891
|
+
clear_execution_timings()
|
|
892
|
+
with TimerContext("child", category="cpu"):
|
|
893
|
+
pass
|
|
894
|
+
assert list(raw_timings()) == [("parent", "child")]
|
|
895
|
+
assert "child: 2.0000 s (0.00%)" in get_execution_times_report()
|
|
896
|
+
payload = cast(TimingsPayload, json.loads(get_execution_times_json()))
|
|
897
|
+
assert payload["total_time"] == 0.0
|
|
898
|
+
assert payload["total_category_time"] == {"cpu": 2.0}
|
|
899
|
+
assert payload["sections"][0]["path"] == ["parent", "child"]
|
|
900
|
+
|
|
901
|
+
def test_deep_reports_do_not_depend_on_python_recursion_limit(self) -> None:
|
|
902
|
+
with ExitStack() as stack:
|
|
903
|
+
for _ in range(1_100):
|
|
904
|
+
_ = stack.enter_context(TimerContext("level"))
|
|
905
|
+
assert len(get_execution_times_report(flatten=False).splitlines()) == 1_103
|
|
906
|
+
|
|
907
|
+
def test_async_cancellation_records_time_and_restores_parent(self) -> None:
|
|
908
|
+
@TimerContext("cancelled")
|
|
909
|
+
async def work(*, value: int) -> int:
|
|
910
|
+
assert value == 42
|
|
911
|
+
await asyncio.sleep(0)
|
|
912
|
+
raise asyncio.CancelledError
|
|
913
|
+
|
|
914
|
+
async def main() -> None:
|
|
915
|
+
with TimerContext("parent"):
|
|
916
|
+
with pytest.raises(asyncio.CancelledError):
|
|
917
|
+
_ = await work(value=42)
|
|
918
|
+
with TimerContext("after"):
|
|
919
|
+
pass
|
|
920
|
+
|
|
921
|
+
with patch.object(time, "perf_counter", side_effect=range(6)):
|
|
922
|
+
asyncio.run(main())
|
|
923
|
+
assert raw_timings()["parent", "cancelled"]["time"] == 1.0
|
|
924
|
+
assert list(raw_timings()) == [("parent",), ("parent", "cancelled"), ("parent", "after")]
|
|
925
|
+
|
|
926
|
+
def test_child_tasks_inherit_the_parent_without_modifying_its_context(self) -> None:
|
|
927
|
+
@TimerContext("child")
|
|
928
|
+
async def child(value: int, *, increment: int) -> int:
|
|
929
|
+
await asyncio.sleep(0)
|
|
930
|
+
return value + increment
|
|
931
|
+
|
|
932
|
+
async def main() -> None:
|
|
933
|
+
with TimerContext("parent"):
|
|
934
|
+
assert await asyncio.gather(child(1, increment=1), child(2, increment=1)) == [2, 3]
|
|
935
|
+
with TimerContext("after"):
|
|
936
|
+
pass
|
|
937
|
+
|
|
938
|
+
asyncio.run(main())
|
|
939
|
+
assert list(raw_timings()) == [("parent",), ("parent", "child"), ("parent", "after")]
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
class TestSnapshotSemantics:
|
|
943
|
+
@pytest.mark.parametrize("flatten", [False, True])
|
|
944
|
+
def test_mutating_a_returned_report_does_not_change_the_registry(self, flatten: bool) -> None:
|
|
945
|
+
with patch.object(time, "perf_counter", side_effect=[0, 2]), TimerContext("section"):
|
|
946
|
+
pass
|
|
947
|
+
report = get_execution_timings(flatten=flatten)
|
|
948
|
+
report["section",]["time"] = -1
|
|
949
|
+
report["section",]["category"] = "changed"
|
|
950
|
+
report.clear()
|
|
951
|
+
assert raw_timings() == {("section",): {"time": 2.0, "category": "default"}}
|
|
952
|
+
|
|
953
|
+
@pytest.mark.parametrize("flatten", [False, True])
|
|
954
|
+
def test_json_category_totals_exclude_matching_ancestors_across_other_categories(self, flatten: bool) -> None:
|
|
955
|
+
with (
|
|
956
|
+
patch.object(time, "perf_counter", side_effect=range(6)),
|
|
957
|
+
TimerContext("outer", category="cpu", counter=1),
|
|
958
|
+
TimerContext("middle", category="io"),
|
|
959
|
+
TimerContext("inner", category="cpu"),
|
|
960
|
+
):
|
|
961
|
+
pass
|
|
962
|
+
payload = cast(TimingsPayload, json.loads(get_execution_times_json(flatten=flatten)))
|
|
963
|
+
assert payload["total_category_time"] == {"cpu": 5.0, "io": 3.0}
|
|
964
|
+
assert get_total_category_time("cpu") == 5.0
|
|
965
|
+
assert get_total_time(flatten=flatten) == 5.0
|
|
966
|
+
|
|
967
|
+
def test_empty_json(self) -> None:
|
|
968
|
+
assert json.loads(get_execution_times_json(indent=None)) == {
|
|
969
|
+
"total_time": 0.0,
|
|
970
|
+
"total_category_time": {},
|
|
971
|
+
"sections": [],
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
def test_unicode_json_file_round_trip(self, tmp_path: Path) -> None:
|
|
975
|
+
with TimerContext("计算", category="数据"):
|
|
976
|
+
pass
|
|
977
|
+
out = tmp_path / "timings.json"
|
|
978
|
+
assert save_execution_timings_json(str(out), indent=None) == out
|
|
979
|
+
text = out.read_text(encoding="utf-8")
|
|
980
|
+
assert text.endswith("\n")
|
|
981
|
+
assert len(text.splitlines()) == 1
|
|
982
|
+
payload = cast(TimingsPayload, json.loads(text))
|
|
983
|
+
assert payload["sections"][0]["name"] == "计算"
|
|
984
|
+
assert payload["sections"][0]["category"] == "数据"
|
|
985
|
+
|
|
986
|
+
def test_custom_logger_receives_the_requested_report(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
987
|
+
with TimerContext("section", counter=0):
|
|
988
|
+
pass
|
|
989
|
+
logger = logging.getLogger("executiontimer.test.custom")
|
|
990
|
+
with caplog.at_level(logging.INFO, logger=logger.name):
|
|
991
|
+
log_execution_times(flatten=False, logger=logger)
|
|
992
|
+
assert [(record.name, record.message) for record in caplog.records] == [
|
|
993
|
+
(logger.name, get_execution_times_report(flatten=False))
|
|
994
|
+
]
|
|
@@ -1,33 +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.0] - 2026-08-20
|
|
11
|
-
|
|
12
|
-
First public release on PyPI.
|
|
13
|
-
|
|
14
|
-
### Added
|
|
15
|
-
|
|
16
|
-
- `TimerContext` — context manager and decorator for timing a named section of code, with
|
|
17
|
-
optional `category` and `counter` arguments.
|
|
18
|
-
- Automatic hierarchical nesting: section names reflect the enclosing timing contexts.
|
|
19
|
-
- Native `async def` support — decorating a coroutine function times the whole `await`
|
|
20
|
-
rather than the creation of the coroutine object.
|
|
21
|
-
- Per-task and per-thread context isolation via `contextvars`, so concurrently recorded
|
|
22
|
-
sections nest independently and merge into one process-wide report.
|
|
23
|
-
- Reporting and export helpers: `get_execution_times_report`, `log_execution_times`,
|
|
24
|
-
`get_execution_timings`, `get_execution_times_json`, `save_execution_timings_json`,
|
|
25
|
-
`get_total_time`, `get_total_category_time`.
|
|
26
|
-
- Optional nesting rules via `register_forbidden_nesting` / `clear_forbidden_nesting`.
|
|
27
|
-
- `clear_execution_timings` to reset the registry.
|
|
28
|
-
- Exported `TimingReport`, `SectionRecord` and `TimingsPayload` typed dictionaries, plus a
|
|
29
|
-
`py.typed` marker so type checkers use the inline annotations.
|
|
30
|
-
- `__version__` attribute on the package.
|
|
31
|
-
|
|
32
|
-
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...HEAD
|
|
33
|
-
[0.1.0]: https://github.com/seba2390/ExecutionTimer/releases/tag/v0.1.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|