executiontimer 1.1.0__tar.gz → 1.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.
Files changed (29) hide show
  1. {executiontimer-1.1.0 → executiontimer-1.1.1}/CHANGELOG.md +19 -1
  2. {executiontimer-1.1.0 → executiontimer-1.1.1}/PKG-INFO +1 -1
  3. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/concurrency.md +48 -16
  4. {executiontimer-1.1.0 → executiontimer-1.1.1}/src/execution_timer/__init__.py +1 -1
  5. {executiontimer-1.1.0 → executiontimer-1.1.1}/tests/execution_timer_test.py +47 -0
  6. {executiontimer-1.1.0 → executiontimer-1.1.1}/.gitignore +0 -0
  7. {executiontimer-1.1.0 → executiontimer-1.1.1}/CONTRIBUTING.md +0 -0
  8. {executiontimer-1.1.0 → executiontimer-1.1.1}/LICENSE +0 -0
  9. {executiontimer-1.1.0 → executiontimer-1.1.1}/README.md +0 -0
  10. {executiontimer-1.1.0 → executiontimer-1.1.1}/benchmarks/README.md +0 -0
  11. {executiontimer-1.1.0 → executiontimer-1.1.1}/benchmarks/overhead.py +0 -0
  12. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/_static/icon-dark.svg +0 -0
  13. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/_static/icon-light.svg +0 -0
  14. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/api.md +0 -0
  15. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/changelog.md +0 -0
  16. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/conf.py +0 -0
  17. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/contributing.md +0 -0
  18. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/getting-started.md +0 -0
  19. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/categories.md +0 -0
  20. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/counters.md +0 -0
  21. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/long-running.md +0 -0
  22. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/overhead.md +0 -0
  23. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/reports.md +0 -0
  24. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/timing-code.md +0 -0
  25. {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/index.md +0 -0
  26. {executiontimer-1.1.0 → executiontimer-1.1.1}/pyproject.toml +0 -0
  27. {executiontimer-1.1.0 → executiontimer-1.1.1}/src/execution_timer/_timer.py +0 -0
  28. {executiontimer-1.1.0 → executiontimer-1.1.1}/src/execution_timer/py.typed +0 -0
  29. {executiontimer-1.1.0 → executiontimer-1.1.1}/tests/docs_examples_test.py +0 -0
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.1] - 2026-10-01
11
+
12
+ ### Documentation
13
+
14
+ - The threads section of the concurrency guide was wrong for free-threaded Python 3.14.
15
+ There, a new thread starts in a copy of the context that started it, so its sections
16
+ nest under the section that was open at that moment. A thread pool reuses its workers,
17
+ so each worker keeps the section it started under: work submitted later from another
18
+ section was reported under the first one. The guide said pool work is always reported at
19
+ the top level, and that free-threaded builds nest thread work correctly with no extra
20
+ work. Neither was true on every build. The guide now explains the difference and shows
21
+ how to get the same report on every build: run each call in
22
+ `contextvars.copy_context().run` to nest it under the current section, or in
23
+ `contextvars.Context().run` to keep it at the top level. Behaviour is unchanged.
24
+ - Tests cover how threads and reused pool workers nest on each kind of build, and both
25
+ recipes. The guide's examples assert their results on every build.
26
+
10
27
  ## [1.1.0] - 2026-09-30
11
28
 
12
29
  ### Changed
@@ -206,7 +223,8 @@ First public release on PyPI.
206
223
  `py.typed` marker so type checkers use the inline annotations.
207
224
  - `__version__` attribute on the package.
208
225
 
