executiontimer 0.1.1__tar.gz → 0.2.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.
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-09-30
11
+
12
+ ### Changed
13
+
14
+ - Decorating a generator function or an async generator function now raises `TypeError`.
15
+ Previously it silently timed only the creation of the generator object, which recorded
16
+ microseconds regardless of how long iteration took.
17
+ - Python 3.11 is now supported; the minimum was 3.12 although nothing required it.
18
+
19
+ ### Fixed
20
+
21
+ - `log_execution_times()` no longer logs an empty `INFO` record, after its warning, when
22
+ there are no timings.
23
+
24
+ ### Documentation
25
+
26
+ - Explain that threads do not inherit the timing context, and how to nest thread work under
27
+ a section.
28
+ - Explain how a section held open across a generator's `yield` absorbs the caller's sections.
29
+ - Note that `flatten` has no effect on `get_total_time`.
30
+
31
+ ### Added
32
+
33
+ - CI tests Python 3.11 and free-threaded Python 3.14t, and the package declares
34
+ free-threading support.
35
+ - Workflows run with a read-only token by default and do not persist checkout credentials.
36
+
10
37
  ## [0.1.1] - 2026-09-20
11
38
 
12
39
  ### Fixed
@@ -58,6 +85,7 @@ First public release on PyPI.
58
85
  `py.typed` marker so type checkers use the inline annotations.
59
86
  - `__version__` attribute on the package.
60
87
 
