executiontimer 1.0.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,26 @@ 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
+
10
30
  ## [1.0.0] - 2026-09-30
11
31
 
12
32
  First stable release. The public API is now covered by semantic versioning: breaking
@@ -129,7 +149,8 @@ First public release on PyPI.
129
149
  `py.typed` marker so type checkers use the inline annotations.
130
150
  - `__version__` attribute on the package.
131
151
 
132
- [Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.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
133
154
  [1.0.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.2.0...v1.0.0
134
155
  [0.2.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...v0.2.0
135
156
  [0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: executiontimer
3
- Version: 1.0.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
@@ -260,7 +260,8 @@ time the loop that consumes the generator instead.
260
260
  If the caller's section exits while a paused generator's section is still open, the
261
261
  timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
262
262
  discards the generator's unfinished one, so later sections nest correctly. Closing that
263
- generator afterwards does nothing.
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.
264
265
 
265
266
  ### Reusing contexts and clearing timings
266
267
 
@@ -276,7 +277,8 @@ for item in items:
276
277
 
277
278
  Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
278
279
  samples from sections that were already active, without disturbing their nesting stack.
279
- 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;
280
282
  an active section's current duration is added only when it exits.
281
283
 
282
284
  ### Measuring overhead
@@ -229,7 +229,8 @@ time the loop that consumes the generator instead.
229
229
  If the caller's section exits while a paused generator's section is still open, the
230
230
  timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
231
231
  discards the generator's unfinished one, so later sections nest correctly. Closing that
232
- generator afterwards does nothing.
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.
233
234
 
234
235
  ### Reusing contexts and clearing timings
235
236
 
@@ -245,7 +246,8 @@ for item in items:
245
246
 
246
247
  Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
247
248
  samples from sections that were already active, without disturbing their nesting stack.
248
- 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;
249
251
  an active section's current duration is added only when it exits.
250
252
 
251
253
  ### Measuring overhead
@@ -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__ = "1.0.0"
21
+ __version__ = "1.0.1"
22
22
 
23
23
  __all__ = [
24
24
  "DEFAULT_CATEGORY",
@@ -74,11 +74,13 @@ class _Frame(NamedTuple):
74
74
  _ACTIVE_CONTEXT: ContextVar[_Frame | None] = ContextVar("execution_timer_context", default=None)
75
75
 
76
76
 
77
- def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ...]]:
77
+ def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[tuple[str, ...], int]]:
78
78
  """Order section paths depth-first so children always follow their parent.
79
79
 
80
- Insertion order is preserved within each level, so a parent revisited after an unrelated
81
- 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.
82
84
  """
83
85
  keys = list(keys)
84
86
  known = set(keys)
@@ -92,16 +94,28 @@ def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ..
92
94
  else:
93
95
  roots.append(key)
94
96
 
95
- ordered: list[tuple[str, ...]] = []
97
+ ordered: list[tuple[tuple[str, ...], int]] = []
96
98
  # Explicit stack rather than recursion: nesting depth is user-controlled.
97
- stack = list(reversed(roots))
99
+ stack = [(key, 0) for key in reversed(roots)]
98
100
  while stack:
99
- key = stack.pop()
100
- ordered.append(key)
101
- stack.extend(reversed(children.get(key, [])))
101
+ key, depth = stack.pop()
102
+ ordered.append((key, depth))
103
+ stack.extend((child, depth + 1) for child in reversed(children.get(key, [])))
102
104
  return ordered
103
105
 
104
106
 
107
+ def _top_level_time(timings: dict[tuple[str, ...], _TimesDict]) -> float:
108
+ """Sum the sections with no recorded parent, matching the roots of ``_ordered_by_hierarchy``.
109
+
110
+ A parent goes missing when timings are cleared while it is active; its children that
111
+ finish afterwards are then top-level and must count toward the total.
112
+ """
113
+ return sum(
114
+ (info["elapsed_time"] for key, info in timings.items() if len(key) == 1 or key[:-1] not in timings),
115
+ 0.0,
116
+ )
117
+
118
+
105
119
  class _ExecutionTimer:
106
120
  """Registry of named, nestable timing sections, shared through ``_TIMER``."""
107
121
 
@@ -137,9 +151,10 @@ class _ExecutionTimer:
137
151
  def stop_timer(self, name: str) -> None:
138
152
  """Stop timing a section and accumulate its elapsed time.
139
153
 
140
- Never raises: an exception here would replace 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.
154
+ Never raises, unless warnings are configured as errors: an exception here would replace
155
+ one already propagating from the timed block. Exiting past still-active inner sections
156
+ (typically a suspended generator that holds one open) discards them with a warning,
157
+ after recording the exit, so the stack cannot stay corrupted.
143
158
  Exiting a section that is no longer active, such as one discarded that way when its
144
159
  generator is finally closed, does nothing.
145
160
  """
@@ -150,17 +165,18 @@ class _ExecutionTimer:
150
165
  frame = frame.parent
151
166
  if frame is None:
152
167
  return
168
+ with self._lock:
169
+ # A clear detaches this entry from the registry. Updating the detached object
170
+ # cannot resurrect an old sample or add it to a replacement at the same path.
171
+ frame.entry["elapsed_time"] += end_time - frame.start_time
172
+ _ = _ACTIVE_CONTEXT.set(frame.parent)
153
173
  if frame is not active and active is not None:
174
+ # Warn only once the state is consistent: warnings configured as errors raise here.
154
175
  msg = (
155
176
  f"Section '{name}' exited while '{active.path[-1]}' was still active; discarding the "
156
177
  "unfinished inner sections. Close sections before a generator yields."
157
178
  )
158
179
  warnings.warn(msg, RuntimeWarning, stacklevel=3)
159
- with self._lock:
160
- # A clear detaches this entry from the registry. Updating the detached object
161
- # cannot resurrect an old sample or add it to a replacement at the same path.
162
- frame.entry["elapsed_time"] += end_time - frame.start_time
163
- _ = _ACTIVE_CONTEXT.set(frame.parent)
164
180
 
165
181
  def _resolve(self, *, flatten: bool) -> dict[tuple[str, ...], _TimesDict]:
166
182
  snapshot = self.snapshot()
@@ -168,23 +184,25 @@ class _ExecutionTimer:
168
184
 
169
185
  def report_timings(self, *, flatten: bool = True) -> str:
170
186
  """Build a report of all sections with duration and percentage of total time."""
171
- timings = self._resolve(flatten=flatten)
172
- if not timings:
187
+ snapshot = self.snapshot()
188
+ if not snapshot:
173
189
  return ""
174
190
 
175
- total_time = sum(info["elapsed_time"] for key, info in timings.items() if len(key) == 1)
191
+ # Total the unflattened paths, like get_total_time and the JSON export.
192
+ total_time = _top_level_time(snapshot)
193
+ timings = _flatten(snapshot) if flatten else snapshot
176
194
  report = [f"Total time: {total_time:.4f} s.\n"]
177
- for key in _ordered_by_hierarchy(timings):
195
+ for key, depth in _ordered_by_hierarchy(timings):
178
196
  elapsed_time = timings[key]["elapsed_time"]
179
197
  percentage = (elapsed_time / total_time) * 100 if total_time else 0.0
180
- report.append(f"{'.. ' * (len(key) - 1)}{key[-1]}: {elapsed_time:.4f} s ({percentage:.2f}%)")
198
+ report.append(f"{'.. ' * depth}{key[-1]}: {elapsed_time:.4f} s ({percentage:.2f}%)")
181
199
  return "\n".join(report)
182
200
 
183
201
  def compute_total_time(self) -> float:
184
202
  """Compute total elapsed time across all top-level sections."""
185
203
  # Counter merging cannot change the sum, so there is no snapshot to copy or flatten.
186
204
  with self._lock:
187
- return sum((info["elapsed_time"] for key, info in self.timings.items() if len(key) == 1), 0.0)
205
+ return _top_level_time(self.timings)
188
206
 
189
207
  def compute_total_category_time(self, category: str) -> float:
190
208
  """Compute total elapsed time in a category, counting only top-most entries of that category."""
@@ -344,7 +362,7 @@ def _build_payload(*, flatten: bool = True) -> TimingsPayload:
344
362
  "time": round(timings[key]["elapsed_time"], 6),
345
363
  "category": timings[key]["category"],
346
364
  }
347
- for key in _ordered_by_hierarchy(timings)
365
+ for key, _ in _ordered_by_hierarchy(timings)
348
366
  ]
