executiontimer 0.1.0__py3-none-any.whl → 0.1.1__py3-none-any.whl

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.
@@ -18,7 +18,7 @@ from execution_timer._timer import (
18
18
  save_execution_timings_json,
19
19
  )
20
20
 
21
- __version__ = "0.1.0"
21
+ __version__ = "0.1.1"
22
22
 
23
23
  __all__ = [
24
24
  "DEFAULT_CATEGORY",
execution_timer/_timer.py CHANGED
@@ -3,7 +3,7 @@
3
3
  Timings are stored in a process-wide registry guarded by a lock. The active-context stack
4
4
  lives in a :class:`~contextvars.ContextVar`, so it is isolated per thread *and* per asyncio
5
5
  task: sections recorded concurrently nest independently and merge into one report.
6
- Recording the *same* section path from overlapping threads or tasks is not meaningful.
6
+ Overlapping calls to the same section accumulate their individual durations.
7
7
  """
8
8
 
9
9
  from __future__ import annotations
@@ -14,11 +14,12 @@ import json
14
14
  import logging
15
15
  import threading
16
16
  import time
17
- from collections.abc import Callable, Coroutine, Iterable
17
+ from collections.abc import Callable, Coroutine, Iterable, Iterator
18
18
  from contextvars import ContextVar
19
+ from itertools import count
19
20
  from pathlib import Path
20
21
  from types import TracebackType
21
- from typing import ClassVar, Final, ParamSpec, TypedDict, TypeVar, cast
22
+ from typing import Final, NamedTuple, ParamSpec, TypedDict, TypeVar, cast
22
23
 
23
24
  P = ParamSpec("P")
24
25
  R = TypeVar("R")
@@ -28,9 +29,6 @@ DEFAULT_CATEGORY: Final = "default"
28
29
 
29
30
  _LOGGER: Final = logging.getLogger(__name__)
30
31
 
31
- # Stack of (name, category) frames for the current thread / asyncio task.
32
- _ACTIVE_CONTEXT: ContextVar[tuple[tuple[str, str], ...]] = ContextVar("execution_timer_context", default=())
33
-
34
32
 
35
33
  class TimingReport(TypedDict):
36
34
  """Timing entry for one section: elapsed seconds and its category."""
@@ -57,11 +55,24 @@ class TimingsPayload(TypedDict):
57
55
 
58
56
 
59
57
  class _TimesDict(TypedDict):
60
- start_time: float
58
+ sequence: int
61
59
  elapsed_time: float
62
60
  category: str
63
61
 
64
62
 
63
+ class _Frame(NamedTuple):
64
+ """Per-invocation state, with a cached path and an immutable parent link."""
65
+
66
+ path: tuple[str, ...]
67
+ category: str
68
+ start_time: float
69
+ entry: _TimesDict
70
+ parent: _Frame | None
71
+
72
+
73
+ _ACTIVE_CONTEXT: ContextVar[_Frame | None] = ContextVar("execution_timer_context", default=None)
74
+
75
+
65
76
  def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ...]]:
66
77
  """Order section paths depth-first so children always follow their parent.
67
78
 
@@ -91,81 +102,53 @@ def _ordered_by_hierarchy(keys: Iterable[tuple[str, ...]]) -> list[tuple[str, ..
91
102
 
92
103
 
93
104
  class _ExecutionTimer:
94
- """Singleton registry of named, nestable timing sections."""
95
-
96
- _instance: ClassVar[_ExecutionTimer | None] = None
97
- _lock: ClassVar[threading.Lock] = threading.Lock()
98
- timings: ClassVar[dict[tuple[str, ...], _TimesDict]] = {}
99
- forbidden_nesting: ClassVar[set[tuple[str, str]]] = set()
100
-
101
- def __new__(cls) -> _ExecutionTimer:
102
- if cls._instance is None:
103
- cls._instance = super().__new__(cls)
104
- return cls._instance
105
+ """Registry of named, nestable timing sections, shared through ``_TIMER``."""
105
106
 
106
- @property
107
- def _full_name(self) -> tuple[str, ...]:
108
- return tuple(frame[0] for frame in _ACTIVE_CONTEXT.get())
107
+ def __init__(self) -> None:
108
+ self._lock: threading.Lock = threading.Lock()
109
+ self.timings: dict[tuple[str, ...], _TimesDict] = {}
110
+ self.forbidden_nesting: set[tuple[str, str]] = set()
111
+ self._sequence: Iterator[int] = count()
109
112
 
110
- def _snapshot(self) -> dict[tuple[str, ...], _TimesDict]:
113
+ def snapshot(self) -> dict[tuple[str, ...], _TimesDict]:
111
114
  """Copy the registry under the lock so readers never iterate a mutating dict."""
112
115
  with self._lock:
113
116
  return {key: info.copy() for key, info in self.timings.items()}
114
117
 
115
118
  def start_timer(self, name: str, category: str) -> None:
116
119
  """Start timing a section under the given name within the active context."""
117
- self._add_context(name, category)
118
- full_name = self._full_name
119
- start_time = time.perf_counter()
120
+ parent = _ACTIVE_CONTEXT.get()
121
+ full_name = (*parent.path, name) if parent is not None else (name,)
120
122
  with self._lock:
121
- entry = self.timings.get(full_name)
123
+ if parent is not None and (parent.category, category) in self.forbidden_nesting:
124
+ msg = f"Category '{category}' is not allowed inside category '{parent.category}'."
125
+ raise ValueError(msg)
126
+ sequence = next(self._sequence)
127
+ entry: _TimesDict | None = self.timings.get(full_name)
122
128
  if entry is None:
123
- self.timings[full_name] = {"start_time": start_time, "elapsed_time": 0.0, "category": category}
129
+ entry = {"sequence": sequence, "elapsed_time": 0.0, "category": category}
130
+ self.timings[full_name] = entry
124
131
  else:
125
- entry["start_time"] = start_time
132
+ entry["sequence"] = sequence
126
133
  entry["category"] = category
134
+ _ = _ACTIVE_CONTEXT.set(_Frame(full_name, category, time.perf_counter(), entry, parent))
127
135
 
128
136
  def stop_timer(self, name: str) -> None:
129
137
  """Stop timing a section and accumulate its elapsed time."""
130
138
  end_time = time.perf_counter()
131
- full_name = self._full_name
132
- with self._lock:
133
- entry = self.timings.get(full_name)
134
- # The entry is gone if the registry was cleared while this section was running;
135
- # dropping the sample is preferable to raising out of a ``with`` block.
136
- if entry is not None:
137
- entry["elapsed_time"] += end_time - entry["start_time"]
138
- self._remove_context(name)
139
-
140
- def _add_context(self, name: str, category: str) -> None:
141
- stack = _ACTIVE_CONTEXT.get()
142
- if stack and (stack[-1][1], category) in self.forbidden_nesting:
143
- msg = f"Category '{category}' is not allowed inside category '{stack[-1][1]}'."
144
- raise ValueError(msg)
145
- _ = _ACTIVE_CONTEXT.set((*stack, (name, category)))
146
-
147
- def _remove_context(self, name: str) -> None:
148
- """Pop the innermost context, restoring the stack to its state before ``name`` was entered."""
149
- stack = _ACTIVE_CONTEXT.get()
150
- if not stack:
139
+ frame = _ACTIVE_CONTEXT.get()
140
+ if frame is None:
151
141
  return
152
- # Cut back through the matching frame so a mismatched or out-of-order exit cannot corrupt the stack.
153
- for index in range(len(stack) - 1, -1, -1):
154
- if stack[index][0] == name:
155
- _ = _ACTIVE_CONTEXT.set(stack[:index])
156
- return
157
- _ = _ACTIVE_CONTEXT.set(stack[:-1])
158
-
159
- def compute_flattened_timings(self) -> dict[tuple[str, ...], _TimesDict]:
160
- """Aggregate elapsed times with counter suffixes removed from section names.
161
-
162
- When counter variants of one section are merged, elapsed times are summed and the
163
- most recently recorded category is kept.
164
- """
165
- return _flatten(self._snapshot())
142
+ if frame.path[-1] != name:
143
+ raise RuntimeError(f"Cannot stop '{name}' while '{frame.path[-1]}' is active.")
144
+ with self._lock:
145
+ # A clear detaches this entry from the registry. Updating the detached object
146
+ # cannot resurrect an old sample or add it to a replacement at the same path.
147
+ frame.entry["elapsed_time"] += end_time - frame.start_time
148
+ _ = _ACTIVE_CONTEXT.set(frame.parent)
166
149
 
167
150
  def _resolve(self, *, flatten: bool) -> dict[tuple[str, ...], _TimesDict]:
168
- snapshot = self._snapshot()
151
+ snapshot = self.snapshot()
169
152
  return _flatten(snapshot) if flatten else snapshot
170
153
 
171
154
  def report_timings(self, *, flatten: bool = True) -> str:
@@ -185,12 +168,14 @@ class _ExecutionTimer:
185
168
 
186
169
  def compute_total_time(self, *, flatten: bool = True) -> float:
187
170
  """Compute total elapsed time across all top-level sections."""
188
- timings = self._resolve(flatten=flatten)
189
- return sum(info["elapsed_time"] for key, info in timings.items() if len(key) == 1)
171
+ # Counter merging cannot change the sum. Avoid allocating and flattening a snapshot.
172
+ _ = flatten
173
+ with self._lock:
174
+ return sum((info["elapsed_time"] for key, info in self.timings.items() if len(key) == 1), 0.0)
190
175
 
191
176
  def compute_total_category_time(self, category: str) -> float:
192
177
  """Compute total elapsed time in a category, counting only top-most entries of that category."""
193
- timings = self._snapshot()
178
+ timings = self.snapshot()
194
179
  total_time = 0.0
195
180
  for key, info in timings.items():
196
181
  if info["category"] != category or _has_ancestor_with_category(timings, key, category):
@@ -208,6 +193,17 @@ class _ExecutionTimer:
208
193
  with self._lock:
209
194
  self.timings.clear()
210
195
 
196
+ def register_forbidden_nesting(self, outer: str, inner: str) -> None:
197
+ with self._lock:
198
+ self.forbidden_nesting.add((outer, inner))
199
+
200
+ def clear_forbidden_nesting(self) -> None:
201
+ with self._lock:
202
+ self.forbidden_nesting.clear()
203
+
204
+
205
+ _TIMER: Final = _ExecutionTimer()
206
+
211
207
 
212
208
  def _flatten(timings: dict[tuple[str, ...], _TimesDict]) -> dict[tuple[str, ...], _TimesDict]:
213
209
  flat_map: dict[tuple[str, ...], _TimesDict] = {}
@@ -218,7 +214,9 @@ def _flatten(timings: dict[tuple[str, ...], _TimesDict]) -> dict[tuple[str, ...]
218
214
  flat_map[flat_key] = info.copy()
219
215
  else:
220
216
  existing["elapsed_time"] += info["elapsed_time"]
221
- existing["category"] = info["category"]
217
+ if info["sequence"] > existing["sequence"]:
218
+ existing["category"] = info["category"]
219
+ existing["sequence"] = info["sequence"]
222
220
  return flat_map
223
221
 
224
222
 
@@ -234,7 +232,7 @@ class TimerContext:
234
232
  def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
235
233
  self.name: str = _build_name_with_counter(name, counter)
236
234
  self.category: str = category
237
- self.timer: _ExecutionTimer = _ExecutionTimer()
235
+ self.timer: _ExecutionTimer = _TIMER
238
236
 
239
237
  def __enter__(self) -> TimerContext:
240
238
  self.timer.start_timer(self.name, self.category)
@@ -259,7 +257,7 @@ class TimerContext:
259
257
 
260
258
  @functools.wraps(func)
261
259
  def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
262
- with TimerContext(self.name, self.category):
260
+ with self:
263
261
  return func(*args, **kwargs)
264
262
 
265
263
  return wrapper
@@ -269,7 +267,7 @@ class TimerContext:
269
267
 
270
268
  @functools.wraps(func)
271
269
  async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
272
- with TimerContext(self.name, self.category):
270
+ with self:
273
271
  return await func(*args, **kwargs)
274
272
 
275
273
  return async_wrapper
@@ -285,42 +283,51 @@ def _build_name_with_counter(name: str, counter: int | None = None) -> str:
285
283
  def _basic_name_without_counter(name: str) -> str:
286
284
  """Strip a trailing ``[counter]`` from a section name if present."""
287
285
  if "[" in name and name.endswith("]"):
288
- return name[: name.rfind("[")]
286
+ prefix, _, suffix = name.rpartition("[")
287
+ digits = suffix[:-1].removeprefix("-")
288
+ if digits.isascii() and digits.isdecimal():
289
+ return prefix
289
290
  return name
290
291
 
291
292
 
292
293
  def get_execution_times_report(*, flatten: bool = True) -> str:
293
294
  """Get a formatted report of all recorded sections; flatten counters if requested."""
294
- return _ExecutionTimer().report_timings(flatten=flatten)
295
+ return _TIMER.report_timings(flatten=flatten)
295
296
 
296
297
 
297
298
  def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
298
299
  """Log the execution-times report at INFO level; flatten counters if requested."""
299
- (logger or _LOGGER).info(get_execution_times_report(flatten=flatten))
300
+ target = logger if logger is not None else _LOGGER
301
+ if target.isEnabledFor(logging.INFO):
302
+ target.info(get_execution_times_report(flatten=flatten))
300
303
 
301
304
 
302
305
  def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
303
306
  """Get elapsed seconds and category for every recorded section; flatten counters if requested."""
304
- return _ExecutionTimer().get_execution_timings(flatten=flatten)
307
+ return _TIMER.get_execution_timings(flatten=flatten)
305
308
 
306
309
 
307
310
  def _build_payload(*, flatten: bool = True) -> TimingsPayload:
308
311
  """Build a JSON-serializable snapshot of all timings, including totals and per-category sums."""
309
- timer = _ExecutionTimer()
310
- timings = timer.get_execution_timings(flatten=flatten)
312
+ snapshot = _TIMER.snapshot()
313
+ timings = _flatten(snapshot) if flatten else snapshot
311
314
  sections: list[SectionRecord] = [
312
315
  {
313
316
  "name": key[-1],
314
317
  "path": list(key),
315
- "time": round(timings[key]["time"], 6),
318
+ "time": round(timings[key]["elapsed_time"], 6),
316
319
  "category": timings[key]["category"],
317
320
  }
318
321
  for key in _ordered_by_hierarchy(timings)
319
322
  ]
320
- categories = sorted({info["category"] for info in timings.values()})
323
+ category_totals: dict[str, float] = {}
324
+ for key, info in snapshot.items():
325
+ category = info["category"]
326
+ if not _has_ancestor_with_category(snapshot, key, category):
327
+ category_totals[category] = category_totals.get(category, 0.0) + info["elapsed_time"]
321
328
  return {
322
- "total_time": round(timer.compute_total_time(flatten=flatten), 6),
323
- "total_category_time": {cat: round(timer.compute_total_category_time(cat), 6) for cat in categories},
329
+ "total_time": round(sum((info["elapsed_time"] for key, info in snapshot.items() if len(key) == 1), 0.0), 6),
330
+ "total_category_time": {cat: round(category_totals[cat], 6) for cat in sorted(category_totals)},
324
331
  "sections": sections,
325
332
  }
326
333
 
@@ -339,24 +346,24 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
339
346
 
340
347
  def get_total_time(*, flatten: bool = True) -> float:
341
348
  """Get total elapsed seconds across all top-level sections."""
342
- return _ExecutionTimer().compute_total_time(flatten=flatten)
349
+ return _TIMER.compute_total_time(flatten=flatten)
343
350
 
344
351
 
345
352
  def get_total_category_time(category: str) -> float:
346
353
  """Get total elapsed seconds in a category, counting only top-most entries of that category."""
347
- return _ExecutionTimer().compute_total_category_time(category)
354
+ return _TIMER.compute_total_category_time(category)
348
355
 
349
356
 
350
357
  def clear_execution_timings() -> None:
351
358
  """Reset all recorded timings."""
352
- _ExecutionTimer().clear()
359
+ _TIMER.clear()
353
360
 
354
361
 
355
362
  def register_forbidden_nesting(outer: str, inner: str) -> None:
356
363
  """Forbid timing sections of category ``inner`` directly inside sections of category ``outer``."""
357
- _ExecutionTimer.forbidden_nesting.add((outer, inner))
364
+ _TIMER.register_forbidden_nesting(outer, inner)
358
365
 
359
366
 
360
367
  def clear_forbidden_nesting() -> None:
361
368
  """Remove all forbidden-nesting rules."""
362
- _ExecutionTimer.forbidden_nesting = set()
369
+ _TIMER.clear_forbidden_nesting()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: executiontimer
3
- Version: 0.1.0
3
+ Version: 0.1.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
@@ -145,6 +145,10 @@ get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
145
145
  get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
146
146
  ```
147
147
 
148
+ Flattening removes the final integer suffix (including negative counters). Other
149
+ bracketed names such as `array[index]` are preserved. If merged entries have different
150
+ categories, the category from the most recently entered section is used.
151
+
148
152
  ### Categories
149
153
 
150
154
  Categories are plain strings — use whatever fits your domain:
@@ -161,6 +165,10 @@ get_total_category_time("gpu")
161
165
  `get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
162
166
  section nested inside another `gpu` section is not double-counted.
163
167
 
168
+ Repeated calls to the same path accumulate time. If its category changes, the latest
169
+ category applies to that path's entire accumulated time. Use consistent categories per
170
+ path when you need separate category totals.
171
+
164
172
  You can also forbid a category from appearing inside another, which raises a `ValueError`
165
173
  as soon as the invalid nesting happens:
166
174
 
@@ -198,6 +206,9 @@ save_execution_timings_json("timings.json")
198
206
  }
199
207
  ```
200
208
 
209
+ Sections and totals come from one snapshot. Category totals use the original paths,
210
+ even when flattening merges sections with different categories.
211
+
201
212
  ### Concurrency
202
213
 
203
214
  The recorded timings live in one process-wide registry guarded by a lock. The *active
@@ -213,9 +224,38 @@ async def worker(n: int) -> None:
213
224
  await asyncio.gather(worker(0), worker(1))
214
225
  ```
215
226
 
216
- Recording the *same* section path from overlapping threads or tasks is not meaningful —
217
- the elapsed times would overlap and sum to more than the wall-clock duration. Give
218
- concurrent sections distinct names (or use `counter=`).
227
+ Overlapping calls to the same section path are supported: each call keeps its own start
228
+ time, and their durations are added together. These totals measure accumulated elapsed
229
+ time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
230
+ concurrent calls separately.
231
+
232
+ New asyncio tasks inherit the timing context in which they are created. Their sections
233
+ nest under that parent; changes to each task's active stack remain independent. Await
234
+ child tasks inside the parent section if you want the parent duration to include them.
235
+
236
+ ### Reusing contexts and clearing timings
237
+
238
+ A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
239
+ For a tight loop, reuse a context to avoid constructing one on every iteration:
240
+
241
+ ```python
242
+ step_timer = TimerContext("step")
243
+ for item in items:
244
+ with step_timer:
245
+ process(item)
246
+ ```
247
+
248
+ Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
249
+ samples from sections that were already active, without disturbing their nesting stack.
250
+ Sections started after the clear are recorded normally. Reports include completed calls;
251
+ an active section's current duration is added only when it exits.
252
+
253
+ ### Measuring overhead
254
+
255
+ Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
256
+ fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
257
+ results using the same interpreter and machine; see [benchmarks/README.md](benchmarks/README.md).
258
+ `log_execution_times()` skips building a report when its logger has `INFO` disabled.
219
259
 
220
260
  ## API
221
261
 
@@ -0,0 +1,7 @@
1
+ execution_timer/__init__.py,sha256=WC6Cn6-mGTEHh1386OJ--hrAFaa9Z6eSBFze21brEfw,959
2
+ execution_timer/_timer.py,sha256=RbLD7HshcDBlcz0II7JiLZduuNr6Dao0D_d0gs9cuww,14526
3
+ execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ executiontimer-0.1.1.dist-info/METADATA,sha256=sAJT1E9gFJHlfd2oVwAwcrWYebEhq3xhgmjMIvv3NS4,11374
5
+ executiontimer-0.1.1.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
6
+ executiontimer-0.1.1.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
7
+ executiontimer-0.1.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.0
2
+ Generator: hatchling 1.32.3
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,7 +0,0 @@
1
- execution_timer/__init__.py,sha256=6DKOLTSXY0gu_DjW_rIqBRZROUYnlp8haVYvLX6NzTQ,959
2
- execution_timer/_timer.py,sha256=USfPpUdpW3jk1ffjIcTdOmB-FK-8HQHgcvZ44mJQ2f4,14504
3
- execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- executiontimer-0.1.0.dist-info/METADATA,sha256=1ftfFmuikQRb6w1mmfZAUVdezEUUgyweDkxE8ljTLNY,9412
5
- executiontimer-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
6
- executiontimer-0.1.0.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
7
- executiontimer-0.1.0.dist-info/RECORD,,