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.
@@ -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/v0.2.0...HEAD
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.2.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 :: 4 - Beta
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 calculation time: 0.3156 s.
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. Reports include completed calls;
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(*, flatten=True)` | Total seconds across all top-level sections (`flatten` has no effect on the sum). |
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 calculation time: 0.3156 s.
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. Reports include completed calls;
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(*, flatten=True)` | Total seconds across all top-level sections (`flatten` has no effect on the sum). |
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 :: 4 - Beta",
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
- requires = ["hatchling"]
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]
@@ -18,7 +18,7 @@ from execution_timer._timer import (
18
18
  save_execution_timings_json,
19
19
  )
20
20
 
21
- __version__ = "0.2.0"
21
+ __version__ = "1.0.1"
22
22
 
23
23
  __all__ = [
24
24
  "DEFAULT_CATEGORY",
@@ -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
- Insertion order is preserved within each level, so a parent revisited after an unrelated
80
- sibling still renders with its own children rather than beneath the sibling.
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 = list(reversed(roots))
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
- frame = _ACTIVE_CONTEXT.get()
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
- timings = self._resolve(flatten=flatten)
157
- if not timings:
158
- _LOGGER.warning("No timings to report.")
187
+ snapshot = self.snapshot()
188
+ if not snapshot:
159
189
  return ""
160
190
 
161
- total_time = sum(info["elapsed_time"] for key, info in timings.items() if len(key) == 1)
162
- report = [f"\nTotal calculation time: {total_time:.4f} s.\n"]
163
- for key in _ordered_by_hierarchy(timings):
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"{'.. ' * (len(key) - 1)}{key[-1]}: {elapsed_time:.4f} s ({percentage:.2f}%)")
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, *, flatten: bool = True) -> float:
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. Avoid allocating and flattening a snapshot.
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 sum((info["elapsed_time"] for key, info in self.timings.items() if len(key) == 1), 0.0)
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.timer: _ExecutionTimer = _TIMER
266
+ self._timer: _ExecutionTimer = _TIMER
236
267
 
237
268
  def __enter__(self) -> TimerContext:
238
- self.timer.start_timer(self.name, self.category)
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.timer.stop_timer(self.name)
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; flatten counters if requested."""
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
- target.info(report)
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(sum((info["elapsed_time"] for key, info in snapshot.items() if len(key) == 1), 0.0), 6),
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(*, flatten: bool = True) -> float:
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
- from collections.abc import AsyncIterator, Iterator
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 calculation time" in report
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
- assert get_execution_times_report() == ""
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 calculation time" in caplog.text
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() -> Iterator[int]:
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.WARNING):
720
- assert get_execution_times_report() == ""
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 test_out_of_order_exit_raises_without_changing_the_active_section(self) -> None:
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
- patch.object(time, "perf_counter", side_effect=[0, 1, 2, 3, 4]),
911
- outer,
912
- inner,
913
- pytest.raises(RuntimeError, match="Cannot stop 'outer' while 'inner' is active"),
914
- ):
915
- outer.__exit__(None, None, None)
916
- assert raw_timings()["outer",]["time"] == 4.0
917
- assert raw_timings()["outer", "inner"]["time"] == 2.0
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
- assert "child: 2.0000 s (0.00%)" in get_execution_times_report()
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"] == 0.0
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()) == 1_103
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(flatten=flatten) == 5.0
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