349
367
  category_totals: dict[str, float] = {}
350
368
  for key, info in snapshot.items():
@@ -352,7 +370,7 @@ def _build_payload(*, flatten: bool = True) -> TimingsPayload:
352
370
  if not _has_ancestor_with_category(snapshot, key, category):
353
371
  category_totals[category] = category_totals.get(category, 0.0) + info["elapsed_time"]
354
372
  return {
355
- "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),
356
374
  "total_category_time": {cat: round(category_totals[cat], 6) for cat in sorted(category_totals)},
357
375
  "sections": sections,
358
376
  }
@@ -7,6 +7,7 @@ import json
7
7
  import logging
8
8
  import threading
9
9
  import time
10
+ import warnings
10
11
  from collections.abc import AsyncIterator, Generator, Iterator
11
12
  from concurrent.futures import ThreadPoolExecutor
12
13
  from contextlib import ExitStack
@@ -935,6 +936,23 @@ class TestLifecycle:
935
936
  pass
936
937
  assert list(raw_timings()) == [("caller",), ("caller", "generator"), ("after",)]
937
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
+
938
956
  def test_exiting_a_section_that_is_not_active_leaves_the_stack_intact(self) -> None:
939
957
  with TimerContext("outer"):
940
958
  TimerContext("stranger").__exit__(None, None, None)
@@ -952,12 +970,31 @@ class TestLifecycle:
952
970
  with TimerContext("child", category="cpu"):
953
971
  pass
954
972
  assert list(raw_timings()) == [("parent", "child")]
955
- 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
956
977
  payload = cast(TimingsPayload, json.loads(get_execution_times_json()))
957
- assert payload["total_time"] == 0.0
978
+ assert payload["total_time"] == 2.0
958
979
  assert payload["total_category_time"] == {"cpu": 2.0}
959
980
  assert payload["sections"][0]["path"] == ["parent", "child"]
960
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
+
961
998
  def test_deep_reports_do_not_depend_on_python_recursion_limit(self) -> None:
962
999
  with ExitStack() as stack:
963
1000
  for _ in range(1_100):
File without changes