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.
- {executiontimer-1.1.0 → executiontimer-1.1.1}/CHANGELOG.md +19 -1
- {executiontimer-1.1.0 → executiontimer-1.1.1}/PKG-INFO +1 -1
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/concurrency.md +48 -16
- {executiontimer-1.1.0 → executiontimer-1.1.1}/src/execution_timer/__init__.py +1 -1
- {executiontimer-1.1.0 → executiontimer-1.1.1}/tests/execution_timer_test.py +47 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/.gitignore +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/CONTRIBUTING.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/LICENSE +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/README.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/benchmarks/README.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/benchmarks/overhead.py +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/_static/icon-dark.svg +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/_static/icon-light.svg +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/api.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/changelog.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/conf.py +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/contributing.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/getting-started.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/categories.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/counters.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/long-running.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/overhead.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/reports.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/guide/timing-code.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/docs/index.md +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/pyproject.toml +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/src/execution_timer/_timer.py +0 -0
- {executiontimer-1.1.0 → executiontimer-1.1.1}/src/execution_timer/py.typed +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
91
|
-
|
|
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
|
-
|
|
124
|
+
timings = get_execution_timings()
|
|
125
|
+
assert ("download",) in timings
|
|
126
|
+
assert ("batch", "download") not in timings
|
|
95
127
|
```
|
|
96
128
|
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|