executiontimer 0.1.0__py3-none-any.whl → 0.2.0__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.2.0"
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."""
105
+ """Registry of named, nestable timing sections, shared through ``_TIMER``."""
95
106
 
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()
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()
100
112
 
101
- def __new__(cls) -> _ExecutionTimer:
102
- if cls._instance is None:
103
- cls._instance = super().__new__(cls)
104
- return cls._instance
105
-
106
- @property
107
- def _full_name(self) -> tuple[str, ...]:
108
- return tuple(frame[0] for frame in _ACTIVE_CONTEXT.get())
109
-
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)
@@ -249,8 +247,16 @@ class TimerContext:
249
247
  """Decorate a function to time its execution under this context.
250
248
 
251
249
  Coroutine functions are wrapped so the timing spans the entire ``await``, not just
252
- creation of the coroutine object.
250
+ creation of the coroutine object. Generator functions are rejected: a wrapper would
251
+ time only creation of the generator object, not its iteration.
253
252
  """
253
+ if inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func):
254
+ msg = (
255
+ f"Cannot decorate generator function {func.__qualname__!r}: only creating the generator "
256
+ "would be timed. Time the loop that consumes it, or its body between yields, with a "
257
+ "'with TimerContext(...)' block instead."
258
+ )
259
+ raise TypeError(msg)
254
260
  if inspect.iscoroutinefunction(func):
255
261
  # ``iscoroutinefunction`` narrows nothing useful for the type checker, so bridge
256
262
  # through an explicitly typed helper instead of leaking ``Any`` into the signature.
@@ -259,7 +265,7 @@ class TimerContext:
259
265
 
260
266
  @functools.wraps(func)
261
267
  def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
262
- with TimerContext(self.name, self.category):
268
+ with self:
263
269
  return func(*args, **kwargs)
264
270
 
265
271
  return wrapper
@@ -269,7 +275,7 @@ class TimerContext:
269
275
 
270
276
  @functools.wraps(func)
271
277
  async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
272
- with TimerContext(self.name, self.category):
278
+ with self:
273
279
  return await func(*args, **kwargs)
274
280
 
275
281
  return async_wrapper
@@ -285,42 +291,54 @@ def _build_name_with_counter(name: str, counter: int | None = None) -> str:
285
291
  def _basic_name_without_counter(name: str) -> str:
286
292
  """Strip a trailing ``[counter]`` from a section name if present."""
287
293
  if "[" in name and name.endswith("]"):
288
- return name[: name.rfind("[")]
294
+ prefix, _, suffix = name.rpartition("[")
295
+ digits = suffix[:-1].removeprefix("-")
296
+ if digits.isascii() and digits.isdecimal():
297
+ return prefix
289
298
  return name
290
299
 
291
300
 
292
301
  def get_execution_times_report(*, flatten: bool = True) -> str:
293
302
  """Get a formatted report of all recorded sections; flatten counters if requested."""
294
- return _ExecutionTimer().report_timings(flatten=flatten)
303
+ return _TIMER.report_timings(flatten=flatten)
295
304
 
296
305
 
297
306
  def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
298
307
  """Log the execution-times report at INFO level; flatten counters if requested."""
299
- (logger or _LOGGER).info(get_execution_times_report(flatten=flatten))
308
+ target = logger if logger is not None else _LOGGER
309
+ if target.isEnabledFor(logging.INFO):
310
+ report = get_execution_times_report(flatten=flatten)
311
+ # An empty report has already been warned about; logging it would add a blank record.
312
+ if report:
313
+ target.info(report)
300
314
 
301
315
 
302
316
  def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
303
317
  """Get elapsed seconds and category for every recorded section; flatten counters if requested."""
304
- return _ExecutionTimer().get_execution_timings(flatten=flatten)
318
+ return _TIMER.get_execution_timings(flatten=flatten)
305
319
 
306
320
 
307
321
  def _build_payload(*, flatten: bool = True) -> TimingsPayload:
308
322
  """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)
323
+ snapshot = _TIMER.snapshot()
324
+ timings = _flatten(snapshot) if flatten else snapshot
311
325
  sections: list[SectionRecord] = [
312
326
  {
313
327
  "name": key[-1],
314
328
  "path": list(key),
315
- "time": round(timings[key]["time"], 6),
329
+ "time": round(timings[key]["elapsed_time"], 6),
316
330
  "category": timings[key]["category"],
317
331
  }
318
332
  for key in _ordered_by_hierarchy(timings)
319
333
  ]