209
- [Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.1.0...HEAD
226
+ [Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.1.1...HEAD
227
+ [1.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v1.1.0...v1.1.1
210
228
  [1.1.0]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.3...v1.1.0
211
229
  [1.0.3]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.2...v1.0.3
212
230
  [1.0.2]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.1...v1.0.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: executiontimer
3
- Version: 1.1.0
3
+ Version: 1.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://seba2390.github.io/ExecutionTimer/
@@ -57,11 +57,33 @@ the same.
57
57
 
58
58
  ## Threads
59
59
 
60
- New threads do **not** inherit the active stack. Sections recorded in a thread appear at the
61
- top level of the report, and their time is added to the total alongside the section that
62
- started the thread:
60
+ Whether a new thread starts inside the section that started it depends on the Python build:
61
+
62
+ - **Regular builds**, including Python 3.14, start each new thread with an empty stack.
63
+ Sections recorded in the thread appear at the top level of the report, and their time
64
+ is added to the total alongside the section that started the thread.
65
+ - **Free-threaded builds of Python 3.14** start each new thread with a copy of the context
66
+ of the code that called {meth}`~threading.Thread.start`. Sections recorded in the thread
67
+ nest under the section that was open at that moment.
68
+
69
+ Python's `-X thread_inherit_context` option switches between the two on any 3.14 build,
70
+ and `sys.flags.thread_inherit_context` reports which one is in effect.
71
+
72
+ ### Thread pools
73
+
74
+ The difference matters most for thread pools, because a pool reuses its threads.
75
+ {class}`~concurrent.futures.ThreadPoolExecutor` starts a worker thread when work is
76
+ submitted and keeps it for later work. On a free-threaded build, the worker keeps the
77
+ stack it started with for as long as it lives, so work submitted later, from a different
78
+ section or from none, still nests under the section that was open when the worker started.
79
+ On a regular build, the same work is recorded at the top level.
80
+
81
+ To get the same report on every build, pick the context for each call yourself. To nest
82
+ thread work under the current section, run each call in a fresh copy of the current
83
+ context from {func}`contextvars.copy_context`:
63
84
 
64
85
  ```python
86
+ import contextvars
65
87
  from concurrent.futures import ThreadPoolExecutor
66
88
 
67
89
  from execution_timer import clear_execution_timings
@@ -74,28 +96,38 @@ def download(n: int) -> int:
74
96
  return n
75
97
 
76
98
 
77
- with TimerContext("batch"), ThreadPoolExecutor() as pool:
78
- list(pool.map(download, range(4))) # recorded as ("download",), not ("batch", "download")
99
+ with ThreadPoolExecutor(max_workers=2) as pool:
100
+ with TimerContext("batch"):
101
+ futures = [pool.submit(contextvars.copy_context().run, download, n) for n in range(4)]
102
+ results = [future.result() for future in futures]
103
+ with TimerContext("retry"):
104
+ pool.submit(contextvars.copy_context().run, download, 0).result()
105
+
106
+ timings = get_execution_timings()
107
+ assert ("batch", "download") in timings
108
+ assert ("retry", "download") in timings
109
+ assert ("download",) not in timings
79
110
  ```
80
111
 
81
- To nest thread work under the current section, run it in a copy of the current context
82
- with {func}`contextvars.copy_context`, or use {func}`asyncio.to_thread`, which does that for
83
- you:
112
+ Copy the context for every call rather than once per section: a context can't be entered
113
+ by two threads at the same time. {func}`asyncio.to_thread` copies the context for you.
84
114
 
85
- ```python
86
- import contextvars
115
+ To keep thread work at the top level on every build, run each call in a new, empty
116
+ {class}`~contextvars.Context` instead:
87
117
 
118
+ ```python
88
119
  clear_execution_timings()
89
120
 
90
- with TimerContext("batch"), ThreadPoolExecutor() as pool:
91
- context = contextvars.copy_context()
92
- pool.submit(context.run, download, 1).result()
121
+ with ThreadPoolExecutor(max_workers=2) as pool, TimerContext("batch"):
122
+ pool.submit(contextvars.Context().run, download, 1).result()
93
123
 
94
- assert ("batch", "download") in get_execution_timings()
124
+ timings = get_execution_timings()
125
+ assert ("download",) in timings
126
+ assert ("batch", "download") not in timings
95
127
  ```
96
128
 
97
- On free-threaded builds of Python 3.14, new threads inherit the context by default, so
98
- thread sections nest without extra work.
129
+ The same works for a plain thread: pass `target=contextvars.copy_context().run` or
130
+ `target=contextvars.Context().run`, followed by the function and its arguments in `args`.
99
131
 
100
132
  ## Overlapping calls add up
101
133
 
@@ -18,7 +18,7 @@ from execution_timer._timer import (
18
18
  save_execution_timings_json,
19
19
  )
20
20
 
21
- __version__ = "1.1.0"
21
+ __version__ = "1.1.1"
22
22
 
23
23
  __all__ = [
24
24
  "DEFAULT_CATEGORY",
@@ -2,10 +2,12 @@
2
2
 
3
3
  import asyncio
4
4
  import builtins
5
+ import contextvars
5
6
  import functools
6
7
  import inspect
7
8
  import json
8
9
  import logging
10
+ import sys
9
11
  import threading
10
12
  import time
11
13
  import warnings
@@ -38,6 +40,10 @@ from execution_timer import (
38
40
  P = ParamSpec("P")
39
41
  R = TypeVar("R")
40
42
 
43
+ # New threads start in a copy of the starting thread's context. The default on free-threaded
44
+ # Python 3.14; the flag does not exist before 3.14.
45
+ THREADS_INHERIT_CONTEXT = bool(getattr(sys.flags, "thread_inherit_context", False))
46
+
41
47
 
42
48
  @pytest.fixture(autouse=True)
43
49
  def reset_timer() -> Iterator[None]:
@@ -590,6 +596,47 @@ class TestThreading:
590
596
  assert ("t0", "c1") not in timings
591
597
  assert ("t1", "c0") not in timings
592
598
 
599
+ def test_new_thread_nests_under_the_starting_section_only_where_threads_inherit_context(self) -> None:
600
+ thread = threading.Thread(target=TimerContext("work")(lambda: None))
601
+ with TimerContext("outer"):
602
+ thread.start()
603
+ thread.join()
604
+
605
+ expected = ("outer", "work") if THREADS_INHERIT_CONTEXT else ("work",)
606
+ assert set(raw_timings()) == {("outer",), expected}
607
+
608
+ def test_reused_pool_worker_keeps_the_section_it_started_in_where_threads_inherit_context(self) -> None:
609
+ """A pool worker inherits the context once, when it starts, and keeps it for later work."""
610
+ work = TimerContext("work")(lambda: None)
611
+ with ThreadPoolExecutor(max_workers=1) as pool:
612
+ with TimerContext("phase_a"):
613
+ pool.submit(work).result()
614
+ with TimerContext("phase_b"):
615
+ pool.submit(work).result()
616
+
617
+ # Where threads inherit context, phase_b's call is misfiled under phase_a.
618
+ expected = {("phase_a", "work")} if THREADS_INHERIT_CONTEXT else {("work",)}
619
+ assert set(raw_timings()) == {("phase_a",), ("phase_b",), *expected}
620
+
621
+ def test_copying_the_context_per_call_nests_pool_work_on_every_build(self) -> None:
622
+ work = TimerContext("work")(lambda: None)
623
+ with ThreadPoolExecutor(max_workers=2) as pool:
624
+ with TimerContext("phase_a"):
625
+ futures = [pool.submit(contextvars.copy_context().run, work) for _ in range(4)]
626
+ _ = [future.result() for future in futures]
627
+ with TimerContext("phase_b"):
628
+ pool.submit(contextvars.copy_context().run, work).result()
629
+ pool.submit(contextvars.copy_context().run, work).result()
630
+
631
+ assert set(raw_timings()) == {("phase_a",), ("phase_a", "work"), ("phase_b",), ("phase_b", "work"), ("work",)}
632
+
633
+ def test_an_empty_context_per_call_keeps_pool_work_top_level_on_every_build(self) -> None:
634
+ work = TimerContext("work")(lambda: None)
635
+ with ThreadPoolExecutor(max_workers=1) as pool, TimerContext("outer"):
636
+ pool.submit(contextvars.Context().run, work).result()
637
+
638
+ assert set(raw_timings()) == {("outer",), ("work",)}
639
+
593
640
 
594
641
  class TestAsyncDecorator:
595
642
  def test_async_decorator_times_the_await_not_coroutine_creation(self) -> None:
File without changes
File without changes