executiontimer 0.1.0__tar.gz → 0.1.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.
@@ -0,0 +1,63 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.1] - 2026-09-20
11
+
12
+ ### Fixed
13
+
14
+ - Overlapping calls to the same section now accumulate each call's actual duration in
15
+ threads and asyncio tasks, including calls sharing one context or decorator.
16
+ - Clearing active timings cannot add a discarded sample to a new entry at the same path.
17
+ - JSON sections and totals now use one consistent snapshot, and category totals retain
18
+ categories that disappear when counter variants are merged.
19
+ - Flattened categories follow the most recently entered section, including revisited counters.
20
+ - Flattening preserves non-integer bracket suffixes such as `array[index]` and `empty[]`.
21
+ - An out-of-order context exit raises `RuntimeError` without changing another section's
22
+ elapsed time or active stack.
23
+
24
+ ### Performance
25
+
26
+ - Cache each active section's path and parent, and reuse decorator contexts to reduce
27
+ recording allocations and avoid rebuilding paths on exit.
28
+ - Sum total time directly without copying and flattening the registry.
29
+ - Calculate all JSON category totals in one pass over a shared snapshot.
30
+ - Skip report generation when logging at `INFO` is disabled.
31
+
32
+ ### Added
33
+
34
+ - Deterministic regression tests for concurrency, clearing, category attribution, and
35
+ snapshot consistency, plus coverage for recursion, cancellation, and deep nesting.
36
+ - A repeatable benchmark for recording and reporting overhead in `benchmarks/overhead.py`.
37
+ - Expanded the suite from 53 to 88 tests, achieving 100% statement and branch coverage.
38
+
39
+ ## [0.1.0] - 2026-08-20
40
+
41
+ First public release on PyPI.
42
+
43
+ ### Added
44
+
45
+ - `TimerContext` — context manager and decorator for timing a named section of code, with
46
+ optional `category` and `counter` arguments.
47
+ - Automatic hierarchical nesting: section names reflect the enclosing timing contexts.
48
+ - Native `async def` support — decorating a coroutine function times the whole `await`
49
+ rather than the creation of the coroutine object.
50
+ - Per-task and per-thread context isolation via `contextvars`, so concurrently recorded
51
+ sections nest independently and merge into one process-wide report.
52
+ - Reporting and export helpers: `get_execution_times_report`, `log_execution_times`,
53
+ `get_execution_timings`, `get_execution_times_json`, `save_execution_timings_json`,
54
+ `get_total_time`, `get_total_category_time`.
55
+ - Optional nesting rules via `register_forbidden_nesting` / `clear_forbidden_nesting`.
56
+ - `clear_execution_timings` to reset the registry.
57
+ - Exported `TimingReport`, `SectionRecord` and `TimingsPayload` typed dictionaries, plus a
58
+ `py.typed` marker so type checkers use the inline annotations.
59
+ - `__version__` attribute on the package.
60
+
61
+ [Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...HEAD
62
+ [0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
63
+ [0.1.0]: https://github.com/seba2390/ExecutionTimer/releases/tag/v0.1.0
@@ -0,0 +1,77 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time to contribute.
4
+
5
+ ## Getting set up
6
+
7
+ This project uses [uv](https://docs.astral.sh/uv/) for dependency management.
8
+
9
+ ```bash
10
+ git clone https://github.com/seba2390/ExecutionTimer.git
11
+ cd ExecutionTimer
12
+ uv sync
13
+ ```
14
+
15
+ ## Before opening a pull request
16
+
17
+ Run the same checks CI runs:
18
+
19
+ ```bash
20
+ uv run pytest --cov
21
+ uv run ruff check --fix
22
+ uv run ruff format
23
+ uv run basedpyright
24
+ ```
25
+
26
+ All four must pass. Coverage is enforced at 95%; the current suite has 100% statement
27
+ and branch coverage. Preserve coverage when adding or changing behavior.
28
+
29
+ The test suite is also run against Python 3.12, 3.13 and 3.14 on Linux, macOS and Windows.
30
+ To check another interpreter locally:
31
+
32
+ ```bash
33
+ uv run --python 3.12 pytest
34
+ ```
35
+
36
+ For changes to recording or reporting performance, compare the benchmark on the same
37
+ machine and Python version, without coverage instrumentation:
38
+
39
+ ```bash
40
+ uv run python benchmarks/overhead.py --number 100000 --repeat 9
41
+ ```
42
+
43
+ See [benchmarks/README.md](benchmarks/README.md) for methodology and reference results.
44
+ Timing results are advisory rather than CI pass/fail thresholds.
45
+
46
+ ## Guidelines
47
+
48
+ - Tests exercise the **public API** only — import from `execution_timer`, not
49
+ `execution_timer._timer`. This keeps internals free to change.
50
+ - Every behaviour change needs a test that fails before the fix and passes after it.
51
+ - Public functions carry type annotations and a one-line docstring.
52
+ - Add an entry under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md).
53
+
54
+ ## Releasing
55
+
56
+ Maintainers only:
57
+
58
+ 1. Move the `## [Unreleased]` entries into a new version section in `CHANGELOG.md`, and
59
+ update the link definitions at the bottom.
60
+ 2. Bump `__version__` in `src/execution_timer/__init__.py`, then run `uv lock --check`
61
+ to verify the lockfile. Package metadata reads the version from `__version__`.
62
+ 3. Run the checks above, build with `uv build`, and validate metadata with
63
+ `uvx twine check --strict dist/*`. Use a clean output directory so old versions are
64
+ not included in release artifacts.
65
+ 4. Commit, then push to `main` and wait for CI to pass.
66
+ 5. Publish a GitHub release tagged `vX.Y.Z`, targeting the validated commit on `main`.
67
+ Use that version's changelog entries as release notes.
68
+
69
+ The [Publish to PyPI workflow](.github/workflows/publish.yml) runs when a GitHub release
70
+ is **published**. Pushing to `main`, pushing a tag alone, or saving a draft release does
71
+ not trigger it. The workflow builds and validates the distributions, checks that the tag
72
+ matches `__version__`, and uploads them to PyPI via Trusted Publishing. No manual
73
+ `twine upload` or PyPI API token is needed. If the `pypi` GitHub environment requires
74
+ approval, approve the publishing job there.
75
+
76
+ After publishing the release, check that the workflow succeeds and the new version
77
+ appears on [PyPI](https://pypi.org/project/executiontimer/).
@@ -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
 
@@ -116,6 +116,10 @@ get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
116
116
  get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
117
117
  ```
118
118
 
119
+ Flattening removes the final integer suffix (including negative counters). Other
120
+ bracketed names such as `array[index]` are preserved. If merged entries have different
121
+ categories, the category from the most recently entered section is used.
122
+
119
123
  ### Categories
120
124
 
121
125
  Categories are plain strings — use whatever fits your domain:
@@ -132,6 +136,10 @@ get_total_category_time("gpu")
132
136
  `get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
133
137
  section nested inside another `gpu` section is not double-counted.
134
138
 
139
+ Repeated calls to the same path accumulate time. If its category changes, the latest
140
+ category applies to that path's entire accumulated time. Use consistent categories per
141
+ path when you need separate category totals.
142
+
135
143
  You can also forbid a category from appearing inside another, which raises a `ValueError`
136
144
  as soon as the invalid nesting happens:
137
145
 
@@ -169,6 +177,9 @@ save_execution_timings_json("timings.json")
169
177
  }
170
178
  ```
171
179
 
180
+ Sections and totals come from one snapshot. Category totals use the original paths,
181
+ even when flattening merges sections with different categories.
182
+
172
183
  ### Concurrency
173
184
 
174
185
  The recorded timings live in one process-wide registry guarded by a lock. The *active
@@ -184,9 +195,38 @@ async def worker(n: int) -> None:
184
195
  await asyncio.gather(worker(0), worker(1))
185
196
  ```
186
197
 
187
- Recording the *same* section path from overlapping threads or tasks is not meaningful —
188
- the elapsed times would overlap and sum to more than the wall-clock duration. Give
189
- concurrent sections distinct names (or use `counter=`).
198
+ Overlapping calls to the same section path are supported: each call keeps its own start
199
+ time, and their durations are added together. These totals measure accumulated elapsed
200
+ time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
201
+ concurrent calls separately.
202
+
203
+ New asyncio tasks inherit the timing context in which they are created. Their sections
204
+ nest under that parent; changes to each task's active stack remain independent. Await
205
+ child tasks inside the parent section if you want the parent duration to include them.
206
+
207
+ ### Reusing contexts and clearing timings
208
+
209
+ A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
210
+ For a tight loop, reuse a context to avoid constructing one on every iteration:
211
+
212
+ ```python
213
+ step_timer = TimerContext("step")
214
+ for item in items:
215
+ with step_timer:
216
+ process(item)
217
+ ```
218
+
219
+ Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
220
+ samples from sections that were already active, without disturbing their nesting stack.
221
+ Sections started after the clear are recorded normally. Reports include completed calls;
222
+ an active section's current duration is added only when it exits.
223
+
224
+ ### Measuring overhead
225
+
226
+ Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
227
+ fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
228
+ results using the same interpreter and machine; see [benchmarks/README.md](benchmarks/README.md).
229
+ `log_execution_times()` skips building a report when its logger has `INFO` disabled.
190
230
 
191
231
  ## API
192
232
 
@@ -0,0 +1,40 @@
1
+ # Overhead benchmarks
2
+
3
+ From a repository checkout, run:
4
+
5
+ ```bash
6
+ uv run python benchmarks/overhead.py --number 100000 --repeat 9
7
+ ```
8
+
9
+ The script prints JSON with the Python version, platform, and median nanoseconds per
10
+ operation. Recording cases run `--number` operations per repeat. Reporting cases run
11
+ `max(1, number // 1000)` operations against 1,000 recorded counter variants with ten
12
+ categories. Async calls run in one event loop, so loop startup is excluded.
13
+
14
+ Compare the same interpreter and machine under similar load, without coverage or a
15
+ profiler enabled. These are microbenchmarks, not CI timing thresholds. The no-op bodies
16
+ make instrumentation costs visible; application speedups depend on the work being timed.
17
+
18
+ ## Local comparison
19
+
20
+ Measured on macOS 26.6.2, ARM64, CPython 3.14.5, using 100,000 operations and nine repeats.
21
+ The baseline is commit `008494c` (version 0.1.0); the updated column uses version 0.1.1.
22
+ Both versions ran the same script. Values below are microseconds per operation and
23
+ include the benchmark function call; plain calls cost approximately 0.017 µs and plain
24
+ awaits 0.056 µs in both runs.
25
+
26
+ | Operation | Baseline (µs) | Updated (µs) | Reduction |
27
+ | --- | ---: | ---: | ---: |
28
+ | New context | 1.550 | 1.028 | 34% |
29
+ | Reused context | 1.425 | 0.947 | 34% |
30
+ | Decorated call | 1.604 | 1.011 | 37% |
31
+ | Decorated await | 1.696 | 1.093 | 36% |
32
+ | Five nested contexts | 7.792 | 5.116 | 34% |
33
+ | Total time, 1,000 sections | 524.809 | 43.068 | 92% |
34
+ | JSON, 1,000 sections | 1,177.635 | 1,005.600 | 15% |
35
+ | Disabled logging, 1,000 sections | 520.167 | 0.099 | >99.9% |
36
+
37
+ Recording keeps each invocation's start time and cached path in a context-local frame.
38
+ Decorators reuse their context instead of constructing one per call. Total-time queries
39
+ avoid copying and flattening entries, JSON exports share one snapshot, and disabled
40
+ logging returns before building a report.
@@ -0,0 +1,120 @@
1
+ """Measure recording and reporting costs; run with ``uv run python benchmarks/overhead.py``.
2
+
3
+ Use the same interpreter, machine, iteration count, and repeat count for comparisons.
4
+ Results are medians in nanoseconds per operation; they are not CI pass/fail thresholds.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import platform
14
+ import statistics
15
+ import time
16
+ import timeit
17
+ from collections.abc import Callable
18
+
19
+ from execution_timer import (
20
+ TimerContext,
21
+ clear_execution_timings,
22
+ get_execution_times_json,
23
+ get_total_time,
24
+ log_execution_times,
25
+ )
26
+
27
+
28
+ def main() -> None:
29
+ parser = argparse.ArgumentParser(description=__doc__)
30
+ _ = parser.add_argument("--number", type=int, default=100_000)
31
+ _ = parser.add_argument("--repeat", type=int, default=7)
32
+ args = parser.parse_args()
33
+ number = int(args.number)
34
+ repeat = int(args.repeat)
35
+ if number < 1 or repeat < 1:
36
+ parser.error("--number and --repeat must be positive")
37
+
38
+ results: dict[str, float] = {}
39
+
40
+ def measure(name: str, operation: Callable[[], object], count: int = number) -> None:
41
+ clear_execution_timings()
42
+ results[name] = statistics.median(timeit.repeat(operation, number=count, repeat=repeat)) * 1e9 / count
43
+
44
+ def plain() -> None:
45
+ pass
46
+
47
+ def context() -> None:
48
+ with TimerContext("section"):
49
+ pass
50
+
51
+ shared = TimerContext("section")
52
+
53
+ def reused_context() -> None:
54
+ with shared:
55
+ pass
56
+
57
+ @TimerContext("section")
58
+ def decorated() -> None:
59
+ pass
60
+
61
+ def nested() -> None:
62
+ with shared, shared, shared, shared, shared:
63
+ pass
64
+
65
+ measure("plain_call", plain)
66
+ measure("new_context", context)
67
+ measure("reused_context", reused_context)
68
+ measure("decorated_call", decorated)
69
+ measure("five_nested_contexts", nested)
70
+
71
+ async def plain_async() -> None:
72
+ pass
73
+
74
+ decorated_async = TimerContext("async_section")(plain_async)
75
+
76
+ async def measure_async() -> None:
77
+ for name, function in (("plain_await", plain_async), ("decorated_await", decorated_async)):
78
+ samples: list[float] = []
79
+ for _ in range(repeat):
80
+ clear_execution_timings()
81
+ started = time.perf_counter()
82
+ for _ in range(number):
83
+ await function()
84
+ samples.append((time.perf_counter() - started) * 1e9 / number)
85
+ results[name] = statistics.median(samples)
86
+
87
+ asyncio.run(measure_async())
88
+
89
+ clear_execution_timings()
90
+ for index in range(1_000):
91
+ with TimerContext("section", category=f"category{index % 10}", counter=index):
92
+ pass
93
+ quiet_logger = logging.getLogger("executiontimer.benchmark")
94
+ quiet_logger.setLevel(logging.WARNING)
95
+ reporting_count = max(1, number // 1_000)
96
+ for name, operation in (
97
+ ("total_1000_sections", get_total_time),
98
+ ("json_1000_sections", get_execution_times_json),
99
+ ("disabled_logging_1000_sections", lambda: log_execution_times(logger=quiet_logger)),
100
+ ):
101
+ results[name] = (
102
+ statistics.median(timeit.repeat(operation, number=reporting_count, repeat=repeat)) * 1e9 / reporting_count
103
+ )
104
+
105
+ print(
106
+ json.dumps(
107
+ {
108
+ "python": platform.python_version(),
109
+ "platform": platform.platform(),
110
+ "number": number,
111
+ "repeat": repeat,
112
+ "nanoseconds_per_operation": {name: round(value, 1) for name, value in results.items()},
113
+ },
114
+ indent=2,
115
+ )
116
+ )
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
@@ -52,7 +52,16 @@ path = "src/execution_timer/__init__.py"
52
52
  packages = ["src/execution_timer"]
53
53
 
54
54
  [tool.hatch.build.targets.sdist]
55
- include = ["/src", "/tests", "/README.md", "/LICENSE", "/CHANGELOG.md", "/pyproject.toml"]
55
+ include = [
56
+ "/src",
57
+ "/tests",
58
+ "/benchmarks",
59
+ "/README.md",
60
+ "/LICENSE",
61
+ "/CHANGELOG.md",
62
+ "/CONTRIBUTING.md",
63
+ "/pyproject.toml",
64
+ ]
56
65
 
57
66
  [dependency-groups]
58
67
  dev = [
@@ -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",
@@ -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,11 +1,15 @@
1
1
  """Tests for the execution timer, using only the public API."""
2
2
 
3
3
  import asyncio
4
+ import builtins
4
5
  import inspect
5
6
  import json
6
7
  import logging
8
+ import threading
7
9
  import time
8
10
  from collections.abc import Iterator
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from contextlib import ExitStack
9
13
  from pathlib import Path
10
14
  from typing import cast
11
15
  from unittest.mock import patch
@@ -543,7 +547,11 @@ class TestAsyncDecorator:
543
547
 
544
548
  asyncio.run(work())
545
549
 
546
- assert raw_timings()["async_work",]["time"] >= 0.04
550
+ # Timing only the coroutine object's creation records ~1e-06 s, so any threshold
551
+ # orders of magnitude above that proves the await was covered. Kept well below the
552
+ # sleep duration because Windows' ~15.6 ms timer granularity lets short sleeps
553
+ # return early -- a threshold near 0.05 makes this test flaky rather than stricter.
554
+ assert raw_timings()["async_work",]["time"] >= 0.01
547
555
 
548
556
  def test_async_decorator_preserves_return_value(self) -> None:
549
557
  @TimerContext("async_compute")
@@ -686,3 +694,301 @@ class TestRobustness:
686
694
  assert get_execution_times_report() == ""
687
695
 
688
696
  assert [record.name for record in caplog.records] == ["execution_timer._timer"]
697
+
698
+
699
+ class TestRegressions:
700
+ @pytest.mark.parametrize("clear_between_calls", [False, True])
701
+ def test_overlapping_tasks_keep_independent_start_times(self, clear_between_calls: bool) -> None:
702
+ now = 0.0
703
+ shared = TimerContext("shared")
704
+
705
+ async def main() -> None:
706
+ nonlocal now
707
+ started = [asyncio.Event(), asyncio.Event()]
708
+ release = [asyncio.Event(), asyncio.Event()]
709
+
710
+ async def worker(index: int) -> None:
711
+ with shared:
712
+ started[index].set()
713
+ _ = await release[index].wait()
714
+
715
+ first = asyncio.create_task(worker(0))
716
+ _ = await started[0].wait()
717
+ if clear_between_calls:
718
+ clear_execution_timings()
719
+ now = 2.0
720
+ second = asyncio.create_task(worker(1))
721
+ _ = await started[1].wait()
722
+ now = 5.0
723
+ release[0].set()
724
+ await first
725
+ now = 9.0
726
+ release[1].set()
727
+ await second
728
+
729
+ with patch.object(time, "perf_counter", side_effect=lambda: now):
730
+ asyncio.run(main())
731
+
732
+ assert raw_timings() == {("shared",): {"time": 7.0 if clear_between_calls else 12.0, "category": "default"}}
733
+
734
+ def test_overlapping_thread_decorator_calls_accumulate_each_duration(self) -> None:
735
+ now = 0.0
736
+ started = [threading.Event(), threading.Event()]
737
+ release = [threading.Event(), threading.Event()]
738
+
739
+ @TimerContext("shared")
740
+ def worker(index: int) -> None:
741
+ started[index].set()
742
+ assert release[index].wait(timeout=5)
743
+
744
+ with patch.object(time, "perf_counter", side_effect=lambda: now), ThreadPoolExecutor(max_workers=2) as pool:
745
+ first = pool.submit(worker, 0)
746
+ try:
747
+ assert started[0].wait(timeout=5)
748
+ now = 2.0
749
+ second = pool.submit(worker, 1)
750
+ assert started[1].wait(timeout=5)
751
+ now = 5.0
752
+ release[0].set()
753
+ first.result(timeout=5)
754
+ now = 9.0
755
+ release[1].set()
756
+ second.result(timeout=5)
757
+ finally:
758
+ for event in release:
759
+ event.set()
760
+
761
+ assert raw_timings()["shared",]["time"] == 12.0
762
+
763
+ def test_flatten_category_follows_latest_revisit_even_with_equal_clock_readings(self) -> None:
764
+ with patch.object(time, "perf_counter", return_value=0.0):
765
+ for counter, category in [(0, "gpu"), (1, "cpu"), (0, "io")]:
766
+ with TimerContext("step", counter=counter, category=category):
767
+ pass
768
+
769
+ assert get_execution_timings()["step",]["category"] == "io"
770
+
771
+ @pytest.mark.parametrize("name", ["array[index]", "empty[]", "label[+1]", "label[1.5]", "label[\uff11\uff12]"])
772
+ def test_flatten_preserves_non_counter_brackets(self, name: str) -> None:
773
+ with TimerContext(name):
774
+ pass
775
+ assert list(get_execution_timings()) == [(name,)]
776
+
777
+ @pytest.mark.parametrize("counter", [-12, 0, 12])
778
+ def test_signed_counters_flatten_only_the_last_suffix(self, counter: int) -> None:
779
+ with TimerContext("array[index]", counter=counter):
780
+ pass
781
+ assert list(get_execution_timings()) == [("array[index]",)]
782
+
783
+ @pytest.mark.parametrize("flatten", [False, True])
784
+ def test_json_keeps_categories_hidden_by_flattening(self, flatten: bool) -> None:
785
+ with patch.object(time, "perf_counter", side_effect=[0, 2, 3, 6]):
786
+ with TimerContext("step", counter=0, category="gpu"):
787
+ pass
788
+ with TimerContext("step", counter=1, category="cpu"):
789
+ pass
790
+
791
+ payload = cast(TimingsPayload, json.loads(get_execution_times_json(flatten=flatten)))
792
+ assert payload["total_category_time"] == {"gpu": 2.0, "cpu": 3.0}
793
+ assert payload["total_time"] == 5.0
794
+
795
+ @pytest.mark.parametrize("flatten", [False, True])
796
+ def test_json_uses_one_snapshot_for_sections_and_totals(self, flatten: bool) -> None:
797
+ with patch.object(time, "perf_counter", side_effect=[0, 2]), TimerContext("section", category="cpu"):
798
+ pass
799
+
800
+ original_round = round
801
+
802
+ def clear_while_formatting(value: float, digits: int) -> float:
803
+ # Deterministically simulate another thread clearing the registry after the
804
+ # sections were read but before totals are formatted, through the public API.
805
+ clear_execution_timings()
806
+ return original_round(value, digits)
807
+
808
+ with patch.object(builtins, "round", side_effect=clear_while_formatting):
809
+ payload = cast(TimingsPayload, json.loads(get_execution_times_json(flatten=flatten)))
810
+
811
+ assert payload["sections"] == [{"name": "section", "path": ["section"], "time": 2.0, "category": "cpu"}]
812
+ assert payload["total_time"] == 2.0
813
+ assert payload["total_category_time"] == {"cpu": 2.0}
814
+
815
+ def test_disabled_logging_does_not_warn_about_empty_report(self, caplog: pytest.LogCaptureFixture) -> None:
816
+ logger = logging.getLogger("executiontimer.test.disabled")
817
+ with caplog.at_level(logging.WARNING), patch.object(logger, "isEnabledFor", return_value=False):
818
+ log_execution_times(logger=logger)
819
+ assert caplog.records == []
820
+
821
+
822
+ class TestLifecycle:
823
+ def test_reusing_one_context_accumulates_exact_durations(self) -> None:
824
+ context = TimerContext("reused")
825
+ with patch.object(time, "perf_counter", side_effect=[0, 2, 5, 8]):
826
+ with context:
827
+ pass
828
+ with context:
829
+ pass
830
+ assert raw_timings()["reused",]["time"] == 5.0
831
+
832
+ def test_one_context_can_be_reentered(self) -> None:
833
+ context = TimerContext("recursive")
834
+ with patch.object(time, "perf_counter", side_effect=[0, 1, 3, 7]), context, context:
835
+ pass
836
+ assert raw_timings() == {
837
+ ("recursive",): {"time": 7.0, "category": "default"},
838
+ ("recursive", "recursive"): {"time": 2.0, "category": "default"},
839
+ }
840
+
841
+ def test_recursive_decorator_preserves_arguments_and_hierarchy(self) -> None:
842
+ @TimerContext("recursive", category="cpu", counter=0)
843
+ def recurse(depth: int, *, value: int) -> int:
844
+ return recurse(depth - 1, value=value + 1) if depth else value
845
+
846
+ with patch.object(time, "perf_counter", side_effect=range(6)):
847
+ assert recurse(2, value=40) == 42
848
+ assert [info["time"] for info in raw_timings().values()] == [5.0, 3.0, 1.0]
849
+ assert list(raw_timings()) == [("recursive[0]",) * depth for depth in range(1, 4)]
850
+
851
+ def test_failed_entry_leaves_parent_and_its_next_child_intact(self) -> None:
852
+ register_forbidden_nesting("gpu", "cpu")
853
+ with TimerContext("parent", category="gpu"):
854
+ with pytest.raises(ValueError), TimerContext("forbidden", category="cpu"):
855
+ pytest.fail("A forbidden section must not be entered")
856
+ with TimerContext("allowed", category="io"):
857
+ pass
858
+ with TimerContext("after"):
859
+ pass
860
+ assert list(raw_timings()) == [("parent",), ("parent", "allowed"), ("after",)]
861
+
862
+ def test_nesting_rules_only_apply_to_the_direct_parent(self) -> None:
863
+ register_forbidden_nesting("gpu", "cpu")
864
+ with (
865
+ TimerContext("gpu", category="gpu"),
866
+ TimerContext("io", category="io"),
867
+ TimerContext("cpu", category="cpu"),
868
+ ):
869
+ pass
870
+ assert ("gpu", "io", "cpu") in raw_timings()
871
+
872
+ def test_out_of_order_exit_raises_without_changing_the_active_section(self) -> None:
873
+ outer = TimerContext("outer")
874
+ inner = TimerContext("inner")
875
+ with (
876
+ patch.object(time, "perf_counter", side_effect=[0, 1, 2, 3, 4]),
877
+ outer,
878
+ inner,
879
+ pytest.raises(RuntimeError, match="Cannot stop 'outer' while 'inner' is active"),
880
+ ):
881
+ outer.__exit__(None, None, None)
882
+ assert raw_timings()["outer",]["time"] == 4.0
883
+ assert raw_timings()["outer", "inner"]["time"] == 2.0
884
+
885
+ def test_exit_without_an_active_section_is_a_noop(self) -> None:
886
+ TimerContext("unused").__exit__(None, None, None)
887
+ assert raw_timings() == {}
888
+
889
+ def test_reports_preserve_orphaned_children_after_clear(self) -> None:
890
+ with patch.object(time, "perf_counter", side_effect=[0, 1, 3, 5]), TimerContext("parent"):
891
+ clear_execution_timings()
892
+ with TimerContext("child", category="cpu"):
893
+ pass
894
+ assert list(raw_timings()) == [("parent", "child")]
895
+ assert "child: 2.0000 s (0.00%)" in get_execution_times_report()
896
+ payload = cast(TimingsPayload, json.loads(get_execution_times_json()))
897
+ assert payload["total_time"] == 0.0
898
+ assert payload["total_category_time"] == {"cpu": 2.0}
899
+ assert payload["sections"][0]["path"] == ["parent", "child"]
900
+
901
+ def test_deep_reports_do_not_depend_on_python_recursion_limit(self) -> None:
902
+ with ExitStack() as stack:
903
+ for _ in range(1_100):
904
+ _ = stack.enter_context(TimerContext("level"))
905
+ assert len(get_execution_times_report(flatten=False).splitlines()) == 1_103
906
+
907
+ def test_async_cancellation_records_time_and_restores_parent(self) -> None:
908
+ @TimerContext("cancelled")
909
+ async def work(*, value: int) -> int:
910
+ assert value == 42
911
+ await asyncio.sleep(0)
912
+ raise asyncio.CancelledError
913
+
914
+ async def main() -> None:
915
+ with TimerContext("parent"):
916
+ with pytest.raises(asyncio.CancelledError):
917
+ _ = await work(value=42)
918
+ with TimerContext("after"):
919
+ pass
920
+
921
+ with patch.object(time, "perf_counter", side_effect=range(6)):
922
+ asyncio.run(main())
923
+ assert raw_timings()["parent", "cancelled"]["time"] == 1.0
924
+ assert list(raw_timings()) == [("parent",), ("parent", "cancelled"), ("parent", "after")]
925
+
926
+ def test_child_tasks_inherit_the_parent_without_modifying_its_context(self) -> None:
927
+ @TimerContext("child")
928
+ async def child(value: int, *, increment: int) -> int:
929
+ await asyncio.sleep(0)
930
+ return value + increment
931
+
932
+ async def main() -> None:
933
+ with TimerContext("parent"):
934
+ assert await asyncio.gather(child(1, increment=1), child(2, increment=1)) == [2, 3]
935
+ with TimerContext("after"):
936
+ pass
937
+
938
+ asyncio.run(main())
939
+ assert list(raw_timings()) == [("parent",), ("parent", "child"), ("parent", "after")]
940
+
941
+
942
+ class TestSnapshotSemantics:
943
+ @pytest.mark.parametrize("flatten", [False, True])
944
+ def test_mutating_a_returned_report_does_not_change_the_registry(self, flatten: bool) -> None:
945
+ with patch.object(time, "perf_counter", side_effect=[0, 2]), TimerContext("section"):
946
+ pass
947
+ report = get_execution_timings(flatten=flatten)
948
+ report["section",]["time"] = -1
949
+ report["section",]["category"] = "changed"
950
+ report.clear()
951
+ assert raw_timings() == {("section",): {"time": 2.0, "category": "default"}}
952
+
953
+ @pytest.mark.parametrize("flatten", [False, True])
954
+ def test_json_category_totals_exclude_matching_ancestors_across_other_categories(self, flatten: bool) -> None:
955
+ with (
956
+ patch.object(time, "perf_counter", side_effect=range(6)),
957
+ TimerContext("outer", category="cpu", counter=1),
958
+ TimerContext("middle", category="io"),
959
+ TimerContext("inner", category="cpu"),
960
+ ):
961
+ pass
962
+ payload = cast(TimingsPayload, json.loads(get_execution_times_json(flatten=flatten)))
963
+ assert payload["total_category_time"] == {"cpu": 5.0, "io": 3.0}
964
+ assert get_total_category_time("cpu") == 5.0
965
+ assert get_total_time(flatten=flatten) == 5.0
966
+
967
+ def test_empty_json(self) -> None:
968
+ assert json.loads(get_execution_times_json(indent=None)) == {
969
+ "total_time": 0.0,
970
+ "total_category_time": {},
971
+ "sections": [],
972
+ }
973
+
974
+ def test_unicode_json_file_round_trip(self, tmp_path: Path) -> None:
975
+ with TimerContext("计算", category="数据"):
976
+ pass
977
+ out = tmp_path / "timings.json"
978
+ assert save_execution_timings_json(str(out), indent=None) == out
979
+ text = out.read_text(encoding="utf-8")
980
+ assert text.endswith("\n")
981
+ assert len(text.splitlines()) == 1
982
+ payload = cast(TimingsPayload, json.loads(text))
983
+ assert payload["sections"][0]["name"] == "计算"
984
+ assert payload["sections"][0]["category"] == "数据"
985
+
986
+ def test_custom_logger_receives_the_requested_report(self, caplog: pytest.LogCaptureFixture) -> None:
987
+ with TimerContext("section", counter=0):
988
+ pass
989
+ logger = logging.getLogger("executiontimer.test.custom")
990
+ with caplog.at_level(logging.INFO, logger=logger.name):
991
+ log_execution_times(flatten=False, logger=logger)
992
+ assert [(record.name, record.message) for record in caplog.records] == [
993
+ (logger.name, get_execution_times_report(flatten=False))
994
+ ]
@@ -1,33 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project will be documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [Unreleased]
9
-
10
- ## [0.1.0] - 2026-08-20
11
-
12
- First public release on PyPI.
13
-
14
- ### Added
15
-
16
- - `TimerContext` — context manager and decorator for timing a named section of code, with
17
- optional `category` and `counter` arguments.
18
- - Automatic hierarchical nesting: section names reflect the enclosing timing contexts.
19
- - Native `async def` support — decorating a coroutine function times the whole `await`
20
- rather than the creation of the coroutine object.
21
- - Per-task and per-thread context isolation via `contextvars`, so concurrently recorded
22
- sections nest independently and merge into one process-wide report.
23
- - Reporting and export helpers: `get_execution_times_report`, `log_execution_times`,
24
- `get_execution_timings`, `get_execution_times_json`, `save_execution_timings_json`,
25
- `get_total_time`, `get_total_category_time`.
26
- - Optional nesting rules via `register_forbidden_nesting` / `clear_forbidden_nesting`.
27
- - `clear_execution_timings` to reset the registry.
28
- - Exported `TimingReport`, `SectionRecord` and `TimingsPayload` typed dictionaries, plus a
29
- `py.typed` marker so type checkers use the inline annotations.
30
- - `__version__` attribute on the package.
31
-
32
- [Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...HEAD
33
- [0.1.0]: https://github.com/seba2390/ExecutionTimer/releases/tag/v0.1.0
File without changes