320
- categories = sorted({info["category"] for info in timings.values()})
334
+ category_totals: dict[str, float] = {}
335
+ for key, info in snapshot.items():
336
+ category = info["category"]
337
+ if not _has_ancestor_with_category(snapshot, key, category):
338
+ category_totals[category] = category_totals.get(category, 0.0) + info["elapsed_time"]
321
339
  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},
340
+ "total_time": round(sum((info["elapsed_time"] for key, info in snapshot.items() if len(key) == 1), 0.0), 6),
341
+ "total_category_time": {cat: round(category_totals[cat], 6) for cat in sorted(category_totals)},
324
342
  "sections": sections,
325
343
  }
326
344
 
@@ -338,25 +356,29 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
338
356
 
339
357
 
340
358
  def get_total_time(*, flatten: bool = True) -> float:
341
- """Get total elapsed seconds across all top-level sections."""
342
- return _ExecutionTimer().compute_total_time(flatten=flatten)
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)
343
365
 
344
366
 
345
367
  def get_total_category_time(category: str) -> float:
346
368
  """Get total elapsed seconds in a category, counting only top-most entries of that category."""
347
- return _ExecutionTimer().compute_total_category_time(category)
369
+ return _TIMER.compute_total_category_time(category)
348
370
 
349
371
 
350
372
  def clear_execution_timings() -> None:
351
373
  """Reset all recorded timings."""
352
- _ExecutionTimer().clear()
374
+ _TIMER.clear()
353
375
 
354
376
 
355
377
  def register_forbidden_nesting(outer: str, inner: str) -> None:
356
378
  """Forbid timing sections of category ``inner`` directly inside sections of category ``outer``."""
357
- _ExecutionTimer.forbidden_nesting.add((outer, inner))
379
+ _TIMER.register_forbidden_nesting(outer, inner)
358
380
 
359
381
 
360
382
  def clear_forbidden_nesting() -> None:
361
383
  """Remove all forbidden-nesting rules."""
362
- _ExecutionTimer.forbidden_nesting = set()
384
+ _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.2.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
@@ -16,15 +16,17 @@ Classifier: Intended Audience :: Developers
16
16
  Classifier: Intended Audience :: Science/Research
17
17
  Classifier: Operating System :: OS Independent
18
18
  Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
19
20
  Classifier: Programming Language :: Python :: 3.12
20
21
  Classifier: Programming Language :: Python :: 3.13
21
22
  Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
22
24
  Classifier: Programming Language :: Python :: Implementation :: CPython
23
25
  Classifier: Topic :: Software Development
24
26
  Classifier: Topic :: Software Development :: Testing
25
27
  Classifier: Topic :: System :: Benchmark
26
28
  Classifier: Typing :: Typed
27
- Requires-Python: >=3.12
29
+ Requires-Python: >=3.11
28
30
  Description-Content-Type: text/markdown
29
31
 
30
32
  <p align="center">
@@ -55,7 +57,8 @@ Zero dependencies. Fully type annotated. Works with threads and `asyncio`.
55
57
  - 🌳 **Automatic hierarchy** — nesting `with` blocks nests the report, no wiring required
56
58
  - 🏷️ **User-defined categories** — tag sections with any string (`"gpu"`, `"io"`, `"db"`) and get per-category totals
57
59
  - ⚡ **Native async** — decorating an `async def` times the whole `await`, not the coroutine object
58
- - 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task
60
+ - 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task,
61
+ including on free-threaded Python builds
59
62
  - 🔢 **Loop counters** — time each iteration separately, then merge them back together
60
63
  - 📤 **JSON export** — structured output for dashboards, CI, or an LLM
61
64
  - 🚫 **Nesting rules** — optionally forbid one category inside another to catch mistakes early
@@ -71,7 +74,7 @@ pip install executiontimer
71
74
  uv add executiontimer
72
75
  ```
73
76
 
74
- Requires Python 3.12+.
77
+ Requires Python 3.11+.
75
78
 
76
79
  > **Note** — the install name is `executiontimer`, the import name is `execution_timer`:
77
80
  >
@@ -131,6 +134,10 @@ Coroutine functions are supported natively — the timing spans the entire `awai
131
134
  async def fetch(url: str) -> bytes: ...
132
135
  ```
133
136
 
137
+ Generator functions (including `async` generators) cannot be decorated and raise a
138
+ `TypeError`: the decorator would time only the creation of the generator object, not its
139
+ iteration. Time the loop that consumes the generator with a `with` block instead.
140
+
134
141
  ### Counters
135
142
 
136
143
  Pass `counter=i` to time loop iterations separately. The report merges them by default
@@ -145,6 +152,10 @@ get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
145
152
  get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
146
153
  ```
