executiontimer 0.1.1__tar.gz → 1.0.0__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,136 @@
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
+ ## [1.0.0] - 2026-09-30
11
+
12
+ First stable release. The public API is now covered by semantic versioning: breaking
13
+ changes will wait for 2.0. Three changes below are breaking; they clean up the API before
14
+ it is frozen.
15
+
16
+ ### Changed
17
+
18
+ - **Breaking:** the report from `get_execution_times_report()` no longer starts with a
19
+ blank line, and its header reads `Total time:` rather than `Total calculation time:`.
20
+ `log_execution_times()` still starts the report on its own line.
21
+ - **Breaking:** `TimerContext.timer` is now private (`_timer`). It exposed the internal
22
+ registry, which is not part of the public API.
23
+ - `get_execution_times_report()` no longer logs a warning when there are no timings; it
24
+ returns `""`. `log_execution_times()` warns instead, on the logger it was given, so
25
+ building an empty report no longer prints to stderr when logging is not configured.
26
+ - The package is classified as `Development Status :: 5 - Production/Stable`.
27
+
28
+ ### Removed
29
+
30
+ - **Breaking:** the `flatten` parameter of `get_total_time()`, which had no effect.
31
+
32
+ ### Documentation
33
+
34
+ - README links to the changelog, contributing guide, benchmarks and license are absolute,
35
+ so they work on PyPI.
36
+ - Note that each `counter=` value is kept as a separate section until the timings are
37
+ cleared.
38
+
39
+ ### Fixed
40
+
41
+ - Exiting a section while an inner one is still active, typically because a paused
42
+ generator holds a section open, no longer raises `RuntimeError`. The raise replaced any
43
+ exception already propagating from the timed block and left the active stack corrupted
44
+ for the rest of the thread. The timer now emits a `RuntimeWarning`, records the exited
45
+ section, and discards the unfinished inner sections. Exiting a section that is no longer
46
+ active does nothing, so closing the paused generator later no longer raises either.
47
+
48
+ ### Security
49
+
50
+ - Workflows pin every action to a full commit SHA, with the release as a comment that
51
+ Dependabot keeps current, and CI installs dependencies with `uv sync --locked` so a
52
+ stale lockfile fails the build.
53
+
54
+ ## [0.2.0] - 2026-09-30
55
+
56
+ ### Changed
57
+
58
+ - Decorating a generator function or an async generator function now raises `TypeError`.
59
+ Previously it silently timed only the creation of the generator object, which recorded
60
+ microseconds regardless of how long iteration took.
61
+ - Python 3.11 is now supported; the minimum was 3.12 although nothing required it.
62
+
63
+ ### Fixed
64
+
65
+ - `log_execution_times()` no longer logs an empty `INFO` record, after its warning, when
66
+ there are no timings.
67
+
68
+ ### Documentation
69
+
70
+ - Explain that threads do not inherit the timing context, and how to nest thread work under
71
+ a section.
72
+ - Explain how a section held open across a generator's `yield` absorbs the caller's sections.
73
+ - Note that `flatten` has no effect on `get_total_time`.
74
+
75
+ ### Added
76
+
77
+ - CI tests Python 3.11 and free-threaded Python 3.14t, and the package declares
78
+ free-threading support.
79
+ - Workflows run with a read-only token by default and do not persist checkout credentials.
80
+
81
+ ## [0.1.1] - 2026-09-20
82
+
83
+ ### Fixed
84
+
85
+ - Overlapping calls to the same section now accumulate each call's actual duration in
86
+ threads and asyncio tasks, including calls sharing one context or decorator.
87
+ - Clearing active timings cannot add a discarded sample to a new entry at the same path.
88
+ - JSON sections and totals now use one consistent snapshot, and category totals retain
89
+ categories that disappear when counter variants are merged.
90
+ - Flattened categories follow the most recently entered section, including revisited counters.
91
+ - Flattening preserves non-integer bracket suffixes such as `array[index]` and `empty[]`.
92
+ - An out-of-order context exit raises `RuntimeError` without changing another section's
93
+ elapsed time or active stack.
94
+
95
+ ### Performance
96
+
97
+ - Cache each active section's path and parent, and reuse decorator contexts to reduce
98
+ recording allocations and avoid rebuilding paths on exit.
99
+ - Sum total time directly without copying and flattening the registry.
100
+ - Calculate all JSON category totals in one pass over a shared snapshot.
101
+ - Skip report generation when logging at `INFO` is disabled.
102
+
103
+ ### Added
104
+
105
+ - Deterministic regression tests for concurrency, clearing, category attribution, and
106
+ snapshot consistency, plus coverage for recursion, cancellation, and deep nesting.
107
+ - A repeatable benchmark for recording and reporting overhead in `benchmarks/overhead.py`.
108
+ - Expanded the suite from 53 to 88 tests, achieving 100% statement and branch coverage.
109
+
110
+ ## [0.1.0] - 2026-08-20
111
+
112
+ First public release on PyPI.
113
+
114
+ ### Added
115
+
116
+ - `TimerContext` — context manager and decorator for timing a named section of code, with
117
+ optional `category` and `counter` arguments.
118
+ - Automatic hierarchical nesting: section names reflect the enclosing timing contexts.
119
+ - Native `async def` support — decorating a coroutine function times the whole `await`
120
+ rather than the creation of the coroutine object.
121
+ - Per-task and per-thread context isolation via `contextvars`, so concurrently recorded
122
+ sections nest independently and merge into one process-wide report.
123
+ - Reporting and export helpers: `get_execution_times_report`, `log_execution_times`,
124
+ `get_execution_timings`, `get_execution_times_json`, `save_execution_timings_json`,
125
+ `get_total_time`, `get_total_category_time`.
126
+ - Optional nesting rules via `register_forbidden_nesting` / `clear_forbidden_nesting`.
127
+ - `clear_execution_timings` to reset the registry.
128
+ - Exported `TimingReport`, `SectionRecord` and `TimingsPayload` typed dictionaries, plus a
129
+ `py.typed` marker so type checkers use the inline annotations.
130
+ - `__version__` attribute on the package.
131
+
132
+ [Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.0...HEAD
133
+ [1.0.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.2.0...v1.0.0
134
+ [0.2.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...v0.2.0
135
+ [0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
136
+ [0.1.0]: https://github.com/seba2390/ExecutionTimer/releases/tag/v0.1.0
@@ -26,11 +26,12 @@ uv run basedpyright
26
26
  All four must pass. Coverage is enforced at 95%; the current suite has 100% statement
27
27
  and branch coverage. Preserve coverage when adding or changing behavior.
28
28
 
29
- The test suite is also run against Python 3.12, 3.13 and 3.14 on Linux, macOS and Windows.
29
+ The test suite is also run against Python 3.11, 3.12, 3.13, 3.14 and free-threaded 3.14t
30
+ on Linux, macOS and Windows.
30
31
  To check another interpreter locally:
31
32
 
32
33
  ```bash
33
- uv run --python 3.12 pytest
34
+ uv run --python 3.11 pytest
34
35
  ```
35
36
 
36
37
  For changes to recording or reporting performance, compare the benchmark on the same
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: executiontimer
3
- Version: 0.1.1
3
+ Version: 1.0.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
@@ -11,20 +11,22 @@ Author: Sebastian Yde Madsen
11
11
  License-Expression: MIT
12
12
  License-File: LICENSE
13
13
  Keywords: benchmark,context-manager,decorator,execution-time,instrumentation,performance,profiling,timing
14
- Classifier: Development Status :: 4 - Beta
14
+ Classifier: Development Status :: 5 - Production/Stable
15
15
  Classifier: Intended Audience :: Developers
16
16
  Classifier: Intended Audience :: Science/Research
17
17
  Classifier: Operating System :: OS Independent
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
  >
@@ -100,7 +103,7 @@ print(get_execution_times_report())
100
103
  ```
101
104
 
102
105
  ```text
103
- Total calculation time: 0.3156 s.
106
+ Total time: 0.3156 s.
104
107
 
105
108
  load_data: 0.1219 s (38.62%)
106
109
  solve: 0.1937 s (61.38%)
@@ -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
@@ -149,6 +156,11 @@ Flattening removes the final integer suffix (including negative counters). Other
149
156
  bracketed names such as `array[index]` are preserved. If merged entries have different
150
157
  categories, the category from the most recently entered section is used.
151
158
 
159
+ Each counter value is stored as its own section until `clear_execution_timings()` is
160
+ called, so a long-running process that times an unbounded loop with `counter=` keeps
161
+ growing the registry. Clear it periodically, or drop `counter=` to accumulate into one
162
+ section.
163
+
152
164
  ### Categories
153
165
 
154
166
  Categories are plain strings — use whatever fits your domain:
@@ -233,6 +245,23 @@ New asyncio tasks inherit the timing context in which they are created. Their se
233
245
  nest under that parent; changes to each task's active stack remain independent. Await
234
246
  child tasks inside the parent section if you want the parent duration to include them.
235
247
 
248
+ New threads do *not* inherit the timing context, so sections recorded in a thread
249
+ appear at the top level of the report, and their time is added to the total alongside
250
+ the section that started the thread. To nest thread work under the current section, run
251
+ it with `contextvars.copy_context().run(...)` or `asyncio.to_thread(...)`. Either way,
252
+ concurrent threads accumulate overlapping time. (Free-threaded builds of Python 3.14 make
253
+ threads inherit the context by default.)
254
+
255
+ Generators run in their caller's context. A `with TimerContext(...)` block that stays open
256
+ across a `yield` therefore also contains whatever the caller times while the generator is
257
+ paused, and its duration includes that paused time. Close sections before yielding, or
258
+ time the loop that consumes the generator instead.
259
+
260
+ If the caller's section exits while a paused generator's section is still open, the
261
+ timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
262
+ discards the generator's unfinished one, so later sections nest correctly. Closing that
263
+ generator afterwards does nothing.
264
+
236
265
  ### Reusing contexts and clearing timings
237
266
 
238
267
  A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
@@ -254,7 +283,7 @@ an active section's current duration is added only when it exits.
254
283
 
255
284
  Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
256
285
  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).
286
+ results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
258
287
  `log_execution_times()` skips building a report when its logger has `INFO` disabled.
259
288
 
260
289
  ## API
@@ -262,12 +291,12 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
262
291
  | Function | Description |
263
292
  | --- | --- |
264
293
  | `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
265
- | `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections. |
266
- | `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level. |
294
+ | `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
295
+ | `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
267
296
  | `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
268
297
  | `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
269
298
  | `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
270
- | `get_total_time(*, flatten=True)` | Total seconds across all top-level sections. |
299
+ | `get_total_time()` | Total seconds across all top-level sections. |
271
300
  | `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
272
301
  | `clear_execution_timings()` | Reset all recorded timings. |
273
302
  | `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
@@ -290,9 +319,9 @@ uv run ruff check --fix && uv run ruff format
290
319
  uv run basedpyright
291
320
  ```
292
321
 
293
- See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, and
294
- [CHANGELOG.md](CHANGELOG.md) for release notes.
322
+ See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
323
+ [CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
295
324
 
296
325
  ## License
297
326
 
298
- MIT — see [LICENSE](LICENSE).
327
+ MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
@@ -26,7 +26,8 @@ Zero dependencies. Fully type annotated. Works with threads and `asyncio`.
26
26
  - 🌳 **Automatic hierarchy** — nesting `with` blocks nests the report, no wiring required
27
27
  - 🏷️ **User-defined categories** — tag sections with any string (`"gpu"`, `"io"`, `"db"`) and get per-category totals
28
28
  - ⚡ **Native async** — decorating an `async def` times the whole `await`, not the coroutine object
29
- - 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task
29
+ - 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task,
30
+ including on free-threaded Python builds
30
31
  - 🔢 **Loop counters** — time each iteration separately, then merge them back together
31
32
  - 📤 **JSON export** — structured output for dashboards, CI, or an LLM
32
33
  - 🚫 **Nesting rules** — optionally forbid one category inside another to catch mistakes early
@@ -42,7 +43,7 @@ pip install executiontimer
42
43
  uv add executiontimer
43
44
  ```
44
45
 
45
- Requires Python 3.12+.
46
+ Requires Python 3.11+.
46
47
 
47
48
  > **Note** — the install name is `executiontimer`, the import name is `execution_timer`:
48
49
  >
@@ -71,7 +72,7 @@ print(get_execution_times_report())
71
72
  ```
72
73
 
73
74
  ```text
74
- Total calculation time: 0.3156 s.
75
+ Total time: 0.3156 s.
75
76
 
76
77
  load_data: 0.1219 s (38.62%)
77
78
  solve: 0.1937 s (61.38%)
@@ -102,6 +103,10 @@ Coroutine functions are supported natively — the timing spans the entire `awai
102
103
  async def fetch(url: str) -> bytes: ...
103
104
  ```
104
105
 
106
+ Generator functions (including `async` generators) cannot be decorated and raise a
107
+ `TypeError`: the decorator would time only the creation of the generator object, not its
108
+ iteration. Time the loop that consumes the generator with a `with` block instead.
109
+
105
110
  ### Counters
106
111
 
107
112
  Pass `counter=i` to time loop iterations separately. The report merges them by default
@@ -120,6 +125,11 @@ Flattening removes the final integer suffix (including negative counters). Other
120
125
  bracketed names such as `array[index]` are preserved. If merged entries have different
121
126
  categories, the category from the most recently entered section is used.
122
127
 
128
+ Each counter value is stored as its own section until `clear_execution_timings()` is
129
+ called, so a long-running process that times an unbounded loop with `counter=` keeps
130
+ growing the registry. Clear it periodically, or drop `counter=` to accumulate into one
131
+ section.
132
+
123
133
  ### Categories
124
134
 
125
135
  Categories are plain strings — use whatever fits your domain:
@@ -204,6 +214,23 @@ New asyncio tasks inherit the timing context in which they are created. Their se
204
214
  nest under that parent; changes to each task's active stack remain independent. Await
205
215
  child tasks inside the parent section if you want the parent duration to include them.
206
216
 
217
+ New threads do *not* inherit the timing context, so sections recorded in a thread
218
+ appear at the top level of the report, and their time is added to the total alongside
219
+ the section that started the thread. To nest thread work under the current section, run
220
+ it with `contextvars.copy_context().run(...)` or `asyncio.to_thread(...)`. Either way,
221
+ concurrent threads accumulate overlapping time. (Free-threaded builds of Python 3.14 make
222
+ threads inherit the context by default.)
223
+
224
+ Generators run in their caller's context. A `with TimerContext(...)` block that stays open
225
+ across a `yield` therefore also contains whatever the caller times while the generator is
226
+ paused, and its duration includes that paused time. Close sections before yielding, or
227
+ time the loop that consumes the generator instead.
228
+
229
+ If the caller's section exits while a paused generator's section is still open, the
230
+ timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
231
+ discards the generator's unfinished one, so later sections nest correctly. Closing that
232
+ generator afterwards does nothing.
233
+
207
234
  ### Reusing contexts and clearing timings
208
235
 
209
236
  A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
@@ -225,7 +252,7 @@ an active section's current duration is added only when it exits.
225
252
 
226
253
  Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
227
254
  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).
255
+ results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
229
256
  `log_execution_times()` skips building a report when its logger has `INFO` disabled.
230
257
 
231
258
  ## API
@@ -233,12 +260,12 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
233
260
  | Function | Description |
234
261
  | --- | --- |
235
262
  | `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
236
- | `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections. |
237
- | `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level. |
263
+ | `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
264
+ | `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
238
265
  | `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
239
266
  | `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
240
267
  | `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
241
- | `get_total_time(*, flatten=True)` | Total seconds across all top-level sections. |
268
+ | `get_total_time()` | Total seconds across all top-level sections. |
242
269
  | `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
243
270
  | `clear_execution_timings()` | Reset all recorded timings. |
244
271
  | `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
@@ -261,9 +288,9 @@ uv run ruff check --fix && uv run ruff format
261
288
  uv run basedpyright
262
289
  ```
263
290
 
264
- See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, and
265
- [CHANGELOG.md](CHANGELOG.md) for release notes.
291
+ See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
292
+ [CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
266
293
 
267
294
  ## License
268
295
 
269
- MIT — see [LICENSE](LICENSE).
296
+ MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
@@ -5,7 +5,7 @@ description = "Hierarchical execution timing with user-defined categories."
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
8
- requires-python = ">=3.12"
8
+ requires-python = ">=3.11"
9
9
  authors = [{ name = "Sebastian Yde Madsen" }]
10
10
  keywords = [
11
11
  "timing",
@@ -18,14 +18,16 @@ keywords = [
18
18
  "decorator",
19
19
  ]
20
20
  classifiers = [
21
- "Development Status :: 4 - Beta",
21
+ "Development Status :: 5 - Production/Stable",
22
22
  "Intended Audience :: Developers",
23
23
  "Intended Audience :: Science/Research",
24
24
  "Operating System :: OS Independent",
25
25
  "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.11",
26
27
  "Programming Language :: Python :: 3.12",
27
28
  "Programming Language :: Python :: 3.13",
28
29
  "Programming Language :: Python :: 3.14",
30
+ "Programming Language :: Python :: Free Threading :: 3 - Stable",
29
31
  "Programming Language :: Python :: Implementation :: CPython",
30
32
  "Topic :: Software Development",
31
33
  "Topic :: Software Development :: Testing",
@@ -85,7 +87,7 @@ fail_under = 95
85
87
  exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]
86
88
 
87
89
  [tool.ruff]
88
- target-version = "py312"
90
+ target-version = "py311"
89
91
  line-length = 120
90
92
  src = ["src", "tests"]
91
93
 
@@ -103,5 +105,5 @@ select = [
103
105
 
104
106
  [tool.basedpyright]
105
107
  typeCheckingMode = "recommended"
106
- pythonVersion = "3.12"
108
+ pythonVersion = "3.11"
107
109
  include = ["src", "tests"]
@@ -18,7 +18,7 @@ from execution_timer._timer import (
18
18
  save_execution_timings_json,
19
19
  )
20
20
 
21
- __version__ = "0.1.1"
21
+ __version__ = "1.0.0"
22
22
 
23
23
  __all__ = [
24
24
  "DEFAULT_CATEGORY",
@@ -14,6 +14,7 @@ import json
14
14
  import logging
15
15
  import threading
16
16
  import time
17
+ import warnings
17
18
  from collections.abc import Callable, Coroutine, Iterable, Iterator
18
19
  from contextvars import ContextVar
19
20
  from itertools import count
@@ -134,13 +135,27 @@ class _ExecutionTimer:
134
135
  _ = _ACTIVE_CONTEXT.set(_Frame(full_name, category, time.perf_counter(), entry, parent))
135
136
 
136
137
  def stop_timer(self, name: str) -> None:
137
- """Stop timing a section and accumulate its elapsed time."""
138
+ """Stop timing a section and accumulate its elapsed time.
139
+
140
+ Never raises: an exception here would replace one already propagating from the timed
141
+ block. Exiting past still-active inner sections (typically a suspended generator that
142
+ holds one open) discards them with a warning, so the stack cannot stay corrupted.
143
+ Exiting a section that is no longer active, such as one discarded that way when its
144
+ generator is finally closed, does nothing.
145
+ """
138
146
  end_time = time.perf_counter()
139
- frame = _ACTIVE_CONTEXT.get()
147
+ active = _ACTIVE_CONTEXT.get()
148
+ frame = active
149
+ while frame is not None and frame.path[-1] != name:
150
+ frame = frame.parent
140
151
  if frame is None:
141
152
  return
142
- if frame.path[-1] != name:
143
- raise RuntimeError(f"Cannot stop '{name}' while '{frame.path[-1]}' is active.")
153
+ if frame is not active and active is not None:
154
+ msg = (
155
+ f"Section '{name}' exited while '{active.path[-1]}' was still active; discarding the "
156
+ "unfinished inner sections. Close sections before a generator yields."
157
+ )
158
+ warnings.warn(msg, RuntimeWarning, stacklevel=3)
144
159
  with self._lock:
145
160
  # A clear detaches this entry from the registry. Updating the detached object
146
161
  # cannot resurrect an old sample or add it to a replacement at the same path.
@@ -155,21 +170,19 @@ class _ExecutionTimer:
155
170
  """Build a report of all sections with duration and percentage of total time."""
156
171
  timings = self._resolve(flatten=flatten)
157
172
  if not timings:
158
- _LOGGER.warning("No timings to report.")
159
173
  return ""
160
174
 
161
175
  total_time = sum(info["elapsed_time"] for key, info in timings.items() if len(key) == 1)
162
- report = [f"\nTotal calculation time: {total_time:.4f} s.\n"]
176
+ report = [f"Total time: {total_time:.4f} s.\n"]
163
177
  for key in _ordered_by_hierarchy(timings):
164
178
  elapsed_time = timings[key]["elapsed_time"]
165
179
  percentage = (elapsed_time / total_time) * 100 if total_time else 0.0
166
180
  report.append(f"{'.. ' * (len(key) - 1)}{key[-1]}: {elapsed_time:.4f} s ({percentage:.2f}%)")
167
181
  return "\n".join(report)
168
182
 
169
- def compute_total_time(self, *, flatten: bool = True) -> float:
183
+ def compute_total_time(self) -> float:
170
184
  """Compute total elapsed time across all top-level sections."""
171
- # Counter merging cannot change the sum. Avoid allocating and flattening a snapshot.
172
- _ = flatten
185
+ # Counter merging cannot change the sum, so there is no snapshot to copy or flatten.
173
186
  with self._lock:
174
187
  return sum((info["elapsed_time"] for key, info in self.timings.items() if len(key) == 1), 0.0)
175
188
 
@@ -232,23 +245,31 @@ class TimerContext:
232
245
  def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
233
246
  self.name: str = _build_name_with_counter(name, counter)
234
247
  self.category: str = category
235
- self.timer: _ExecutionTimer = _TIMER
248
+ self._timer: _ExecutionTimer = _TIMER
236
249
 
237
250
  def __enter__(self) -> TimerContext:
238
- self.timer.start_timer(self.name, self.category)
251
+ self._timer.start_timer(self.name, self.category)
239
252
  return self
240
253
 
241
254
  def __exit__(
242
255
  self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
243
256
  ) -> None:
244
- self.timer.stop_timer(self.name)
257
+ self._timer.stop_timer(self.name)
245
258
 
246
259
  def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
247
260
  """Decorate a function to time its execution under this context.
248
261
 
249
262
  Coroutine functions are wrapped so the timing spans the entire ``await``, not just
250
- creation of the coroutine object.
263
+ creation of the coroutine object. Generator functions are rejected: a wrapper would
264
+ time only creation of the generator object, not its iteration.
251
265
  """
266
+ if inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func):
267
+ msg = (
268
+ f"Cannot decorate generator function {func.__qualname__!r}: only creating the generator "
269
+ "would be timed. Time the loop that consumes it, or its body between yields, with a "
270
+ "'with TimerContext(...)' block instead."
271
+ )
272
+ raise TypeError(msg)
252
273
  if inspect.iscoroutinefunction(func):
253
274
  # ``iscoroutinefunction`` narrows nothing useful for the type checker, so bridge
254
275
  # through an explicitly typed helper instead of leaking ``Any`` into the signature.
@@ -291,15 +312,20 @@ def _basic_name_without_counter(name: str) -> str:
291
312
 
292
313
 
293
314
  def get_execution_times_report(*, flatten: bool = True) -> str:
294
- """Get a formatted report of all recorded sections; flatten counters if requested."""
315
+ """Get a formatted report of all recorded sections (``""`` if none); flatten counters if requested."""
295
316
  return _TIMER.report_timings(flatten=flatten)
296
317
 
297
318
 
298
319
  def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
299
- """Log the execution-times report at INFO level; flatten counters if requested."""
320
+ """Log the execution-times report at INFO level, or a warning if there is nothing to report."""
300
321
  target = logger if logger is not None else _LOGGER
301
322
  if target.isEnabledFor(logging.INFO):
302
- target.info(get_execution_times_report(flatten=flatten))
323
+ report = get_execution_times_report(flatten=flatten)
324
+ if report:
325
+ # Start the multi-line report on its own line, after the log record's prefix.
326
+ target.info("\n%s", report)
327
+ else:
328
+ target.warning("No timings to report.")
303
329
 
304
330
 
305
331
  def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
@@ -344,9 +370,9 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
344
370
  return out
345
371
 
346
372
 
347
- def get_total_time(*, flatten: bool = True) -> float:
373
+ def get_total_time() -> float:
348
374
  """Get total elapsed seconds across all top-level sections."""
349
- return _TIMER.compute_total_time(flatten=flatten)
375
+ return _TIMER.compute_total_time()
350
376
 
351
377
 
352
378
  def get_total_category_time(category: str) -> float:
@@ -7,7 +7,7 @@ import json
7
7
  import logging
8
8
  import threading
9
9
  import time
10
- from collections.abc import Iterator
10
+ from collections.abc import AsyncIterator, Generator, Iterator
11
11
  from concurrent.futures import ThreadPoolExecutor
12
12
  from contextlib import ExitStack
13
13
  from pathlib import Path
@@ -191,15 +191,17 @@ class TestReporting:
191
191
 
192
192
  report = get_execution_times_report()
193
193
 
194
- assert "Total calculation time" in report
194
+ assert report.startswith("Total time: ")
195
195
  assert "context_report:" in report
196
196
  assert "context_report_nested:" in report
197
197
  for i in range(3):
198
198
  assert f".. context_sub_{i}:" in report
199
199
  assert f".. .. context_subsub_{i}:" in report
200
200
 
201
- def test_report_empty_when_no_timings(self) -> None:
202
- assert get_execution_times_report() == ""
201
+ def test_report_empty_when_no_timings(self, caplog: pytest.LogCaptureFixture) -> None:
202
+ with caplog.at_level(logging.DEBUG):
203
+ assert get_execution_times_report() == ""
204
+ assert caplog.records == []
203
205
 
204
206
  def test_report_flatten_flag(self) -> None:
205
207
  with patch.object(time, "perf_counter", side_effect=[0, 1, 1, 2]):
@@ -271,7 +273,7 @@ class TestOutput:
271
273
  with caplog.at_level(logging.INFO):
272
274
  log_execution_times()
273
275
 
274
- assert "Total calculation time" in caplog.text
276
+ assert "Total time: " in caplog.text
275
277
  assert "logged:" in caplog.text
276
278
 
277
279
  def test_get_execution_times_json_is_valid_and_structured(self) -> None:
@@ -380,6 +382,32 @@ class TestTimerContextDecorator:
380
382
  assert ("outer",) in timings
381
383
  assert ("outer", "inner") in timings
382
384
 
385
+ def test_decorating_a_generator_function_raises(self) -> None:
386
+ def numbers() -> Generator[int]:
387
+ yield 1
388
+
389
+ with pytest.raises(TypeError, match=r"Cannot decorate generator function '.*numbers'"):
390
+ _ = TimerContext("gen")(numbers)
391
+ assert raw_timings() == {}
392
+
393
+ def test_decorating_an_async_generator_function_raises(self) -> None:
394
+ async def numbers() -> AsyncIterator[int]:
395
+ yield 1
396
+
397
+ with pytest.raises(TypeError, match=r"Cannot decorate generator function '.*numbers'"):
398
+ _ = TimerContext("agen")(numbers)
399
+ assert raw_timings() == {}
400
+
401
+ def test_decorator_accepts_a_function_returning_a_generator(self) -> None:
402
+ """Only generator *functions* are rejected; a plain function may return an iterator."""
403
+
404
+ @TimerContext("factory")
405
+ def factory() -> Iterator[int]:
406
+ return iter([1, 2])
407
+
408
+ assert list(factory()) == [1, 2]
409
+ assert ("factory",) in raw_timings()
410
+
383
411
 
384
412
  class TestExceptions:
385
413
  def test_timing_recorded_when_body_raises(self) -> None:
@@ -690,11 +718,19 @@ class TestRobustness:
690
718
  assert errors == []
691
719
 
692
720
  def test_logging_uses_the_package_logger_not_the_root_logger(self, caplog: pytest.LogCaptureFixture) -> None:
693
- with caplog.at_level(logging.WARNING):
694
- assert get_execution_times_report() == ""
721
+ with caplog.at_level(logging.INFO):
722
+ log_execution_times()
695
723
 
696
724
  assert [record.name for record in caplog.records] == ["execution_timer._timer"]
697
725
 
726
+ def test_logging_an_empty_registry_emits_only_the_warning(self, caplog: pytest.LogCaptureFixture) -> None:
727
+ with caplog.at_level(logging.INFO):
728
+ log_execution_times()
729
+
730
+ assert [(record.levelno, record.message) for record in caplog.records] == [
731
+ (logging.WARNING, "No timings to report.")
732
+ ]
733
+
698
734
 
699
735
  class TestRegressions:
700
736
  @pytest.mark.parametrize("clear_between_calls", [False, True])
@@ -869,18 +905,42 @@ class TestLifecycle:
869
905
  pass
870
906
  assert ("gpu", "io", "cpu") in raw_timings()
871
907
 
872
- def test_out_of_order_exit_raises_without_changing_the_active_section(self) -> None:
908
+ def test_out_of_order_exit_unwinds_to_the_exited_section_and_warns(self) -> None:
873
909
  outer = TimerContext("outer")
874
910
  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
911
+ with patch.object(time, "perf_counter", side_effect=[0, 1, 3]):
912
+ _ = outer.__enter__()
913
+ _ = inner.__enter__()
914
+ with pytest.warns(RuntimeWarning, match="'outer' exited while 'inner' was still active"):
915
+ outer.__exit__(None, None, None)
916
+ with TimerContext("after"):
917
+ pass
918
+ assert raw_timings()["outer",]["time"] == 3.0
919
+ assert raw_timings()["outer", "inner"]["time"] == 0.0
920
+ assert ("after",) in raw_timings()
921
+
922
+ def test_abandoned_generator_section_does_not_break_the_caller(self) -> None:
923
+ def numbers() -> Generator[int]:
924
+ with TimerContext("generator"):
925
+ yield 1
926
+ yield 2
927
+
928
+ paused = numbers()
929
+ with pytest.warns(RuntimeWarning), pytest.raises(KeyError, match="from the body"), TimerContext("caller"):
930
+ _ = next(paused)
931
+ raise KeyError("from the body")
932
+ # Closing the generator later exits a section that is no longer active: a no-op.
933
+ paused.close()
934
+ with TimerContext("after"):
935
+ pass
936
+ assert list(raw_timings()) == [("caller",), ("caller", "generator"), ("after",)]
937
+
938
+ def test_exiting_a_section_that_is_not_active_leaves_the_stack_intact(self) -> None:
939
+ with TimerContext("outer"):
940
+ TimerContext("stranger").__exit__(None, None, None)
941
+ with TimerContext("inner"):
942
+ pass
943
+ assert list(raw_timings()) == [("outer",), ("outer", "inner")]
884
944
 
885
945
  def test_exit_without_an_active_section_is_a_noop(self) -> None:
886
946
  TimerContext("unused").__exit__(None, None, None)
@@ -902,7 +962,7 @@ class TestLifecycle:
902
962
  with ExitStack() as stack:
903
963
  for _ in range(1_100):
904
964
  _ = stack.enter_context(TimerContext("level"))
905
- assert len(get_execution_times_report(flatten=False).splitlines()) == 1_103
965
+ assert len(get_execution_times_report(flatten=False).splitlines()) == 1_102
906
966
 
907
967
  def test_async_cancellation_records_time_and_restores_parent(self) -> None:
908
968
  @TimerContext("cancelled")
@@ -962,7 +1022,7 @@ class TestSnapshotSemantics:
962
1022
  payload = cast(TimingsPayload, json.loads(get_execution_times_json(flatten=flatten)))
963
1023
  assert payload["total_category_time"] == {"cpu": 5.0, "io": 3.0}
964
1024
  assert get_total_category_time("cpu") == 5.0
965
- assert get_total_time(flatten=flatten) == 5.0
1025
+ assert get_total_time() == 5.0
966
1026
 
967
1027
  def test_empty_json(self) -> None:
968
1028
  assert json.loads(get_execution_times_json(indent=None)) == {
@@ -990,5 +1050,5 @@ class TestSnapshotSemantics:
990
1050
  with caplog.at_level(logging.INFO, logger=logger.name):
991
1051
  log_execution_times(flatten=False, logger=logger)
992
1052
  assert [(record.name, record.message) for record in caplog.records] == [
993
- (logger.name, get_execution_times_report(flatten=False))
1053
+ (logger.name, "\n" + get_execution_times_report(flatten=False))
994
1054
  ]
@@ -1,63 +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.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
File without changes