executiontimer 1.0.0__tar.gz → 1.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {executiontimer-1.0.0 → executiontimer-1.0.2}/CHANGELOG.md +46 -1
- {executiontimer-1.0.0 → executiontimer-1.0.2}/CONTRIBUTING.md +5 -4
- {executiontimer-1.0.0 → executiontimer-1.0.2}/PKG-INFO +12 -6
- {executiontimer-1.0.0 → executiontimer-1.0.2}/README.md +11 -5
- {executiontimer-1.0.0 → executiontimer-1.0.2}/benchmarks/README.md +12 -12
- {executiontimer-1.0.0 → executiontimer-1.0.2}/pyproject.toml +6 -2
- {executiontimer-1.0.0 → executiontimer-1.0.2}/src/execution_timer/__init__.py +1 -1
- {executiontimer-1.0.0 → executiontimer-1.0.2}/src/execution_timer/_timer.py +56 -27
- {executiontimer-1.0.0 → executiontimer-1.0.2}/tests/execution_timer_test.py +128 -4
- {executiontimer-1.0.0 → executiontimer-1.0.2}/.gitignore +0 -0
- {executiontimer-1.0.0 → executiontimer-1.0.2}/LICENSE +0 -0
- {executiontimer-1.0.0 → executiontimer-1.0.2}/benchmarks/overhead.py +0 -0
- {executiontimer-1.0.0 → executiontimer-1.0.2}/src/execution_timer/py.typed +0 -0
|
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.2] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Decorating a function that returns a coroutine, typically because another decorator
|
|
15
|
+
sits between `TimerContext` and an `async def`, now times the `await`. Previously the
|
|
16
|
+
section recorded only the call that created the coroutine, a few microseconds however
|
|
17
|
+
long the coroutine ran. Other awaitables, such as futures and tasks, are returned
|
|
18
|
+
unchanged.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- The test suite fails on any warning, and CI requires 100% statement and branch coverage
|
|
23
|
+
rather than 95%.
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
- Note that flattening also merges sections whose own names end in an integer, such as
|
|
28
|
+
`"row[1]"` and `"row[2]"`.
|
|
29
|
+
- The release steps in the contributing guide describe the pull-request flow that the
|
|
30
|
+
protected `main` branch requires.
|
|
31
|
+
- Refresh the overhead benchmark against version 1.0.2.
|
|
32
|
+
|
|
33
|
+
## [1.0.1] - 2026-09-30
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Exiting a section while an inner one is still active no longer leaves the active stack
|
|
38
|
+
corrupted when warnings are configured as errors (for example pytest's
|
|
39
|
+
`filterwarnings = ["error"]`). The `RuntimeWarning` was emitted before the exit was
|
|
40
|
+
recorded, so raising it skipped the cleanup that 1.0.0 introduced. The section is now
|
|
41
|
+
recorded and the stack unwound before the warning is emitted.
|
|
42
|
+
- Sections whose parent was cleared while active now count as top-level. Previously,
|
|
43
|
+
clearing timings inside an outer section, such as a periodic clear in a long-running
|
|
44
|
+
loop, made `get_total_time()`, the report and the JSON `total_time` read `0`, every
|
|
45
|
+
percentage `0.00%`, and indented the sections beneath an unrelated one in the report.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- The build requires `hatchling>=1.27`, the first release that supports the PEP 639
|
|
50
|
+
`license-files` metadata the project declares.
|
|
51
|
+
- Dependabot groups its monthly updates into one pull request per ecosystem.
|
|
52
|
+
|
|
10
53
|
## [1.0.0] - 2026-09-30
|
|
11
54
|
|
|
12
55
|
First stable release. The public API is now covered by semantic versioning: breaking
|
|
@@ -129,7 +172,9 @@ First public release on PyPI.
|
|
|
129
172
|
`py.typed` marker so type checkers use the inline annotations.
|
|
130
173
|
- `__version__` attribute on the package.
|
|
131
174
|
|
|
132
|
-
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.
|
|
175
|
+
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.2...HEAD
|
|
176
|
+
[1.0.2]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.1...v1.0.2
|
|
177
|
+
[1.0.1]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.0...v1.0.1
|
|
133
178
|
[1.0.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.2.0...v1.0.0
|
|
134
179
|
[0.2.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...v0.2.0
|
|
135
180
|
[0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
|
|
@@ -23,8 +23,8 @@ uv run ruff format
|
|
|
23
23
|
uv run basedpyright
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
All four must pass.
|
|
27
|
-
|
|
26
|
+
All four must pass. The suite has 100% statement and branch coverage, and CI enforces it.
|
|
27
|
+
Any warning raised during the tests fails them.
|
|
28
28
|
|
|
29
29
|
The test suite is also run against Python 3.11, 3.12, 3.13, 3.14 and free-threaded 3.14t
|
|
30
30
|
on Linux, macOS and Windows.
|
|
@@ -63,8 +63,9 @@ Maintainers only:
|
|
|
63
63
|
3. Run the checks above, build with `uv build`, and validate metadata with
|
|
64
64
|
`uvx twine check --strict dist/*`. Use a clean output directory so old versions are
|
|
65
65
|
not included in release artifacts.
|
|
66
|
-
4. Commit
|
|
67
|
-
|
|
66
|
+
4. Commit on a branch and open a pull request. `main` is protected: it only accepts
|
|
67
|
+
pull requests whose `CI passed` check succeeds. Squash-merge once CI is green.
|
|
68
|
+
5. Publish a GitHub release tagged `vX.Y.Z`, targeting the merged commit on `main`.
|
|
68
69
|
Use that version's changelog entries as release notes.
|
|
69
70
|
|
|
70
71
|
The [Publish to PyPI workflow](.github/workflows/publish.yml) runs when a GitHub release
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: executiontimer
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Hierarchical execution timing with user-defined categories.
|
|
5
5
|
Project-URL: Homepage, https://github.com/seba2390/ExecutionTimer
|
|
6
6
|
Project-URL: Documentation, https://github.com/seba2390/ExecutionTimer#readme
|
|
@@ -134,6 +134,9 @@ Coroutine functions are supported natively — the timing spans the entire `awai
|
|
|
134
134
|
async def fetch(url: str) -> bytes: ...
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
+
This also holds when another decorator sits between `TimerContext` and the `async def`
|
|
138
|
+
and returns its coroutine: the section covers both the call and the `await`.
|
|
139
|
+
|
|
137
140
|
Generator functions (including `async` generators) cannot be decorated and raise a
|
|
138
141
|
`TypeError`: the decorator would time only the creation of the generator object, not its
|
|
139
142
|
iteration. Time the loop that consumes the generator with a `with` block instead.
|
|
@@ -153,7 +156,9 @@ get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., .
|
|
|
153
156
|
```
|
|
154
157
|
|
|
155
158
|
Flattening removes the final integer suffix (including negative counters). Other
|
|
156
|
-
bracketed names such as `array[index]` are preserved.
|
|
159
|
+
bracketed names such as `array[index]` are preserved. Flattening cannot tell a counter
|
|
160
|
+
from a name you wrote yourself, so sections named `"row[1]"` and `"row[2]"` are also merged
|
|
161
|
+
into `row`; use `flatten=False` to keep them apart. If merged entries have different
|
|
157
162
|
categories, the category from the most recently entered section is used.
|
|
158
163
|
|
|
159
164
|
Each counter value is stored as its own section until `clear_execution_timings()` is
|
|
@@ -260,7 +265,8 @@ time the loop that consumes the generator instead.
|
|
|
260
265
|
If the caller's section exits while a paused generator's section is still open, the
|
|
261
266
|
timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
|
|
262
267
|
discards the generator's unfinished one, so later sections nest correctly. Closing that
|
|
263
|
-
generator afterwards does nothing.
|
|
268
|
+
generator afterwards does nothing. If warnings are configured as errors, the warning is
|
|
269
|
+
raised only after that cleanup, so the timings and nesting stay consistent.
|
|
264
270
|
|
|
265
271
|
### Reusing contexts and clearing timings
|
|
266
272
|
|
|
@@ -276,7 +282,8 @@ for item in items:
|
|
|
276
282
|
|
|
277
283
|
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
278
284
|
samples from sections that were already active, without disturbing their nesting stack.
|
|
279
|
-
Sections started after the clear are recorded normally
|
|
285
|
+
Sections started after the clear are recorded normally; if their parent was cleared, they
|
|
286
|
+
are reported as top-level sections and count toward the total. Reports include completed calls;
|
|
280
287
|
an active section's current duration is added only when it exits.
|
|
281
288
|
|
|
282
289
|
### Measuring overhead
|
|
@@ -284,7 +291,6 @@ an active section's current duration is added only when it exits.
|
|
|
284
291
|
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
285
292
|
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
286
293
|
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
287
|
-
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
288
294
|
|
|
289
295
|
## API
|
|
290
296
|
|
|
@@ -292,7 +298,7 @@ results using the same interpreter and machine; see [benchmarks/README.md](https
|
|
|
292
298
|
| --- | --- |
|
|
293
299
|
| `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
|
|
294
300
|
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
295
|
-
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
|
|
301
|
+
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty); a no-op if `INFO` is disabled. |
|
|
296
302
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
297
303
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
298
304
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
@@ -103,6 +103,9 @@ Coroutine functions are supported natively — the timing spans the entire `awai
|
|
|
103
103
|
async def fetch(url: str) -> bytes: ...
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
This also holds when another decorator sits between `TimerContext` and the `async def`
|
|
107
|
+
and returns its coroutine: the section covers both the call and the `await`.
|
|
108
|
+
|
|
106
109
|
Generator functions (including `async` generators) cannot be decorated and raise a
|
|
107
110
|
`TypeError`: the decorator would time only the creation of the generator object, not its
|
|
108
111
|
iteration. Time the loop that consumes the generator with a `with` block instead.
|
|
@@ -122,7 +125,9 @@ get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., .
|
|
|
122
125
|
```
|
|
123
126
|
|
|
124
127
|
Flattening removes the final integer suffix (including negative counters). Other
|
|
125
|
-
bracketed names such as `array[index]` are preserved.
|
|
128
|
+
bracketed names such as `array[index]` are preserved. Flattening cannot tell a counter
|
|
129
|
+
from a name you wrote yourself, so sections named `"row[1]"` and `"row[2]"` are also merged
|
|
130
|
+
into `row`; use `flatten=False` to keep them apart. If merged entries have different
|
|
126
131
|
categories, the category from the most recently entered section is used.
|
|
127
132
|
|
|
128
133
|
Each counter value is stored as its own section until `clear_execution_timings()` is
|
|
@@ -229,7 +234,8 @@ time the loop that consumes the generator instead.
|
|
|
229
234
|
If the caller's section exits while a paused generator's section is still open, the
|
|
230
235
|
timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
|
|
231
236
|
discards the generator's unfinished one, so later sections nest correctly. Closing that
|
|
232
|
-
generator afterwards does nothing.
|
|
237
|
+
generator afterwards does nothing. If warnings are configured as errors, the warning is
|
|
238
|
+
raised only after that cleanup, so the timings and nesting stay consistent.
|
|
233
239
|
|
|
234
240
|
### Reusing contexts and clearing timings
|
|
235
241
|
|
|
@@ -245,7 +251,8 @@ for item in items:
|
|
|
245
251
|
|
|
246
252
|
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
247
253
|
samples from sections that were already active, without disturbing their nesting stack.
|
|
248
|
-
Sections started after the clear are recorded normally
|
|
254
|
+
Sections started after the clear are recorded normally; if their parent was cleared, they
|
|
255
|
+
are reported as top-level sections and count toward the total. Reports include completed calls;
|
|
249
256
|
an active section's current duration is added only when it exits.
|
|
250
257
|
|
|
251
258
|
### Measuring overhead
|
|
@@ -253,7 +260,6 @@ an active section's current duration is added only when it exits.
|
|
|
253
260
|
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
254
261
|
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
255
262
|
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
256
|
-
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
257
263
|
|
|
258
264
|
## API
|
|
259
265
|
|
|
@@ -261,7 +267,7 @@ results using the same interpreter and machine; see [benchmarks/README.md](https
|
|
|
261
267
|
| --- | --- |
|
|
262
268
|
| `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
|
|
263
269
|
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
264
|
-
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
|
|
270
|
+
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty); a no-op if `INFO` is disabled. |
|
|
265
271
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
266
272
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
267
273
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
@@ -18,21 +18,21 @@ make instrumentation costs visible; application speedups depend on the work bein
|
|
|
18
18
|
## Local comparison
|
|
19
19
|
|
|
20
20
|
Measured on macOS 26.6.2, ARM64, CPython 3.14.5, using 100,000 operations and nine repeats.
|
|
21
|
-
The baseline is commit `008494c` (version 0.1.0); the updated column
|
|
22
|
-
Both versions ran the same script. Values below are microseconds per
|
|
23
|
-
include the benchmark function call; plain calls cost approximately
|
|
24
|
-
awaits 0.
|
|
21
|
+
The baseline is commit `008494c` (version 0.1.0); the updated column is version 1.0.2.
|
|
22
|
+
Both versions ran the same script, one after the other. Values below are microseconds per
|
|
23
|
+
operation and include the benchmark function call; plain calls cost approximately
|
|
24
|
+
0.02 µs and plain awaits 0.06 µs in both runs.
|
|
25
25
|
|
|
26
26
|
| Operation | Baseline (µs) | Updated (µs) | Reduction |
|
|
27
27
|
| --- | ---: | ---: | ---: |
|
|
28
|
-
| New context | 1.
|
|
29
|
-
| Reused context | 1.
|
|
30
|
-
| Decorated call | 1.
|
|
31
|
-
| Decorated await | 1.
|
|
32
|
-
| Five nested contexts | 7.
|
|
33
|
-
| Total time, 1,000 sections |
|
|
34
|
-
| JSON, 1,000 sections | 1,
|
|
35
|
-
| Disabled logging, 1,000 sections |
|
|
28
|
+
| New context | 1.558 | 1.101 | 29% |
|
|
29
|
+
| Reused context | 1.390 | 1.039 | 25% |
|
|
30
|
+
| Decorated call | 1.623 | 1.129 | 30% |
|
|
31
|
+
| Decorated await | 1.711 | 1.133 | 34% |
|
|
32
|
+
| Five nested contexts | 7.961 | 5.380 | 32% |
|
|
33
|
+
| Total time, 1,000 sections | 519.131 | 43.188 | 92% |
|
|
34
|
+
| JSON, 1,000 sections | 1,235.188 | 1,064.559 | 14% |
|
|
35
|
+
| Disabled logging, 1,000 sections | 572.003 | 0.098 | >99.9% |
|
|
36
36
|
|
|
37
37
|
Recording keeps each invocation's start time and cached path in a context-local frame.
|
|
38
38
|
Decorators reuse their context instead of constructing one per call. Total-time queries
|
|
@@ -44,7 +44,8 @@ Issues = "https://github.com/seba2390/ExecutionTimer/issues"
|
|
|
44
44
|
Changelog = "https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md"
|
|
45
45
|
|
|
46
46
|
[build-system]
|
|
47
|
-
|
|
47
|
+
# 1.27 is the first release that supports PEP 639 license metadata (license-files).
|
|
48
|
+
requires = ["hatchling>=1.27"]
|
|
48
49
|
build-backend = "hatchling.build"
|
|
49
50
|
|
|
50
51
|
[tool.hatch.version]
|
|
@@ -76,6 +77,9 @@ dev = [
|
|
|
76
77
|
[tool.pytest.ini_options]
|
|
77
78
|
testpaths = ["tests"]
|
|
78
79
|
addopts = "--strict-markers --strict-config"
|
|
80
|
+
# Any warning fails the suite, so code paths that warn are tested as they behave under
|
|
81
|
+
# warnings-as-errors, a common configuration in projects that use this package.
|
|
82
|
+
filterwarnings = ["error"]
|
|
79
83
|
|
|
80
84
|
[tool.coverage.run]
|
|
81
85
|
source = ["src/execution_timer"]
|
|
@@ -83,7 +87,7 @@ branch = true
|
|
|
83
87
|
|
|
84
88
|
[tool.coverage.report]
|
|
85
89
|
show_missing = true
|
|
86
|
-
fail_under =
|
|
90
|
+
fail_under = 100
|
|
87
91
|
exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]
|
|
88
92
|
|
|
89
93
|
[tool.ruff]
|
|
@@ -74,11 +74,13 @@ class _Frame(NamedTuple):
|
|
|
74
74
|
_ACTIVE_CONTEXT: ContextVar[_Frame | None] = ContextVar("execution_timer_context", default=None)
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ...]]:
|
|
77
|
+
def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[tuple[str, ...], int]]:
|
|
78
78
|
"""Order section paths depth-first so children always follow their parent.
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
Returns each path with its depth in the recorded tree, which is shallower than the path
|
|
81
|
+
length when an ancestor is missing. Insertion order is preserved within each level, so a
|
|
82
|
+
parent revisited after an unrelated sibling still renders with its own children rather
|
|
83
|
+
than beneath the sibling.
|
|
82
84
|
"""
|
|
83
85
|
keys = list(keys)
|
|
84
86
|
known = set(keys)
|
|
@@ -92,16 +94,28 @@ def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ..
|
|
|
92
94
|
else:
|
|
93
95
|
roots.append(key)
|
|
94
96
|
|
|
95
|
-
ordered: list[tuple[str, ...]] = []
|
|
97
|
+
ordered: list[tuple[tuple[str, ...], int]] = []
|
|
96
98
|
# Explicit stack rather than recursion: nesting depth is user-controlled.
|
|
97
|
-
stack =
|
|
99
|
+
stack = [(key, 0) for key in reversed(roots)]
|
|
98
100
|
while stack:
|
|
99
|
-
key = stack.pop()
|
|
100
|
-
ordered.append(key)
|
|
101
|
-
stack.extend(reversed(children.get(key, [])))
|
|
101
|
+
key, depth = stack.pop()
|
|
102
|
+
ordered.append((key, depth))
|
|
103
|
+
stack.extend((child, depth + 1) for child in reversed(children.get(key, [])))
|
|
102
104
|
return ordered
|
|
103
105
|
|
|
104
106
|
|
|
107
|
+
def _top_level_time(timings: dict[tuple[str, ...], _TimesDict]) -> float:
|
|
108
|
+
"""Sum the sections with no recorded parent, matching the roots of ``_ordered_by_hierarchy``.
|
|
109
|
+
|
|
110
|
+
A parent goes missing when timings are cleared while it is active; its children that
|
|
111
|
+
finish afterwards are then top-level and must count toward the total.
|
|
112
|
+
"""
|
|
113
|
+
return sum(
|
|
114
|
+
(info["elapsed_time"] for key, info in timings.items() if len(key) == 1 or key[:-1] not in timings),
|
|
115
|
+
0.0,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
105
119
|
class _ExecutionTimer:
|
|
106
120
|
"""Registry of named, nestable timing sections, shared through ``_TIMER``."""
|
|
107
121
|
|
|
@@ -137,9 +151,10 @@ class _ExecutionTimer:
|
|
|
137
151
|
def stop_timer(self, name: str) -> None:
|
|
138
152
|
"""Stop timing a section and accumulate its elapsed time.
|
|
139
153
|
|
|
140
|
-
Never raises: an exception here would replace
|
|
141
|
-
block. Exiting past still-active inner sections
|
|
142
|
-
holds one open) discards them with a warning,
|
|
154
|
+
Never raises, unless warnings are configured as errors: an exception here would replace
|
|
155
|
+
one already propagating from the timed block. Exiting past still-active inner sections
|
|
156
|
+
(typically a suspended generator that holds one open) discards them with a warning,
|
|
157
|
+
after recording the exit, so the stack cannot stay corrupted.
|
|
143
158
|
Exiting a section that is no longer active, such as one discarded that way when its
|
|
144
159
|
generator is finally closed, does nothing.
|
|
145
160
|
"""
|
|
@@ -150,17 +165,18 @@ class _ExecutionTimer:
|
|
|
150
165
|
frame = frame.parent
|
|
151
166
|
if frame is None:
|
|
152
167
|
return
|
|
168
|
+
with self._lock:
|
|
169
|
+
# A clear detaches this entry from the registry. Updating the detached object
|
|
170
|
+
# cannot resurrect an old sample or add it to a replacement at the same path.
|
|
171
|
+
frame.entry["elapsed_time"] += end_time - frame.start_time
|
|
172
|
+
_ = _ACTIVE_CONTEXT.set(frame.parent)
|
|
153
173
|
if frame is not active and active is not None:
|
|
174
|
+
# Warn only once the state is consistent: warnings configured as errors raise here.
|
|
154
175
|
msg = (
|
|
155
176
|
f"Section '{name}' exited while '{active.path[-1]}' was still active; discarding the "
|
|
156
177
|
"unfinished inner sections. Close sections before a generator yields."
|
|
157
178
|
)
|
|
158
179
|
warnings.warn(msg, RuntimeWarning, stacklevel=3)
|
|
159
|
-
with self._lock:
|
|
160
|
-
# A clear detaches this entry from the registry. Updating the detached object
|
|
161
|
-
# cannot resurrect an old sample or add it to a replacement at the same path.
|
|
162
|
-
frame.entry["elapsed_time"] += end_time - frame.start_time
|
|
163
|
-
_ = _ACTIVE_CONTEXT.set(frame.parent)
|
|
164
180
|
|
|
165
181
|
def _resolve(self, *, flatten: bool) -> dict[tuple[str, ...], _TimesDict]:
|
|
166
182
|
snapshot = self.snapshot()
|
|
@@ -168,23 +184,25 @@ class _ExecutionTimer:
|
|
|
168
184
|
|
|
169
185
|
def report_timings(self, *, flatten: bool = True) -> str:
|
|
170
186
|
"""Build a report of all sections with duration and percentage of total time."""
|
|
171
|
-
|
|
172
|
-
if not
|
|
187
|
+
snapshot = self.snapshot()
|
|
188
|
+
if not snapshot:
|
|
173
189
|
return ""
|
|
174
190
|
|
|
175
|
-
|
|
191
|
+
# Total the unflattened paths, like get_total_time and the JSON export.
|
|
192
|
+
total_time = _top_level_time(snapshot)
|
|
193
|
+
timings = _flatten(snapshot) if flatten else snapshot
|
|
176
194
|
report = [f"Total time: {total_time:.4f} s.\n"]
|
|
177
|
-
for key in _ordered_by_hierarchy(timings):
|
|
195
|
+
for key, depth in _ordered_by_hierarchy(timings):
|
|
178
196
|
elapsed_time = timings[key]["elapsed_time"]
|
|
179
197
|
percentage = (elapsed_time / total_time) * 100 if total_time else 0.0
|
|
180
|
-
report.append(f"{'.. ' *
|
|
198
|
+
report.append(f"{'.. ' * depth}{key[-1]}: {elapsed_time:.4f} s ({percentage:.2f}%)")
|
|
181
199
|
return "\n".join(report)
|
|
182
200
|
|
|
183
201
|
def compute_total_time(self) -> float:
|
|
184
202
|
"""Compute total elapsed time across all top-level sections."""
|
|
185
203
|
# Counter merging cannot change the sum, so there is no snapshot to copy or flatten.
|
|
186
204
|
with self._lock:
|
|
187
|
-
return
|
|
205
|
+
return _top_level_time(self.timings)
|
|
188
206
|
|
|
189
207
|
def compute_total_category_time(self, category: str) -> float:
|
|
190
208
|
"""Compute total elapsed time in a category, counting only top-most entries of that category."""
|
|
@@ -260,8 +278,9 @@ class TimerContext:
|
|
|
260
278
|
"""Decorate a function to time its execution under this context.
|
|
261
279
|
|
|
262
280
|
Coroutine functions are wrapped so the timing spans the entire ``await``, not just
|
|
263
|
-
creation of the coroutine object.
|
|
264
|
-
|
|
281
|
+
creation of the coroutine object. So is a coroutine returned by a plain function,
|
|
282
|
+
typically another decorator stacked on an ``async def``. Generator functions are
|
|
283
|
+
rejected: a wrapper would time only creation of the generator object, not its iteration.
|
|
265
284
|
"""
|
|
266
285
|
if inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func):
|
|
267
286
|
msg = (
|
|
@@ -279,10 +298,20 @@ class TimerContext:
|
|
|
279
298
|
@functools.wraps(func)
|
|
280
299
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
281
300
|
with self:
|
|
282
|
-
|
|
301
|
+
result = func(*args, **kwargs)
|
|
302
|
+
if inspect.iscoroutine(result):
|
|
303
|
+
# A decorator between this one and an ``async def`` hides the coroutine function,
|
|
304
|
+
# so the call above only created the coroutine. Time awaiting it as well.
|
|
305
|
+
return cast("R", self._time_await(result))
|
|
306
|
+
return result
|
|
283
307
|
|
|
284
308
|
return wrapper
|
|
285
309
|
|
|
310
|
+
async def _time_await(self, coroutine: Coroutine[object, object, T]) -> T:
|
|
311
|
+
"""Await a coroutine that was created outside this context, timing the whole await."""
|
|
312
|
+
with self:
|
|
313
|
+
return await coroutine
|
|
314
|
+
|
|
286
315
|
def _wrap_async(self, func: Callable[P, Coroutine[object, object, T]]) -> Callable[P, Coroutine[object, object, T]]:
|
|
287
316
|
"""Wrap a coroutine function so the timing spans the whole await."""
|
|
288
317
|
|
|
@@ -344,7 +373,7 @@ def _build_payload(*, flatten: bool = True) -> TimingsPayload:
|
|
|
344
373
|
"time": round(timings[key]["elapsed_time"], 6),
|
|
345
374
|
"category": timings[key]["category"],
|
|
346
375
|
}
|
|
347
|
-
for key in _ordered_by_hierarchy(timings)
|
|
376
|
+
for key, _ in _ordered_by_hierarchy(timings)
|
|
348
377
|
]
|
|
349
378
|
category_totals: dict[str, float] = {}
|
|
350
379
|
for key, info in snapshot.items():
|
|
@@ -352,7 +381,7 @@ def _build_payload(*, flatten: bool = True) -> TimingsPayload:
|
|
|
352
381
|
if not _has_ancestor_with_category(snapshot, key, category):
|
|
353
382
|
category_totals[category] = category_totals.get(category, 0.0) + info["elapsed_time"]
|
|
354
383
|
return {
|
|
355
|
-
"total_time": round(
|
|
384
|
+
"total_time": round(_top_level_time(snapshot), 6),
|
|
356
385
|
"total_category_time": {cat: round(category_totals[cat], 6) for cat in sorted(category_totals)},
|
|
357
386
|
"sections": sections,
|
|
358
387
|
}
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import builtins
|
|
5
|
+
import functools
|
|
5
6
|
import inspect
|
|
6
7
|
import json
|
|
7
8
|
import logging
|
|
8
9
|
import threading
|
|
9
10
|
import time
|
|
10
|
-
|
|
11
|
+
import warnings
|
|
12
|
+
from collections.abc import AsyncIterator, Callable, Generator, Iterator
|
|
11
13
|
from concurrent.futures import ThreadPoolExecutor
|
|
12
14
|
from contextlib import ExitStack
|
|
13
15
|
from pathlib import Path
|
|
14
|
-
from typing import cast
|
|
16
|
+
from typing import ParamSpec, TypeVar, cast
|
|
15
17
|
from unittest.mock import patch
|
|
16
18
|
|
|
17
19
|
import pytest
|
|
@@ -33,6 +35,9 @@ from execution_timer import (
|
|
|
33
35
|
save_execution_timings_json,
|
|
34
36
|
)
|
|
35
37
|
|
|
38
|
+
P = ParamSpec("P")
|
|
39
|
+
R = TypeVar("R")
|
|
40
|
+
|
|
36
41
|
|
|
37
42
|
@pytest.fixture(autouse=True)
|
|
38
43
|
def reset_timer() -> Iterator[None]:
|
|
@@ -641,6 +646,89 @@ class TestAsyncDecorator:
|
|
|
641
646
|
assert ("child",) not in timings
|
|
642
647
|
|
|
643
648
|
|
|
649
|
+
def passthrough(func: Callable[P, R]) -> Callable[P, R]:
|
|
650
|
+
"""A plain decorator that hides whether ``func`` is a coroutine function."""
|
|
651
|
+
|
|
652
|
+
@functools.wraps(func)
|
|
653
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
654
|
+
return func(*args, **kwargs)
|
|
655
|
+
|
|
656
|
+
return wrapper
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class TestStackedAsyncDecorator:
|
|
660
|
+
"""A plain decorator between ``TimerContext`` and an ``async def`` returns its coroutine."""
|
|
661
|
+
|
|
662
|
+
def test_times_the_await_not_just_the_call(self) -> None:
|
|
663
|
+
@TimerContext("stacked")
|
|
664
|
+
@passthrough
|
|
665
|
+
async def work() -> None:
|
|
666
|
+
await asyncio.sleep(0)
|
|
667
|
+
|
|
668
|
+
# The call that creates the coroutine takes 1 s, and awaiting it takes 4 s.
|
|
669
|
+
with patch.object(time, "perf_counter", side_effect=[0, 1, 1, 5]):
|
|
670
|
+
asyncio.run(work())
|
|
671
|
+
|
|
672
|
+
assert raw_timings() == {("stacked",): {"time": 5, "category": DEFAULT_CATEGORY}}
|
|
673
|
+
|
|
674
|
+
def test_real_await_is_covered(self) -> None:
|
|
675
|
+
@TimerContext("stacked")
|
|
676
|
+
@passthrough
|
|
677
|
+
async def work() -> None:
|
|
678
|
+
await asyncio.sleep(0.05)
|
|
679
|
+
|
|
680
|
+
asyncio.run(work())
|
|
681
|
+
|
|
682
|
+
# Same threshold rationale as the plain async decorator test above.
|
|
683
|
+
assert raw_timings()["stacked",]["time"] >= 0.01
|
|
684
|
+
|
|
685
|
+
def test_sections_inside_the_coroutine_nest_under_it(self) -> None:
|
|
686
|
+
@TimerContext("stacked")
|
|
687
|
+
@passthrough
|
|
688
|
+
async def work() -> None:
|
|
689
|
+
with TimerContext("inner"):
|
|
690
|
+
await asyncio.sleep(0)
|
|
691
|
+
|
|
692
|
+
async def main() -> None:
|
|
693
|
+
with TimerContext("outer"):
|
|
694
|
+
await work()
|
|
695
|
+
|
|
696
|
+
asyncio.run(main())
|
|
697
|
+
|
|
698
|
+
assert set(raw_timings()) == {("outer",), ("outer", "stacked"), ("outer", "stacked", "inner")}
|
|
699
|
+
|
|
700
|
+
def test_preserves_return_value_and_exceptions(self) -> None:
|
|
701
|
+
@TimerContext("compute")
|
|
702
|
+
@passthrough
|
|
703
|
+
async def compute() -> int:
|
|
704
|
+
await asyncio.sleep(0)
|
|
705
|
+
return 42
|
|
706
|
+
|
|
707
|
+
@TimerContext("failing")
|
|
708
|
+
@passthrough
|
|
709
|
+
async def failing() -> None:
|
|
710
|
+
await asyncio.sleep(0)
|
|
711
|
+
raise ValueError("bad")
|
|
712
|
+
|
|
713
|
+
assert asyncio.run(compute()) == 42
|
|
714
|
+
with pytest.raises(ValueError, match="bad"):
|
|
715
|
+
asyncio.run(failing())
|
|
716
|
+
assert set(raw_timings()) == {("compute",), ("failing",)}
|
|
717
|
+
|
|
718
|
+
def test_other_awaitables_are_returned_unchanged(self) -> None:
|
|
719
|
+
loop = asyncio.new_event_loop()
|
|
720
|
+
try:
|
|
721
|
+
future: asyncio.Future[int] = loop.create_future()
|
|
722
|
+
|
|
723
|
+
@TimerContext("returns_future")
|
|
724
|
+
def get_future() -> asyncio.Future[int]:
|
|
725
|
+
return future
|
|
726
|
+
|
|
727
|
+
assert get_future() is future
|
|
728
|
+
finally:
|
|
729
|
+
loop.close()
|
|
730
|
+
|
|
731
|
+
|
|
644
732
|
class TestContextManagerProtocol:
|
|
645
733
|
def test_enter_returns_the_context(self) -> None:
|
|
646
734
|
with TimerContext("named", category="gpu", counter=2) as ctx:
|
|
@@ -935,6 +1023,23 @@ class TestLifecycle:
|
|
|
935
1023
|
pass
|
|
936
1024
|
assert list(raw_timings()) == [("caller",), ("caller", "generator"), ("after",)]
|
|
937
1025
|
|
|
1026
|
+
def test_warnings_as_errors_still_record_and_unwind_before_raising(self) -> None:
|
|
1027
|
+
def numbers() -> Generator[int]:
|
|
1028
|
+
with TimerContext("generator"):
|
|
1029
|
+
yield 1
|
|
1030
|
+
|
|
1031
|
+
paused = numbers()
|
|
1032
|
+
with patch.object(time, "perf_counter", side_effect=[0, 1, 3]), warnings.catch_warnings():
|
|
1033
|
+
warnings.simplefilter("error", RuntimeWarning)
|
|
1034
|
+
with pytest.raises(RuntimeWarning), TimerContext("caller"):
|
|
1035
|
+
_ = next(paused)
|
|
1036
|
+
raise KeyError("from the body")
|
|
1037
|
+
paused.close()
|
|
1038
|
+
with TimerContext("after"):
|
|
1039
|
+
pass
|
|
1040
|
+
assert raw_timings()["caller",]["time"] == 3.0
|
|
1041
|
+
assert list(raw_timings()) == [("caller",), ("caller", "generator"), ("after",)]
|
|
1042
|
+
|
|
938
1043
|
def test_exiting_a_section_that_is_not_active_leaves_the_stack_intact(self) -> None:
|
|
939
1044
|
with TimerContext("outer"):
|
|
940
1045
|
TimerContext("stranger").__exit__(None, None, None)
|
|
@@ -952,12 +1057,31 @@ class TestLifecycle:
|
|
|
952
1057
|
with TimerContext("child", category="cpu"):
|
|
953
1058
|
pass
|
|
954
1059
|
assert list(raw_timings()) == [("parent", "child")]
|
|
955
|
-
|
|
1060
|
+
# The cleared parent is gone, so its child is top-level: it counts toward the total
|
|
1061
|
+
# and is not indented beneath an unrelated section.
|
|
1062
|
+
assert get_execution_times_report() == "Total time: 2.0000 s.\n\nchild: 2.0000 s (100.00%)"
|
|
1063
|
+
assert get_total_time() == 2.0
|
|
956
1064
|
payload = cast(TimingsPayload, json.loads(get_execution_times_json()))
|
|
957
|
-
assert payload["total_time"] ==
|
|
1065
|
+
assert payload["total_time"] == 2.0
|
|
958
1066
|
assert payload["total_category_time"] == {"cpu": 2.0}
|
|
959
1067
|
assert payload["sections"][0]["path"] == ["parent", "child"]
|
|
960
1068
|
|
|
1069
|
+
def test_periodic_clear_inside_an_outer_section_reports_each_interval(self) -> None:
|
|
1070
|
+
clock = [0, 1, 2, 3, 4, 10, 11, 12, 14, 20, 30, 31]
|
|
1071
|
+
with patch.object(time, "perf_counter", side_effect=clock):
|
|
1072
|
+
with TimerContext("main"):
|
|
1073
|
+
for batch in range(2):
|
|
1074
|
+
with TimerContext("load"), TimerContext("parse"):
|
|
1075
|
+
pass
|
|
1076
|
+
if batch == 0:
|
|
1077
|
+
clear_execution_timings()
|
|
1078
|
+
with TimerContext("after"):
|
|
1079
|
+
pass
|
|
1080
|
+
assert get_execution_times_report() == (
|
|
1081
|
+
"Total time: 5.0000 s.\n\nload: 4.0000 s (80.00%)\n.. parse: 1.0000 s (20.00%)\nafter: 1.0000 s (20.00%)"
|
|
1082
|
+
)
|
|
1083
|
+
assert get_total_time() == 5.0
|
|
1084
|
+
|
|
961
1085
|
def test_deep_reports_do_not_depend_on_python_recursion_limit(self) -> None:
|
|
962
1086
|
with ExitStack() as stack:
|
|
963
1087
|
for _ in range(1_100):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|