147
154
 
155
+ Flattening removes the final integer suffix (including negative counters). Other
156
+ bracketed names such as `array[index]` are preserved. If merged entries have different
157
+ categories, the category from the most recently entered section is used.
158
+
148
159
  ### Categories
149
160
 
150
161
  Categories are plain strings — use whatever fits your domain:
@@ -161,6 +172,10 @@ get_total_category_time("gpu")
161
172
  `get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
162
173
  section nested inside another `gpu` section is not double-counted.
163
174
 
175
+ Repeated calls to the same path accumulate time. If its category changes, the latest
176
+ category applies to that path's entire accumulated time. Use consistent categories per
177
+ path when you need separate category totals.
178
+
164
179
  You can also forbid a category from appearing inside another, which raises a `ValueError`
165
180
  as soon as the invalid nesting happens:
166
181
 
@@ -198,6 +213,9 @@ save_execution_timings_json("timings.json")
198
213
  }
199
214
  ```
200
215
 
216
+ Sections and totals come from one snapshot. Category totals use the original paths,
217
+ even when flattening merges sections with different categories.
218
+
201
219
  ### Concurrency
202
220
 
203
221
  The recorded timings live in one process-wide registry guarded by a lock. The *active
@@ -213,9 +231,50 @@ async def worker(n: int) -> None:
213
231
  await asyncio.gather(worker(0), worker(1))
214
232
  ```
215
233
 
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=`).
234
+ Overlapping calls to the same section path are supported: each call keeps its own start
235
+ time, and their durations are added together. These totals measure accumulated elapsed
236
+ time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
237
+ concurrent calls separately.
238
+
239
+ New asyncio tasks inherit the timing context in which they are created. Their sections
240
+ nest under that parent; changes to each task's active stack remain independent. Await
241
+ child tasks inside the parent section if you want the parent duration to include them.
242
+
243
+ New threads do *not* inherit the timing context, so sections recorded in a thread
244
+ appear at the top level of the report, and their time is added to the total alongside
245
+ the section that started the thread. To nest thread work under the current section, run
246
+ it with `contextvars.copy_context().run(...)` or `asyncio.to_thread(...)`. Either way,
247
+ concurrent threads accumulate overlapping time. (Free-threaded builds of Python 3.14 make
248
+ threads inherit the context by default.)
249
+
250
+ Generators run in their caller's context. A `with TimerContext(...)` block that stays open
251
+ across a `yield` therefore also contains whatever the caller times while the generator is
252
+ paused, and its duration includes that paused time. Close sections before yielding, or
253
+ time the loop that consumes the generator instead.
254
+
255
+ ### Reusing contexts and clearing timings
256
+
257
+ A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
258
+ For a tight loop, reuse a context to avoid constructing one on every iteration:
259
+
260
+ ```python
261
+ step_timer = TimerContext("step")
262
+ for item in items:
263
+ with step_timer:
264
+ process(item)
265
+ ```
266
+
267
+ Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
268
+ 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;
270
+ an active section's current duration is added only when it exits.
271
+
272
+ ### Measuring overhead
273
+
274
+ Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
275
+ 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).
277
+ `log_execution_times()` skips building a report when its logger has `INFO` disabled.
219
278
 
220
279
  ## API
221
280
 
@@ -227,7 +286,7 @@ concurrent sections distinct names (or use `counter=`).
227
286
  | `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
228
287
  | `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
229
288
  | `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
230
- | `get_total_time(*, flatten=True)` | Total seconds across all top-level sections. |
289
+ | `get_total_time(*, flatten=True)` | Total seconds across all top-level sections (`flatten` has no effect on the sum). |
231
290
  | `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
232
291
  | `clear_execution_timings()` | Reset all recorded timings. |
233
292
  | `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
@@ -0,0 +1,7 @@
1
+ execution_timer/__init__.py,sha256=9A5FYIrOvXbohuQMyIis651hYdNmzvNLQxfR02pk92w,959
2
+ execution_timer/_timer.py,sha256=4PSyP501arREnqOKtXEMwjbsb5sFISVYFRc1EXOXGP0,15366
3
+ execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ executiontimer-0.2.0.dist-info/METADATA,sha256=B6G1utu8t7BPwVh8KFID_pPr-c9z2bW0M6hd8-eiPWg,12631
5
+ executiontimer-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ executiontimer-0.2.0.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
7
+ executiontimer-0.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.0
2
+ Generator: hatchling 1.32.4
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,,