executiontimer 0.2.0__tar.gz → 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {executiontimer-0.2.0 → executiontimer-1.0.0}/CHANGELOG.md +46 -1
- {executiontimer-0.2.0 → executiontimer-1.0.0}/PKG-INFO +20 -10
- {executiontimer-0.2.0 → executiontimer-1.0.0}/README.md +18 -8
- {executiontimer-0.2.0 → executiontimer-1.0.0}/pyproject.toml +1 -1
- {executiontimer-0.2.0 → executiontimer-1.0.0}/src/execution_timer/__init__.py +1 -1
- {executiontimer-0.2.0 → executiontimer-1.0.0}/src/execution_timer/_timer.py +34 -23
- {executiontimer-0.2.0 → executiontimer-1.0.0}/tests/execution_timer_test.py +47 -21
- {executiontimer-0.2.0 → executiontimer-1.0.0}/.gitignore +0 -0
- {executiontimer-0.2.0 → executiontimer-1.0.0}/CONTRIBUTING.md +0 -0
- {executiontimer-0.2.0 → executiontimer-1.0.0}/LICENSE +0 -0
- {executiontimer-0.2.0 → executiontimer-1.0.0}/benchmarks/README.md +0 -0
- {executiontimer-0.2.0 → executiontimer-1.0.0}/benchmarks/overhead.py +0 -0
- {executiontimer-0.2.0 → executiontimer-1.0.0}/src/execution_timer/py.typed +0 -0
|
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
First stable release. The public API is now covered by semantic versioning: breaking
|
|
13
|
+
changes will wait for 2.0. Three changes below are breaking; they clean up the API before
|
|
14
|
+
it is frozen.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Breaking:** the report from `get_execution_times_report()` no longer starts with a
|
|
19
|
+
blank line, and its header reads `Total time:` rather than `Total calculation time:`.
|
|
20
|
+
`log_execution_times()` still starts the report on its own line.
|
|
21
|
+
- **Breaking:** `TimerContext.timer` is now private (`_timer`). It exposed the internal
|
|
22
|
+
registry, which is not part of the public API.
|
|
23
|
+
- `get_execution_times_report()` no longer logs a warning when there are no timings; it
|
|
24
|
+
returns `""`. `log_execution_times()` warns instead, on the logger it was given, so
|
|
25
|
+
building an empty report no longer prints to stderr when logging is not configured.
|
|
26
|
+
- The package is classified as `Development Status :: 5 - Production/Stable`.
|
|
27
|
+
|
|
28
|
+
### Removed
|
|
29
|
+
|
|
30
|
+
- **Breaking:** the `flatten` parameter of `get_total_time()`, which had no effect.
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
- README links to the changelog, contributing guide, benchmarks and license are absolute,
|
|
35
|
+
so they work on PyPI.
|
|
36
|
+
- Note that each `counter=` value is kept as a separate section until the timings are
|
|
37
|
+
cleared.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- Exiting a section while an inner one is still active, typically because a paused
|
|
42
|
+
generator holds a section open, no longer raises `RuntimeError`. The raise replaced any
|
|
43
|
+
exception already propagating from the timed block and left the active stack corrupted
|
|
44
|
+
for the rest of the thread. The timer now emits a `RuntimeWarning`, records the exited
|
|
45
|
+
section, and discards the unfinished inner sections. Exiting a section that is no longer
|
|
46
|
+
active does nothing, so closing the paused generator later no longer raises either.
|
|
47
|
+
|
|
48
|
+
### Security
|
|
49
|
+
|
|
50
|
+
- Workflows pin every action to a full commit SHA, with the release as a comment that
|
|
51
|
+
Dependabot keeps current, and CI installs dependencies with `uv sync --locked` so a
|
|
52
|
+
stale lockfile fails the build.
|
|
53
|
+
|
|
10
54
|
## [0.2.0] - 2026-09-30
|
|
11
55
|
|
|
12
56
|
### Changed
|
|
@@ -85,7 +129,8 @@ First public release on PyPI.
|
|
|
85
129
|
`py.typed` marker so type checkers use the inline annotations.
|
|
86
130
|
- `__version__` attribute on the package.
|
|
87
131
|
|
|
88
|
-
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/
|
|
132
|
+
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.0...HEAD
|
|
133
|
+
[1.0.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.2.0...v1.0.0
|
|
89
134
|
[0.2.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...v0.2.0
|
|
90
135
|
[0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
|
|
91
136
|
[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.0
|
|
4
4
|
Summary: Hierarchical execution timing with user-defined categories.
|
|
5
5
|
Project-URL: Homepage, https://github.com/seba2390/ExecutionTimer
|
|
6
6
|
Project-URL: Documentation, https://github.com/seba2390/ExecutionTimer#readme
|
|
@@ -11,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,11 @@ 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.
|
|
264
|
+
|
|
255
265
|
### Reusing contexts and clearing timings
|
|
256
266
|
|
|
257
267
|
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
@@ -273,7 +283,7 @@ an active section's current duration is added only when it exits.
|
|
|
273
283
|
|
|
274
284
|
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
275
285
|
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).
|
|
286
|
+
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
277
287
|
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
278
288
|
|
|
279
289
|
## API
|
|
@@ -281,12 +291,12 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
|
|
|
281
291
|
| Function | Description |
|
|
282
292
|
| --- | --- |
|
|
283
293
|
| `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. |
|
|
294
|
+
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
295
|
+
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
|
|
286
296
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
287
297
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
288
298
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
289
|
-
| `get_total_time(
|
|
299
|
+
| `get_total_time()` | Total seconds across all top-level sections. |
|
|
290
300
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
291
301
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
292
302
|
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
@@ -309,9 +319,9 @@ uv run ruff check --fix && uv run ruff format
|
|
|
309
319
|
uv run basedpyright
|
|
310
320
|
```
|
|
311
321
|
|
|
312
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, and
|
|
313
|
-
[CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
322
|
+
See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
|
|
323
|
+
[CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
|
|
314
324
|
|
|
315
325
|
## License
|
|
316
326
|
|
|
317
|
-
MIT — see [LICENSE](LICENSE).
|
|
327
|
+
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,11 @@ 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.
|
|
233
|
+
|
|
224
234
|
### Reusing contexts and clearing timings
|
|
225
235
|
|
|
226
236
|
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
@@ -242,7 +252,7 @@ an active section's current duration is added only when it exits.
|
|
|
242
252
|
|
|
243
253
|
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
244
254
|
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).
|
|
255
|
+
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
246
256
|
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
247
257
|
|
|
248
258
|
## API
|
|
@@ -250,12 +260,12 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
|
|
|
250
260
|
| Function | Description |
|
|
251
261
|
| --- | --- |
|
|
252
262
|
| `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. |
|
|
263
|
+
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
264
|
+
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
|
|
255
265
|
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
256
266
|
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
257
267
|
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
258
|
-
| `get_total_time(
|
|
268
|
+
| `get_total_time()` | Total seconds across all top-level sections. |
|
|
259
269
|
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
260
270
|
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
261
271
|
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
@@ -278,9 +288,9 @@ uv run ruff check --fix && uv run ruff format
|
|
|
278
288
|
uv run basedpyright
|
|
279
289
|
```
|
|
280
290
|
|
|
281
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, and
|
|
282
|
-
[CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
291
|
+
See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
|
|
292
|
+
[CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
|
|
283
293
|
|
|
284
294
|
## License
|
|
285
295
|
|
|
286
|
-
MIT — see [LICENSE](LICENSE).
|
|
296
|
+
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",
|
|
@@ -14,6 +14,7 @@ import json
|
|
|
14
14
|
import logging
|
|
15
15
|
import threading
|
|
16
16
|
import time
|
|
17
|
+
import warnings
|
|
17
18
|
from collections.abc import Callable, Coroutine, Iterable, Iterator
|
|
18
19
|
from contextvars import ContextVar
|
|
19
20
|
from itertools import count
|
|
@@ -134,13 +135,27 @@ class _ExecutionTimer:
|
|
|
134
135
|
_ = _ACTIVE_CONTEXT.set(_Frame(full_name, category, time.perf_counter(), entry, parent))
|
|
135
136
|
|
|
136
137
|
def stop_timer(self, name: str) -> None:
|
|
137
|
-
"""Stop timing a section and accumulate its elapsed time.
|
|
138
|
+
"""Stop timing a section and accumulate its elapsed time.
|
|
139
|
+
|
|
140
|
+
Never raises: an exception here would replace one already propagating from the timed
|
|
141
|
+
block. Exiting past still-active inner sections (typically a suspended generator that
|
|
142
|
+
holds one open) discards them with a warning, so the stack cannot stay corrupted.
|
|
143
|
+
Exiting a section that is no longer active, such as one discarded that way when its
|
|
144
|
+
generator is finally closed, does nothing.
|
|
145
|
+
"""
|
|
138
146
|
end_time = time.perf_counter()
|
|
139
|
-
|
|
147
|
+
active = _ACTIVE_CONTEXT.get()
|
|
148
|
+
frame = active
|
|
149
|
+
while frame is not None and frame.path[-1] != name:
|
|
150
|
+
frame = frame.parent
|
|
140
151
|
if frame is None:
|
|
141
152
|
return
|
|
142
|
-
if frame
|
|
143
|
-
|
|
153
|
+
if frame is not active and active is not None:
|
|
154
|
+
msg = (
|
|
155
|
+
f"Section '{name}' exited while '{active.path[-1]}' was still active; discarding the "
|
|
156
|
+
"unfinished inner sections. Close sections before a generator yields."
|
|
157
|
+
)
|
|
158
|
+
warnings.warn(msg, RuntimeWarning, stacklevel=3)
|
|
144
159
|
with self._lock:
|
|
145
160
|
# A clear detaches this entry from the registry. Updating the detached object
|
|
146
161
|
# cannot resurrect an old sample or add it to a replacement at the same path.
|
|
@@ -155,21 +170,19 @@ class _ExecutionTimer:
|
|
|
155
170
|
"""Build a report of all sections with duration and percentage of total time."""
|
|
156
171
|
timings = self._resolve(flatten=flatten)
|
|
157
172
|
if not timings:
|
|
158
|
-
_LOGGER.warning("No timings to report.")
|
|
159
173
|
return ""
|
|
160
174
|
|
|
161
175
|
total_time = sum(info["elapsed_time"] for key, info in timings.items() if len(key) == 1)
|
|
162
|
-
report = [f"
|
|
176
|
+
report = [f"Total time: {total_time:.4f} s.\n"]
|
|
163
177
|
for key in _ordered_by_hierarchy(timings):
|
|
164
178
|
elapsed_time = timings[key]["elapsed_time"]
|
|
165
179
|
percentage = (elapsed_time / total_time) * 100 if total_time else 0.0
|
|
166
180
|
report.append(f"{'.. ' * (len(key) - 1)}{key[-1]}: {elapsed_time:.4f} s ({percentage:.2f}%)")
|
|
167
181
|
return "\n".join(report)
|
|
168
182
|
|
|
169
|
-
def compute_total_time(self
|
|
183
|
+
def compute_total_time(self) -> float:
|
|
170
184
|
"""Compute total elapsed time across all top-level sections."""
|
|
171
|
-
# Counter merging cannot change the sum
|
|
172
|
-
_ = flatten
|
|
185
|
+
# Counter merging cannot change the sum, so there is no snapshot to copy or flatten.
|
|
173
186
|
with self._lock:
|
|
174
187
|
return sum((info["elapsed_time"] for key, info in self.timings.items() if len(key) == 1), 0.0)
|
|
175
188
|
|
|
@@ -232,16 +245,16 @@ class TimerContext:
|
|
|
232
245
|
def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
|
|
233
246
|
self.name: str = _build_name_with_counter(name, counter)
|
|
234
247
|
self.category: str = category
|
|
235
|
-
self.
|
|
248
|
+
self._timer: _ExecutionTimer = _TIMER
|
|
236
249
|
|
|
237
250
|
def __enter__(self) -> TimerContext:
|
|
238
|
-
self.
|
|
251
|
+
self._timer.start_timer(self.name, self.category)
|
|
239
252
|
return self
|
|
240
253
|
|
|
241
254
|
def __exit__(
|
|
242
255
|
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
|
|
243
256
|
) -> None:
|
|
244
|
-
self.
|
|
257
|
+
self._timer.stop_timer(self.name)
|
|
245
258
|
|
|
246
259
|
def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
|
|
247
260
|
"""Decorate a function to time its execution under this context.
|
|
@@ -299,18 +312,20 @@ def _basic_name_without_counter(name: str) -> str:
|
|
|
299
312
|
|
|
300
313
|
|
|
301
314
|
def get_execution_times_report(*, flatten: bool = True) -> str:
|
|
302
|
-
"""Get a formatted report of all recorded sections; flatten counters if requested."""
|
|
315
|
+
"""Get a formatted report of all recorded sections (``""`` if none); flatten counters if requested."""
|
|
303
316
|
return _TIMER.report_timings(flatten=flatten)
|
|
304
317
|
|
|
305
318
|
|
|
306
319
|
def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
|
|
307
|
-
"""Log the execution-times report at INFO level
|
|
320
|
+
"""Log the execution-times report at INFO level, or a warning if there is nothing to report."""
|
|
308
321
|
target = logger if logger is not None else _LOGGER
|
|
309
322
|
if target.isEnabledFor(logging.INFO):
|
|
310
323
|
report = get_execution_times_report(flatten=flatten)
|
|
311
|
-
# An empty report has already been warned about; logging it would add a blank record.
|
|
312
324
|
if report:
|
|
313
|
-
|
|
325
|
+
# Start the multi-line report on its own line, after the log record's prefix.
|
|
326
|
+
target.info("\n%s", report)
|
|
327
|
+
else:
|
|
328
|
+
target.warning("No timings to report.")
|
|
314
329
|
|
|
315
330
|
|
|
316
331
|
def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
|
|
@@ -355,13 +370,9 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
|
|
|
355
370
|
return out
|
|
356
371
|
|
|
357
372
|
|
|
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)
|
|
373
|
+
def get_total_time() -> float:
|
|
374
|
+
"""Get total elapsed seconds across all top-level sections."""
|
|
375
|
+
return _TIMER.compute_total_time()
|
|
365
376
|
|
|
366
377
|
|
|
367
378
|
def get_total_category_time(category: str) -> float:
|
|
@@ -7,7 +7,7 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
import threading
|
|
9
9
|
import time
|
|
10
|
-
from collections.abc import AsyncIterator, Iterator
|
|
10
|
+
from collections.abc import AsyncIterator, Generator, Iterator
|
|
11
11
|
from concurrent.futures import ThreadPoolExecutor
|
|
12
12
|
from contextlib import ExitStack
|
|
13
13
|
from pathlib import Path
|
|
@@ -191,15 +191,17 @@ class TestReporting:
|
|
|
191
191
|
|
|
192
192
|
report = get_execution_times_report()
|
|
193
193
|
|
|
194
|
-
assert "Total
|
|
194
|
+
assert report.startswith("Total time: ")
|
|
195
195
|
assert "context_report:" in report
|
|
196
196
|
assert "context_report_nested:" in report
|
|
197
197
|
for i in range(3):
|
|
198
198
|
assert f".. context_sub_{i}:" in report
|
|
199
199
|
assert f".. .. context_subsub_{i}:" in report
|
|
200
200
|
|
|
201
|
-
def test_report_empty_when_no_timings(self) -> None:
|
|
202
|
-
|
|
201
|
+
def test_report_empty_when_no_timings(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
202
|
+
with caplog.at_level(logging.DEBUG):
|
|
203
|
+
assert get_execution_times_report() == ""
|
|
204
|
+
assert caplog.records == []
|
|
203
205
|
|
|
204
206
|
def test_report_flatten_flag(self) -> None:
|
|
205
207
|
with patch.object(time, "perf_counter", side_effect=[0, 1, 1, 2]):
|
|
@@ -271,7 +273,7 @@ class TestOutput:
|
|
|
271
273
|
with caplog.at_level(logging.INFO):
|
|
272
274
|
log_execution_times()
|
|
273
275
|
|
|
274
|
-
assert "Total
|
|
276
|
+
assert "Total time: " in caplog.text
|
|
275
277
|
assert "logged:" in caplog.text
|
|
276
278
|
|
|
277
279
|
def test_get_execution_times_json_is_valid_and_structured(self) -> None:
|
|
@@ -381,7 +383,7 @@ class TestTimerContextDecorator:
|
|
|
381
383
|
assert ("outer", "inner") in timings
|
|
382
384
|
|
|
383
385
|
def test_decorating_a_generator_function_raises(self) -> None:
|
|
384
|
-
def numbers() ->
|
|
386
|
+
def numbers() -> Generator[int]:
|
|
385
387
|
yield 1
|
|
386
388
|
|
|
387
389
|
with pytest.raises(TypeError, match=r"Cannot decorate generator function '.*numbers'"):
|
|
@@ -716,8 +718,8 @@ class TestRobustness:
|
|
|
716
718
|
assert errors == []
|
|
717
719
|
|
|
718
720
|
def test_logging_uses_the_package_logger_not_the_root_logger(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
719
|
-
with caplog.at_level(logging.
|
|
720
|
-
|
|
721
|
+
with caplog.at_level(logging.INFO):
|
|
722
|
+
log_execution_times()
|
|
721
723
|
|
|
722
724
|
assert [record.name for record in caplog.records] == ["execution_timer._timer"]
|
|
723
725
|
|
|
@@ -903,18 +905,42 @@ class TestLifecycle:
|
|
|
903
905
|
pass
|
|
904
906
|
assert ("gpu", "io", "cpu") in raw_timings()
|
|
905
907
|
|
|
906
|
-
def
|
|
908
|
+
def test_out_of_order_exit_unwinds_to_the_exited_section_and_warns(self) -> None:
|
|
907
909
|
outer = TimerContext("outer")
|
|
908
910
|
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"] ==
|
|
911
|
+
with patch.object(time, "perf_counter", side_effect=[0, 1, 3]):
|
|
912
|
+
_ = outer.__enter__()
|
|
913
|
+
_ = inner.__enter__()
|
|
914
|
+
with pytest.warns(RuntimeWarning, match="'outer' exited while 'inner' was still active"):
|
|
915
|
+
outer.__exit__(None, None, None)
|
|
916
|
+
with TimerContext("after"):
|
|
917
|
+
pass
|
|
918
|
+
assert raw_timings()["outer",]["time"] == 3.0
|
|
919
|
+
assert raw_timings()["outer", "inner"]["time"] == 0.0
|
|
920
|
+
assert ("after",) in raw_timings()
|
|
921
|
+
|
|
922
|
+
def test_abandoned_generator_section_does_not_break_the_caller(self) -> None:
|
|
923
|
+
def numbers() -> Generator[int]:
|
|
924
|
+
with TimerContext("generator"):
|
|
925
|
+
yield 1
|
|
926
|
+
yield 2
|
|
927
|
+
|
|
928
|
+
paused = numbers()
|
|
929
|
+
with pytest.warns(RuntimeWarning), pytest.raises(KeyError, match="from the body"), TimerContext("caller"):
|
|
930
|
+
_ = next(paused)
|
|
931
|
+
raise KeyError("from the body")
|
|
932
|
+
# Closing the generator later exits a section that is no longer active: a no-op.
|
|
933
|
+
paused.close()
|
|
934
|
+
with TimerContext("after"):
|
|
935
|
+
pass
|
|
936
|
+
assert list(raw_timings()) == [("caller",), ("caller", "generator"), ("after",)]
|
|
937
|
+
|
|
938
|
+
def test_exiting_a_section_that_is_not_active_leaves_the_stack_intact(self) -> None:
|
|
939
|
+
with TimerContext("outer"):
|
|
940
|
+
TimerContext("stranger").__exit__(None, None, None)
|
|
941
|
+
with TimerContext("inner"):
|
|
942
|
+
pass
|
|
943
|
+
assert list(raw_timings()) == [("outer",), ("outer", "inner")]
|
|
918
944
|
|
|
919
945
|
def test_exit_without_an_active_section_is_a_noop(self) -> None:
|
|
920
946
|
TimerContext("unused").__exit__(None, None, None)
|
|
@@ -936,7 +962,7 @@ class TestLifecycle:
|
|
|
936
962
|
with ExitStack() as stack:
|
|
937
963
|
for _ in range(1_100):
|
|
938
964
|
_ = stack.enter_context(TimerContext("level"))
|
|
939
|
-
assert len(get_execution_times_report(flatten=False).splitlines()) ==
|
|
965
|
+
assert len(get_execution_times_report(flatten=False).splitlines()) == 1_102
|
|
940
966
|
|
|
941
967
|
def test_async_cancellation_records_time_and_restores_parent(self) -> None:
|
|
942
968
|
@TimerContext("cancelled")
|
|
@@ -996,7 +1022,7 @@ class TestSnapshotSemantics:
|
|
|
996
1022
|
payload = cast(TimingsPayload, json.loads(get_execution_times_json(flatten=flatten)))
|
|
997
1023
|
assert payload["total_category_time"] == {"cpu": 5.0, "io": 3.0}
|
|
998
1024
|
assert get_total_category_time("cpu") == 5.0
|
|
999
|
-
assert get_total_time(
|
|
1025
|
+
assert get_total_time() == 5.0
|
|
1000
1026
|
|
|
1001
1027
|
def test_empty_json(self) -> None:
|
|
1002
1028
|
assert json.loads(get_execution_times_json(indent=None)) == {
|
|
@@ -1024,5 +1050,5 @@ class TestSnapshotSemantics:
|
|
|
1024
1050
|
with caplog.at_level(logging.INFO, logger=logger.name):
|
|
1025
1051
|
log_execution_times(flatten=False, logger=logger)
|
|
1026
1052
|
assert [(record.name, record.message) for record in caplog.records] == [
|
|
1027
|
-
(logger.name, get_execution_times_report(flatten=False))
|
|
1053
|
+
(logger.name, "\n" + get_execution_times_report(flatten=False))
|
|
1028
1054
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|