61
- [Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...HEAD
88
+ [Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v0.2.0...HEAD
89
+ [0.2.0]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.1...v0.2.0
62
90
  [0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
63
91
  [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: 0.2.0
4
4
  Summary: Hierarchical execution timing with user-defined categories.
5
5
  Project-URL: Homepage, https://github.com/seba2390/ExecutionTimer
6
6
  Project-URL: Documentation, https://github.com/seba2390/ExecutionTimer#readme
@@ -16,15 +16,17 @@ Classifier: Intended Audience :: Developers
16
16
  Classifier: Intended Audience :: Science/Research
17
17
  Classifier: Operating System :: OS Independent
18
18
  Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
19
20
  Classifier: Programming Language :: Python :: 3.12
20
21
  Classifier: Programming Language :: Python :: 3.13
21
22
  Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
22
24
  Classifier: Programming Language :: Python :: Implementation :: CPython
23
25
  Classifier: Topic :: Software Development
24
26
  Classifier: Topic :: Software Development :: Testing
25
27
  Classifier: Topic :: System :: Benchmark
26
28
  Classifier: Typing :: Typed
27
- Requires-Python: >=3.12
29
+ Requires-Python: >=3.11
28
30
  Description-Content-Type: text/markdown
29
31
 
30
32
  <p align="center">
@@ -55,7 +57,8 @@ Zero dependencies. Fully type annotated. Works with threads and `asyncio`.
55
57
  - 🌳 **Automatic hierarchy** — nesting `with` blocks nests the report, no wiring required
56
58
  - 🏷️ **User-defined categories** — tag sections with any string (`"gpu"`, `"io"`, `"db"`) and get per-category totals
57
59
  - ⚡ **Native async** — decorating an `async def` times the whole `await`, not the coroutine object
58
- - 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task
60
+ - 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task,
61
+ including on free-threaded Python builds
59
62
  - 🔢 **Loop counters** — time each iteration separately, then merge them back together
60
63
  - 📤 **JSON export** — structured output for dashboards, CI, or an LLM
61
64
  - 🚫 **Nesting rules** — optionally forbid one category inside another to catch mistakes early
@@ -71,7 +74,7 @@ pip install executiontimer
71
74
  uv add executiontimer
72
75
  ```
73
76
 
74
- Requires Python 3.12+.
77
+ Requires Python 3.11+.
75
78
 
76
79
  > **Note** — the install name is `executiontimer`, the import name is `execution_timer`:
77
80
  >
@@ -131,6 +134,10 @@ Coroutine functions are supported natively — the timing spans the entire `awai
131
134
  async def fetch(url: str) -> bytes: ...
132
135
  ```
133
136
 
137
+ Generator functions (including `async` generators) cannot be decorated and raise a
138
+ `TypeError`: the decorator would time only the creation of the generator object, not its
139
+ iteration. Time the loop that consumes the generator with a `with` block instead.
140
+
134
141
  ### Counters
135
142
 
136
143
  Pass `counter=i` to time loop iterations separately. The report merges them by default
@@ -233,6 +240,18 @@ New asyncio tasks inherit the timing context in which they are created. Their se
233
240
  nest under that parent; changes to each task's active stack remain independent. Await
234
241
  child tasks inside the parent section if you want the parent duration to include them.
235
242
 
243
+ New threads do *not* inherit the timing context, so sections recorded in a thread
244
+ appear at the top level of the report, and their time is added to the total alongside
245
+ the section that started the thread. To nest thread work under the current section, run
246
+ it with `contextvars.copy_context().run(...)` or `asyncio.to_thread(...)`. Either way,
247
+ concurrent threads accumulate overlapping time. (Free-threaded builds of Python 3.14 make
248
+ threads inherit the context by default.)
249
+
250
+ Generators run in their caller's context. A `with TimerContext(...)` block that stays open
251
+ across a `yield` therefore also contains whatever the caller times while the generator is
252
+ paused, and its duration includes that paused time. Close sections before yielding, or
253
+ time the loop that consumes the generator instead.
254
+
236
255
  ### Reusing contexts and clearing timings
237
256
 
238
257
  A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
@@ -267,7 +286,7 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
267
286
  | `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
268
287
  | `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
269
288
  | `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. |
289
+ | `get_total_time(*, flatten=True)` | Total seconds across all top-level sections (`flatten` has no effect on the sum). |
271
290
  | `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
272
291
  | `clear_execution_timings()` | Reset all recorded timings. |
273
292
  | `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
@@ -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
  >
@@ -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
@@ -204,6 +209,18 @@ New asyncio tasks inherit the timing context in which they are created. Their se
204
209
  nest under that parent; changes to each task's active stack remain independent. Await
205
210
  child tasks inside the parent section if you want the parent duration to include them.
206
211
 
212
+ New threads do *not* inherit the timing context, so sections recorded in a thread
213
+ appear at the top level of the report, and their time is added to the total alongside
214
+ the section that started the thread. To nest thread work under the current section, run
215
+ it with `contextvars.copy_context().run(...)` or `asyncio.to_thread(...)`. Either way,
216
+ concurrent threads accumulate overlapping time. (Free-threaded builds of Python 3.14 make
217
+ threads inherit the context by default.)
218
+
219
+ Generators run in their caller's context. A `with TimerContext(...)` block that stays open
220
+ across a `yield` therefore also contains whatever the caller times while the generator is
221
+ paused, and its duration includes that paused time. Close sections before yielding, or
222
+ time the loop that consumes the generator instead.
223
+
207
224
  ### Reusing contexts and clearing timings
208
225
 
209
226
  A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
@@ -238,7 +255,7 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
238
255
  | `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
239
256
  | `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
240
257
  | `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. |
258
+ | `get_total_time(*, flatten=True)` | Total seconds across all top-level sections (`flatten` has no effect on the sum). |
242
259
  | `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
243
260
  | `clear_execution_timings()` | Reset all recorded timings. |
244
261
  | `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
@@ -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",
@@ -23,9 +23,11 @@ classifiers = [
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__ = "0.2.0"
22
22
 
23
23
  __all__ = [
24
24
  "DEFAULT_CATEGORY",
@@ -247,8 +247,16 @@ class TimerContext:
247
247
  """Decorate a function to time its execution under this context.
248
248
 
249
249
  Coroutine functions are wrapped so the timing spans the entire ``await``, not just
250
- creation of the coroutine object.
250
+ creation of the coroutine object. Generator functions are rejected: a wrapper would
251
+ time only creation of the generator object, not its iteration.
251
252
  """
253
+ if inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func):
254
+ msg = (
255
+ f"Cannot decorate generator function {func.__qualname__!r}: only creating the generator "
256
+ "would be timed. Time the loop that consumes it, or its body between yields, with a "
257
+ "'with TimerContext(...)' block instead."
258
+ )
259
+ raise TypeError(msg)
252
260
  if inspect.iscoroutinefunction(func):
253
261
  # ``iscoroutinefunction`` narrows nothing useful for the type checker, so bridge
254
262
  # through an explicitly typed helper instead of leaking ``Any`` into the signature.
@@ -299,7 +307,10 @@ def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None =
299
307
  """Log the execution-times report at INFO level; flatten counters if requested."""
300
308
  target = logger if logger is not None else _LOGGER
301
309
  if target.isEnabledFor(logging.INFO):
302
- target.info(get_execution_times_report(flatten=flatten))
310
+ report = get_execution_times_report(flatten=flatten)
311
+ # An empty report has already been warned about; logging it would add a blank record.
312
+ if report:
313
+ target.info(report)
303
314
 
304
315
 
305
316
  def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
@@ -345,7 +356,11 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
345
356
 
346
357
 
347
358
  def get_total_time(*, flatten: bool = True) -> float:
348
- """Get total elapsed seconds across all top-level sections."""
359
+ """Get total elapsed seconds across all top-level sections.
360
+
361
+ ``flatten`` has no effect, because merging counter variants cannot change the total. It is
362
+ accepted for symmetry with the other reporting functions.
363
+ """
349
364
  return _TIMER.compute_total_time(flatten=flatten)
350
365
 
351
366
 
@@ -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, Iterator
11
11
  from concurrent.futures import ThreadPoolExecutor
12
12
  from contextlib import ExitStack
13
13
  from pathlib import Path
@@ -380,6 +380,32 @@ class TestTimerContextDecorator:
380
380
  assert ("outer",) in timings
381
381
  assert ("outer", "inner") in timings
382
382
 
383
+ def test_decorating_a_generator_function_raises(self) -> None:
384
+ def numbers() -> Iterator[int]:
385
+ yield 1
386
+
387
+ with pytest.raises(TypeError, match=r"Cannot decorate generator function '.*numbers'"):
388
+ _ = TimerContext("gen")(numbers)
389
+ assert raw_timings() == {}
390
+
391
+ def test_decorating_an_async_generator_function_raises(self) -> None:
392
+ async def numbers() -> AsyncIterator[int]:
393
+ yield 1
394
+
395
+ with pytest.raises(TypeError, match=r"Cannot decorate generator function '.*numbers'"):
396
+ _ = TimerContext("agen")(numbers)
397
+ assert raw_timings() == {}
398
+
399
+ def test_decorator_accepts_a_function_returning_a_generator(self) -> None:
400
+ """Only generator *functions* are rejected; a plain function may return an iterator."""
401
+
402
+ @TimerContext("factory")
403
+ def factory() -> Iterator[int]:
404
+ return iter([1, 2])
405
+
406
+ assert list(factory()) == [1, 2]
407
+ assert ("factory",) in raw_timings()
408
+
383
409
 
384
410
  class TestExceptions:
385
411
  def test_timing_recorded_when_body_raises(self) -> None:
@@ -695,6 +721,14 @@ class TestRobustness:
695
721
 
696
722
  assert [record.name for record in caplog.records] == ["execution_timer._timer"]
697
723
 
724
+ def test_logging_an_empty_registry_emits_only_the_warning(self, caplog: pytest.LogCaptureFixture) -> None:
725
+ with caplog.at_level(logging.INFO):
726
+ log_execution_times()
727
+
728
+ assert [(record.levelno, record.message) for record in caplog.records] == [
729
+ (logging.WARNING, "No timings to report.")
730
+ ]
731
+
698
732
 
699
733
  class TestRegressions:
700
734
  @pytest.mark.parametrize("clear_between_calls", [False, True])
File without changes