executiontimer 0.2.0__tar.gz → 1.0.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.2.0 → executiontimer-1.0.1}/CHANGELOG.md +67 -1
- {executiontimer-0.2.0 → executiontimer-1.0.1}/PKG-INFO +23 -11
- {executiontimer-0.2.0 → executiontimer-1.0.1}/README.md +21 -9
- {executiontimer-0.2.0 → executiontimer-1.0.1}/pyproject.toml +3 -2
- {executiontimer-0.2.0 → executiontimer-1.0.1}/src/execution_timer/__init__.py +1 -1
- {executiontimer-0.2.0 → executiontimer-1.0.1}/src/execution_timer/_timer.py +68 -39
- {executiontimer-0.2.0 → executiontimer-1.0.1}/tests/execution_timer_test.py +86 -23
- {executiontimer-0.2.0 → executiontimer-1.0.1}/.gitignore +0 -0
- {executiontimer-0.2.0 → executiontimer-1.0.1}/CONTRIBUTING.md +0 -0
- {executiontimer-0.2.0 → executiontimer-1.0.1}/LICENSE +0 -0
- {executiontimer-0.2.0 → executiontimer-1.0.1}/benchmarks/README.md +0 -0
- {executiontimer-0.2.0 → executiontimer-1.0.1}/benchmarks/overhead.py +0 -0
- {executiontimer-0.2.0 → executiontimer-1.0.1}/src/execution_timer/py.typed +0 -0
|
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.1] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Exiting a section while an inner one is still active no longer leaves the active stack
|
|
15
|
+
corrupted when warnings are configured as errors (for example pytest's
|
|
16
|
+
`filterwarnings = ["error"]`). The `RuntimeWarning` was emitted before the exit was
|
|
17
|
+
recorded, so raising it skipped the cleanup that 1.0.0 introduced. The section is now
|
|
18
|
+
recorded and the stack unwound before the warning is emitted.
|
|
19
|
+
- Sections whose parent was cleared while active now count as top-level. Previously,
|
|
20
|
+
clearing timings inside an outer section, such as a periodic clear in a long-running
|
|
21
|
+
loop, made `get_total_time()`, the report and the JSON `total_time` read `0`, every
|
|
22
|
+
percentage `0.00%`, and indented the sections beneath an unrelated one in the report.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- The build requires `hatchling>=1.27`, the first release that supports the PEP 639
|
|
27
|
+
`license-files` metadata the project declares.
|
|
28
|
+
- Dependabot groups its monthly updates into one pull request per ecosystem.
|
|
29
|
+
|
|
30
|
+
## [1.0.0] - 2026-09-30
|
|
31
|
+
|
|
32
|
+
First stable release. The public API is now covered by semantic versioning: breaking
|
|
33
|
+
changes will wait for 2.0. Three changes below are breaking; they clean up the API before
|
|
34
|
+
it is frozen.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **Breaking:** the report from `get_execution_times_report()` no longer starts with a
|
|
39
|
+
blank line, and its header reads `Total time:` rather than `Total calculation time:`.
|
|
40
|
+
`log_execution_times()` still starts the report on its own line.
|
|
41
|
+
- **Breaking:** `TimerContext.timer` is now private (`_timer`). It exposed the internal
|
|
42
|
+
registry, which is not part of the public API.
|
|
43
|
+
- `get_execution_times_report()` no longer logs a warning when there are no timings; it
|
|
44
|
+
returns `""`. `log_execution_times()` warns instead, on the logger it was given, so
|
|
45
|
+
building an empty report no longer prints to stderr when logging is not configured.
|
|
46
|
+
- The package is classified as `Development Status :: 5 - Production/Stable`.
|
|
47
|
+
|
|
48
|
+
### Removed
|
|
49
|
+
|
|
50
|
+
- **Breaking:** the `flatten` parameter of `get_total_time()`, which had no effect.
|
|
51
|
+
|
|
52
|
+
### Documentation
|
|
53
|
+
|
|
54
|
+
- README links to the changelog, contributing guide, benchmarks and license are absolute,
|
|
55
|
+
so they work on PyPI.
|
|
56
|
+
- Note that each `counter=` value is kept as a separate section until the timings are
|
|
57
|
+
cleared.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- Exiting a section while an inner one is still active, typically because a paused
|
|
62
|
+
generator holds a section open, no longer raises `RuntimeError`. The raise replaced any
|
|
63
|
+
exception already propagating from the timed block and left the active stack corrupted
|
|
64
|
+
for the rest of the thread. The timer now emits a `RuntimeWarning`, records the exited
|
|
65
|
+
section, and discards the unfinished inner sections. Exiting a section that is no longer
|
|
66
|
+
active does nothing, so closing the paused generator later no longer raises either.
|
|
67
|
+
|
|
68
|
+
### Security
|
|
69
|
+
|
|
70
|
+
- Workflows pin every action to a full commit SHA, with the release as a comment that
|
|
71
|
+
Dependabot keeps current, and CI installs dependencies with `uv sync --locked` so a
|
|
72
|
+
stale lockfile fails the build.
|
|
73
|
+
|
|
10
74
|
## [0.2.0] - 2026-09-30
|
|
11
75
|
|
|
12
76
|
### Changed
|
|
@@ -85,7 +149,9 @@ First public release on PyPI.
|
|
|
85
149
|
`py.typed` marker so type checkers use the inline annotations.
|
|
86
150
|
- `__version__` attribute on the package.
|
|
87
151
|
|
|
88
|
-
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/
|
|
152
|
+
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.1...HEAD
|
|
153
|
+
[1.0.1]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.0...v1.0.1
|
|
154
|
+
[1.0.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.2.0...v1.0.0
|
|
89
155
|
[0.2.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...v0.2.0
|
|
90
156
|
[0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
|
|
91
157
|
[0.1.0]: https://github.com/seba2390/ExecutionTimer/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: executiontimer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.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
|
|
@@ -11,7 +11,7 @@ Author: Sebastian Yde Madsen
|
|
|
11
11
|
License-Expression: MIT
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Keywords: benchmark,context-manager,decorator,execution-time,instrumentation,performance,profiling,timing
|
|
14
|
-
Classifier: Development Status ::
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
16
16
|
Classifier: Intended Audience :: Science/Research
|
|
17
17
|
Classifier: Operating System :: OS Independent
|
|
@@ -103,7 +103,7 @@ print(get_execution_times_report())
|
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
```text
|
|
106
|
-
Total
|
|
106
|
+
Total time: 0.3156 s.
|
|
107
107
|
|
|
108
108
|
load_data: 0.1219 s (38.62%)
|
|
109
109
|
solve: 0.1937 s (61.38%)
|
|
@@ -156,6 +156,11 @@ Flattening removes the final integer suffix (including negative counters). Other
|
|
|
156
156
|
bracketed names such as `array[index]` are preserved. If merged entries have different
|
|
157
157
|
categories, the category from the most recently entered section is used.
|
|
158
158
|
|
|
159
|
+
Each counter value is stored as its own section until `clear_execution_timings()` is
|
|
160
|
+
called, so a long-running process that times an unbounded loop with `counter=` keeps
|
|
161
|
+
growing the registry. Clear it periodically, or drop `counter=` to accumulate into one
|
|
162
|
+
section.
|
|
163
|
+
|
|
159
164
|
### Categories
|
|
160
165
|
|
|
161
166
|
Categories are plain strings — use whatever fits your domain:
|
|
@@ -252,6 +257,12 @@ across a `yield` therefore also contains whatever the caller times while the gen
|
|
|
252
257
|
paused, and its duration includes that paused time. Close sections before yielding, or
|
|
253
258
|
time the loop that consumes the generator instead.
|
|
254
259
|
|
|
260
|
+
If the caller's section exits while a paused generator's section is still open, the
|
|
261
|
+
timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
|
|
262
|
+
discards the generator's unfinished one, so later sections nest correctly. Closing that
|
|
263
|
+
generator afterwards does nothing. If warnings are configured as errors, the warning is
|
|
264
|
+
raised only after that cleanup, so the timings and nesting stay consistent.
|
|
265
|
+
|
|
255
266
|
### Reusing contexts and clearing timings
|
|
256
267
|
|
|
257
268
|
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
@@ -266,14 +277,15 @@ for item in items:
|
|
|
266
277
|
|
|
267
278
|
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
268
279
|
samples from sections that were already active, without disturbing their nesting stack.
|
|
269
|
-
Sections started after the clear are recorded normally
|
|
280
|
+
Sections started after the clear are recorded normally; if their parent was cleared, they
|
|
281
|
+
are reported as top-level sections and count toward the total. Reports include completed calls;
|
|
270
282
|
an active section's current duration is added only when it exits.
|
|
271
283
|
|
|
272
284
|
### Measuring overhead
|
|
273
285
|
|
|
274
286
|
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
275
287
|
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
276
|
-
results using the same interpreter and machine; see [benchmarks/README.md](benchmarks/README.md).
|
|
288
|
+
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
277
289
|
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
278
290
|
|
|
279
291
|
## API
|
|
@@ -281,12 +293,12 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
|
|
|
281
293
|
| Function | Description |
|
|
282
294
|
| --- | --- |
|
|
283
295
|
| `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
|
|
284
|
-
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections. |
|
|
285
|
-
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level. |
|
|
296
|
+
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
297
|
+
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
|
|
286
298
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
287
299
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
288
300
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
289
|
-
| `get_total_time(
|
|
301
|
+
| `get_total_time()` | Total seconds across all top-level sections. |
|
|
290
302
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
291
303
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
292
304
|
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
@@ -309,9 +321,9 @@ uv run ruff check --fix && uv run ruff format
|
|
|
309
321
|
uv run basedpyright
|
|
310
322
|
```
|
|
311
323
|
|
|
312
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, and
|
|
313
|
-
[CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
324
|
+
See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
|
|
325
|
+
[CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
|
|
314
326
|
|
|
315
327
|
## License
|
|
316
328
|
|
|
317
|
-
MIT — see [LICENSE](LICENSE).
|
|
329
|
+
MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
|
|
@@ -72,7 +72,7 @@ print(get_execution_times_report())
|
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
```text
|
|
75
|
-
Total
|
|
75
|
+
Total time: 0.3156 s.
|
|
76
76
|
|
|
77
77
|
load_data: 0.1219 s (38.62%)
|
|
78
78
|
solve: 0.1937 s (61.38%)
|
|
@@ -125,6 +125,11 @@ Flattening removes the final integer suffix (including negative counters). Other
|
|
|
125
125
|
bracketed names such as `array[index]` are preserved. If merged entries have different
|
|
126
126
|
categories, the category from the most recently entered section is used.
|
|
127
127
|
|
|
128
|
+
Each counter value is stored as its own section until `clear_execution_timings()` is
|
|
129
|
+
called, so a long-running process that times an unbounded loop with `counter=` keeps
|
|
130
|
+
growing the registry. Clear it periodically, or drop `counter=` to accumulate into one
|
|
131
|
+
section.
|
|
132
|
+
|
|
128
133
|
### Categories
|
|
129
134
|
|
|
130
135
|
Categories are plain strings — use whatever fits your domain:
|
|
@@ -221,6 +226,12 @@ across a `yield` therefore also contains whatever the caller times while the gen
|
|
|
221
226
|
paused, and its duration includes that paused time. Close sections before yielding, or
|
|
222
227
|
time the loop that consumes the generator instead.
|
|
223
228
|
|
|
229
|
+
If the caller's section exits while a paused generator's section is still open, the
|
|
230
|
+
timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
|
|
231
|
+
discards the generator's unfinished one, so later sections nest correctly. Closing that
|
|
232
|
+
generator afterwards does nothing. If warnings are configured as errors, the warning is
|
|
233
|
+
raised only after that cleanup, so the timings and nesting stay consistent.
|
|
234
|
+
|
|
224
235
|
### Reusing contexts and clearing timings
|
|
225
236
|
|
|
226
237
|
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
@@ -235,14 +246,15 @@ for item in items:
|
|
|
235
246
|
|
|
236
247
|
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
237
248
|
samples from sections that were already active, without disturbing their nesting stack.
|
|
238
|
-
Sections started after the clear are recorded normally
|
|
249
|
+
Sections started after the clear are recorded normally; if their parent was cleared, they
|
|
250
|
+
are reported as top-level sections and count toward the total. Reports include completed calls;
|
|
239
251
|
an active section's current duration is added only when it exits.
|
|
240
252
|
|
|
241
253
|
### Measuring overhead
|
|
242
254
|
|
|
243
255
|
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
244
256
|
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
245
|
-
results using the same interpreter and machine; see [benchmarks/README.md](benchmarks/README.md).
|
|
257
|
+
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
246
258
|
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
247
259
|
|
|
248
260
|
## API
|
|
@@ -250,12 +262,12 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
|
|
|
250
262
|
| Function | Description |
|
|
251
263
|
| --- | --- |
|
|
252
264
|
| `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
|
|
253
|
-
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections. |
|
|
254
|
-
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level. |
|
|
265
|
+
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
266
|
+
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
|
|
255
267
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
256
268
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
257
269
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
258
|
-
| `get_total_time(
|
|
270
|
+
| `get_total_time()` | Total seconds across all top-level sections. |
|
|
259
271
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
260
272
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
261
273
|
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
@@ -278,9 +290,9 @@ uv run ruff check --fix && uv run ruff format
|
|
|
278
290
|
uv run basedpyright
|
|
279
291
|
```
|
|
280
292
|
|
|
281
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, and
|
|
282
|
-
[CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
293
|
+
See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
|
|
294
|
+
[CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
|
|
283
295
|
|
|
284
296
|
## License
|
|
285
297
|
|
|
286
|
-
MIT — see [LICENSE](LICENSE).
|
|
298
|
+
MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
|
|
@@ -18,7 +18,7 @@ keywords = [
|
|
|
18
18
|
"decorator",
|
|
19
19
|
]
|
|
20
20
|
classifiers = [
|
|
21
|
-
"Development Status ::
|
|
21
|
+
"Development Status :: 5 - Production/Stable",
|
|
22
22
|
"Intended Audience :: Developers",
|
|
23
23
|
"Intended Audience :: Science/Research",
|
|
24
24
|
"Operating System :: OS Independent",
|
|
@@ -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]
|
|
@@ -14,6 +14,7 @@ import json
|
|
|
14
14
|
import logging
|
|
15
15
|
import threading
|
|
16
16
|
import time
|
|
17
|
+
import warnings
|
|
17
18
|
from collections.abc import Callable, Coroutine, Iterable, Iterator
|
|
18
19
|
from contextvars import ContextVar
|
|
19
20
|
from itertools import count
|
|
@@ -73,11 +74,13 @@ class _Frame(NamedTuple):
|
|
|
73
74
|
_ACTIVE_CONTEXT: ContextVar[_Frame | None] = ContextVar("execution_timer_context", default=None)
|
|
74
75
|
|
|
75
76
|
|
|
76
|
-
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]]:
|
|
77
78
|
"""Order section paths depth-first so children always follow their parent.
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
81
84
|
"""
|
|
82
85
|
keys = list(keys)
|
|
83
86
|
known = set(keys)
|
|
@@ -91,16 +94,28 @@ def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ..
|
|
|
91
94
|
else:
|
|
92
95
|
roots.append(key)
|
|
93
96
|
|
|
94
|
-
ordered: list[tuple[str, ...]] = []
|
|
97
|
+
ordered: list[tuple[tuple[str, ...], int]] = []
|
|
95
98
|
# Explicit stack rather than recursion: nesting depth is user-controlled.
|
|
96
|
-
stack =
|
|
99
|
+
stack = [(key, 0) for key in reversed(roots)]
|
|
97
100
|
while stack:
|
|
98
|
-
key = stack.pop()
|
|
99
|
-
ordered.append(key)
|
|
100
|
-
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, [])))
|
|
101
104
|
return ordered
|
|
102
105
|
|
|
103
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
|
+
|
|
104
119
|
class _ExecutionTimer:
|
|
105
120
|
"""Registry of named, nestable timing sections, shared through ``_TIMER``."""
|
|
106
121
|
|
|
@@ -134,18 +149,34 @@ class _ExecutionTimer:
|
|
|
134
149
|
_ = _ACTIVE_CONTEXT.set(_Frame(full_name, category, time.perf_counter(), entry, parent))
|
|
135
150
|
|
|
136
151
|
def stop_timer(self, name: str) -> None:
|
|
137
|
-
"""Stop timing a section and accumulate its elapsed time.
|
|
152
|
+
"""Stop timing a section and accumulate its elapsed time.
|
|
153
|
+
|
|
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.
|
|
158
|
+
Exiting a section that is no longer active, such as one discarded that way when its
|
|
159
|
+
generator is finally closed, does nothing.
|
|
160
|
+
"""
|
|
138
161
|
end_time = time.perf_counter()
|
|
139
|
-
|
|
162
|
+
active = _ACTIVE_CONTEXT.get()
|
|
163
|
+
frame = active
|
|
164
|
+
while frame is not None and frame.path[-1] != name:
|
|
165
|
+
frame = frame.parent
|
|
140
166
|
if frame is None:
|
|
141
167
|
return
|
|
142
|
-
if frame.path[-1] != name:
|
|
143
|
-
raise RuntimeError(f"Cannot stop '{name}' while '{frame.path[-1]}' is active.")
|
|
144
168
|
with self._lock:
|
|
145
169
|
# A clear detaches this entry from the registry. Updating the detached object
|
|
146
170
|
# cannot resurrect an old sample or add it to a replacement at the same path.
|
|
147
171
|
frame.entry["elapsed_time"] += end_time - frame.start_time
|
|
148
172
|
_ = _ACTIVE_CONTEXT.set(frame.parent)
|
|
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.
|
|
175
|
+
msg = (
|
|
176
|
+
f"Section '{name}' exited while '{active.path[-1]}' was still active; discarding the "
|
|
177
|
+
"unfinished inner sections. Close sections before a generator yields."
|
|
178
|
+
)
|
|
179
|
+
warnings.warn(msg, RuntimeWarning, stacklevel=3)
|
|
149
180
|
|
|
150
181
|
def _resolve(self, *, flatten: bool) -> dict[tuple[str, ...], _TimesDict]:
|
|
151
182
|
snapshot = self.snapshot()
|
|
@@ -153,25 +184,25 @@ class _ExecutionTimer:
|
|
|
153
184
|
|
|
154
185
|
def report_timings(self, *, flatten: bool = True) -> str:
|
|
155
186
|
"""Build a report of all sections with duration and percentage of total time."""
|
|
156
|
-
|
|
157
|
-
if not
|
|
158
|
-
_LOGGER.warning("No timings to report.")
|
|
187
|
+
snapshot = self.snapshot()
|
|
188
|
+
if not snapshot:
|
|
159
189
|
return ""
|
|
160
190
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
194
|
+
report = [f"Total time: {total_time:.4f} s.\n"]
|
|
195
|
+
for key, depth in _ordered_by_hierarchy(timings):
|
|
164
196
|
elapsed_time = timings[key]["elapsed_time"]
|
|
165
197
|
percentage = (elapsed_time / total_time) * 100 if total_time else 0.0
|
|
166
|
-
report.append(f"{'.. ' *
|
|
198
|
+
report.append(f"{'.. ' * depth}{key[-1]}: {elapsed_time:.4f} s ({percentage:.2f}%)")
|
|
167
199
|
return "\n".join(report)
|
|
168
200
|
|
|
169
|
-
def compute_total_time(self
|
|
201
|
+
def compute_total_time(self) -> float:
|
|
170
202
|
"""Compute total elapsed time across all top-level sections."""
|
|
171
|
-
# Counter merging cannot change the sum
|
|
172
|
-
_ = flatten
|
|
203
|
+
# Counter merging cannot change the sum, so there is no snapshot to copy or flatten.
|
|
173
204
|
with self._lock:
|
|
174
|
-
return
|
|
205
|
+
return _top_level_time(self.timings)
|
|
175
206
|
|
|
176
207
|
def compute_total_category_time(self, category: str) -> float:
|
|
177
208
|
"""Compute total elapsed time in a category, counting only top-most entries of that category."""
|
|
@@ -232,16 +263,16 @@ class TimerContext:
|
|
|
232
263
|
def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
|
|
233
264
|
self.name: str = _build_name_with_counter(name, counter)
|
|
234
265
|
self.category: str = category
|
|
235
|
-
self.
|
|
266
|
+
self._timer: _ExecutionTimer = _TIMER
|
|
236
267
|
|
|
237
268
|
def __enter__(self) -> TimerContext:
|
|
238
|
-
self.
|
|
269
|
+
self._timer.start_timer(self.name, self.category)
|
|
239
270
|
return self
|
|
240
271
|
|
|
241
272
|
def __exit__(
|
|
242
273
|
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
|
|
243
274
|
) -> None:
|
|
244
|
-
self.
|
|
275
|
+
self._timer.stop_timer(self.name)
|
|
245
276
|
|
|
246
277
|
def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
|
|
247
278
|
"""Decorate a function to time its execution under this context.
|
|
@@ -299,18 +330,20 @@ def _basic_name_without_counter(name: str) -> str:
|
|
|
299
330
|
|
|
300
331
|
|
|
301
332
|
def get_execution_times_report(*, flatten: bool = True) -> str:
|
|
302
|
-
"""Get a formatted report of all recorded sections; flatten counters if requested."""
|
|
333
|
+
"""Get a formatted report of all recorded sections (``""`` if none); flatten counters if requested."""
|
|
303
334
|
return _TIMER.report_timings(flatten=flatten)
|
|
304
335
|
|
|
305
336
|
|
|
306
337
|
def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
|
|
307
|
-
"""Log the execution-times report at INFO level
|
|
338
|
+
"""Log the execution-times report at INFO level, or a warning if there is nothing to report."""
|
|
308
339
|
target = logger if logger is not None else _LOGGER
|
|
309
340
|
if target.isEnabledFor(logging.INFO):
|
|
310
341
|
report = get_execution_times_report(flatten=flatten)
|
|
311
|
-
# An empty report has already been warned about; logging it would add a blank record.
|
|
312
342
|
if report:
|
|
313
|
-
|
|
343
|
+
# Start the multi-line report on its own line, after the log record's prefix.
|
|
344
|
+
target.info("\n%s", report)
|
|
345
|
+
else:
|
|
346
|
+
target.warning("No timings to report.")
|
|
314
347
|
|
|
315
348
|
|
|
316
349
|
def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
|
|
@@ -329,7 +362,7 @@ def _build_payload(*, flatten: bool = True) -> TimingsPayload:
|
|
|
329
362
|
"time": round(timings[key]["elapsed_time"], 6),
|
|
330
363
|
"category": timings[key]["category"],
|
|
331
364
|
}
|
|
332
|
-
for key in _ordered_by_hierarchy(timings)
|
|
365
|
+
for key, _ in _ordered_by_hierarchy(timings)
|
|
333
366
|
]
|
|
334
367
|
category_totals: dict[str, float] = {}
|
|
335
368
|
for key, info in snapshot.items():
|
|
@@ -337,7 +370,7 @@ def _build_payload(*, flatten: bool = True) -> TimingsPayload:
|
|
|
337
370
|
if not _has_ancestor_with_category(snapshot, key, category):
|
|
338
371
|
category_totals[category] = category_totals.get(category, 0.0) + info["elapsed_time"]
|
|
339
372
|
return {
|
|
340
|
-
"total_time": round(
|
|
373
|
+
"total_time": round(_top_level_time(snapshot), 6),
|
|
341
374
|
"total_category_time": {cat: round(category_totals[cat], 6) for cat in sorted(category_totals)},
|
|
342
375
|
"sections": sections,
|
|
343
376
|
}
|
|
@@ -355,13 +388,9 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
|
|
|
355
388
|
return out
|
|
356
389
|
|
|
357
390
|
|
|
358
|
-
def get_total_time(
|
|
359
|
-
"""Get total elapsed seconds across all top-level sections.
|
|
360
|
-
|
|
361
|
-
``flatten`` has no effect, because merging counter variants cannot change the total. It is
|
|
362
|
-
accepted for symmetry with the other reporting functions.
|
|
363
|
-
"""
|
|
364
|
-
return _TIMER.compute_total_time(flatten=flatten)
|
|
391
|
+
def get_total_time() -> float:
|
|
392
|
+
"""Get total elapsed seconds across all top-level sections."""
|
|
393
|
+
return _TIMER.compute_total_time()
|
|
365
394
|
|
|
366
395
|
|
|
367
396
|
def get_total_category_time(category: str) -> float:
|
|
@@ -7,7 +7,8 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
import threading
|
|
9
9
|
import time
|
|
10
|
-
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import AsyncIterator, Generator, Iterator
|
|
11
12
|
from concurrent.futures import ThreadPoolExecutor
|
|
12
13
|
from contextlib import ExitStack
|
|
13
14
|
from pathlib import Path
|
|
@@ -191,15 +192,17 @@ class TestReporting:
|
|
|
191
192
|
|
|
192
193
|
report = get_execution_times_report()
|
|
193
194
|
|
|
194
|
-
assert "Total
|
|
195
|
+
assert report.startswith("Total time: ")
|
|
195
196
|
assert "context_report:" in report
|
|
196
197
|
assert "context_report_nested:" in report
|
|
197
198
|
for i in range(3):
|
|
198
199
|
assert f".. context_sub_{i}:" in report
|
|
199
200
|
assert f".. .. context_subsub_{i}:" in report
|
|
200
201
|
|
|
201
|
-
def test_report_empty_when_no_timings(self) -> None:
|
|
202
|
-
|
|
202
|
+
def test_report_empty_when_no_timings(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
203
|
+
with caplog.at_level(logging.DEBUG):
|
|
204
|
+
assert get_execution_times_report() == ""
|
|
205
|
+
assert caplog.records == []
|
|
203
206
|
|
|
204
207
|
def test_report_flatten_flag(self) -> None:
|
|
205
208
|
with patch.object(time, "perf_counter", side_effect=[0, 1, 1, 2]):
|
|
@@ -271,7 +274,7 @@ class TestOutput:
|
|
|
271
274
|
with caplog.at_level(logging.INFO):
|
|
272
275
|
log_execution_times()
|
|
273
276
|
|
|
274
|
-
assert "Total
|
|
277
|
+
assert "Total time: " in caplog.text
|
|
275
278
|
assert "logged:" in caplog.text
|
|
276
279
|
|
|
277
280
|
def test_get_execution_times_json_is_valid_and_structured(self) -> None:
|
|
@@ -381,7 +384,7 @@ class TestTimerContextDecorator:
|
|
|
381
384
|
assert ("outer", "inner") in timings
|
|
382
385
|
|
|
383
386
|
def test_decorating_a_generator_function_raises(self) -> None:
|
|
384
|
-
def numbers() ->
|
|
387
|
+
def numbers() -> Generator[int]:
|
|
385
388
|
yield 1
|
|
386
389
|
|
|
387
390
|
with pytest.raises(TypeError, match=r"Cannot decorate generator function '.*numbers'"):
|
|
@@ -716,8 +719,8 @@ class TestRobustness:
|
|
|
716
719
|
assert errors == []
|
|
717
720
|
|
|
718
721
|
def test_logging_uses_the_package_logger_not_the_root_logger(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
719
|
-
with caplog.at_level(logging.
|
|
720
|
-
|
|
722
|
+
with caplog.at_level(logging.INFO):
|
|
723
|
+
log_execution_times()
|
|
721
724
|
|
|
722
725
|
assert [record.name for record in caplog.records] == ["execution_timer._timer"]
|
|
723
726
|
|
|
@@ -903,18 +906,59 @@ class TestLifecycle:
|
|
|
903
906
|
pass
|
|
904
907
|
assert ("gpu", "io", "cpu") in raw_timings()
|
|
905
908
|
|
|
906
|
-
def
|
|
909
|
+
def test_out_of_order_exit_unwinds_to_the_exited_section_and_warns(self) -> None:
|
|
907
910
|
outer = TimerContext("outer")
|
|
908
911
|
inner = TimerContext("inner")
|
|
909
|
-
with (
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
inner
|
|
913
|
-
|
|
914
|
-
):
|
|
915
|
-
|
|
916
|
-
assert raw_timings()["outer",]["time"] ==
|
|
917
|
-
assert raw_timings()["outer", "inner"]["time"] ==
|
|
912
|
+
with patch.object(time, "perf_counter", side_effect=[0, 1, 3]):
|
|
913
|
+
_ = outer.__enter__()
|
|
914
|
+
_ = inner.__enter__()
|
|
915
|
+
with pytest.warns(RuntimeWarning, match="'outer' exited while 'inner' was still active"):
|
|
916
|
+
outer.__exit__(None, None, None)
|
|
917
|
+
with TimerContext("after"):
|
|
918
|
+
pass
|
|
919
|
+
assert raw_timings()["outer",]["time"] == 3.0
|
|
920
|
+
assert raw_timings()["outer", "inner"]["time"] == 0.0
|
|
921
|
+
assert ("after",) in raw_timings()
|
|
922
|
+
|
|
923
|
+
def test_abandoned_generator_section_does_not_break_the_caller(self) -> None:
|
|
924
|
+
def numbers() -> Generator[int]:
|
|
925
|
+
with TimerContext("generator"):
|
|
926
|
+
yield 1
|
|
927
|
+
yield 2
|
|
928
|
+
|
|
929
|
+
paused = numbers()
|
|
930
|
+
with pytest.warns(RuntimeWarning), pytest.raises(KeyError, match="from the body"), TimerContext("caller"):
|
|
931
|
+
_ = next(paused)
|
|
932
|
+
raise KeyError("from the body")
|
|
933
|
+
# Closing the generator later exits a section that is no longer active: a no-op.
|
|
934
|
+
paused.close()
|
|
935
|
+
with TimerContext("after"):
|
|
936
|
+
pass
|
|
937
|
+
assert list(raw_timings()) == [("caller",), ("caller", "generator"), ("after",)]
|
|
938
|
+
|
|
939
|
+
def test_warnings_as_errors_still_record_and_unwind_before_raising(self) -> None:
|
|
940
|
+
def numbers() -> Generator[int]:
|
|
941
|
+
with TimerContext("generator"):
|
|
942
|
+
yield 1
|
|
943
|
+
|
|
944
|
+
paused = numbers()
|
|
945
|
+
with patch.object(time, "perf_counter", side_effect=[0, 1, 3]), warnings.catch_warnings():
|
|
946
|
+
warnings.simplefilter("error", RuntimeWarning)
|
|
947
|
+
with pytest.raises(RuntimeWarning), TimerContext("caller"):
|
|
948
|
+
_ = next(paused)
|
|
949
|
+
raise KeyError("from the body")
|
|
950
|
+
paused.close()
|
|
951
|
+
with TimerContext("after"):
|
|
952
|
+
pass
|
|
953
|
+
assert raw_timings()["caller",]["time"] == 3.0
|
|
954
|
+
assert list(raw_timings()) == [("caller",), ("caller", "generator"), ("after",)]
|
|
955
|
+
|
|
956
|
+
def test_exiting_a_section_that_is_not_active_leaves_the_stack_intact(self) -> None:
|
|
957
|
+
with TimerContext("outer"):
|
|
958
|
+
TimerContext("stranger").__exit__(None, None, None)
|
|
959
|
+
with TimerContext("inner"):
|
|
960
|
+
pass
|
|
961
|
+
assert list(raw_timings()) == [("outer",), ("outer", "inner")]
|
|
918
962
|
|
|
919
963
|
def test_exit_without_an_active_section_is_a_noop(self) -> None:
|
|
920
964
|
TimerContext("unused").__exit__(None, None, None)
|
|
@@ -926,17 +970,36 @@ class TestLifecycle:
|
|
|
926
970
|
with TimerContext("child", category="cpu"):
|
|
927
971
|
pass
|
|
928
972
|
assert list(raw_timings()) == [("parent", "child")]
|
|
929
|
-
|
|
973
|
+
# The cleared parent is gone, so its child is top-level: it counts toward the total
|
|
974
|
+
# and is not indented beneath an unrelated section.
|
|
975
|
+
assert get_execution_times_report() == "Total time: 2.0000 s.\n\nchild: 2.0000 s (100.00%)"
|
|
976
|
+
assert get_total_time() == 2.0
|
|
930
977
|
payload = cast(TimingsPayload, json.loads(get_execution_times_json()))
|
|
931
|
-
assert payload["total_time"] ==
|
|
978
|
+
assert payload["total_time"] == 2.0
|
|
932
979
|
assert payload["total_category_time"] == {"cpu": 2.0}
|
|
933
980
|
assert payload["sections"][0]["path"] == ["parent", "child"]
|
|
934
981
|
|
|
982
|
+
def test_periodic_clear_inside_an_outer_section_reports_each_interval(self) -> None:
|
|
983
|
+
clock = [0, 1, 2, 3, 4, 10, 11, 12, 14, 20, 30, 31]
|
|
984
|
+
with patch.object(time, "perf_counter", side_effect=clock):
|
|
985
|
+
with TimerContext("main"):
|
|
986
|
+
for batch in range(2):
|
|
987
|
+
with TimerContext("load"), TimerContext("parse"):
|
|
988
|
+
pass
|
|
989
|
+
if batch == 0:
|
|
990
|
+
clear_execution_timings()
|
|
991
|
+
with TimerContext("after"):
|
|
992
|
+
pass
|
|
993
|
+
assert get_execution_times_report() == (
|
|
994
|
+
"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%)"
|
|
995
|
+
)
|
|
996
|
+
assert get_total_time() == 5.0
|
|
997
|
+
|
|
935
998
|
def test_deep_reports_do_not_depend_on_python_recursion_limit(self) -> None:
|
|
936
999
|
with ExitStack() as stack:
|
|
937
1000
|
for _ in range(1_100):
|
|
938
1001
|
_ = stack.enter_context(TimerContext("level"))
|
|
939
|
-
assert len(get_execution_times_report(flatten=False).splitlines()) ==
|
|
1002
|
+
assert len(get_execution_times_report(flatten=False).splitlines()) == 1_102
|
|
940
1003
|
|
|
941
1004
|
def test_async_cancellation_records_time_and_restores_parent(self) -> None:
|
|
942
1005
|
@TimerContext("cancelled")
|
|
@@ -996,7 +1059,7 @@ class TestSnapshotSemantics:
|
|
|
996
1059
|
payload = cast(TimingsPayload, json.loads(get_execution_times_json(flatten=flatten)))
|
|
997
1060
|
assert payload["total_category_time"] == {"cpu": 5.0, "io": 3.0}
|
|
998
1061
|
assert get_total_category_time("cpu") == 5.0
|
|
999
|
-
assert get_total_time(
|
|
1062
|
+
assert get_total_time() == 5.0
|
|
1000
1063
|
|
|
1001
1064
|
def test_empty_json(self) -> None:
|
|
1002
1065
|
assert json.loads(get_execution_times_json(indent=None)) == {
|
|
@@ -1024,5 +1087,5 @@ class TestSnapshotSemantics:
|
|
|
1024
1087
|
with caplog.at_level(logging.INFO, logger=logger.name):
|
|
1025
1088
|
log_execution_times(flatten=False, logger=logger)
|
|
1026
1089
|
assert [(record.name, record.message) for record in caplog.records] == [
|
|
1027
|
-
(logger.name, get_execution_times_report(flatten=False))
|
|
1090
|
+
(logger.name, "\n" + get_execution_times_report(flatten=False))
|
|
1028
1091
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|