executiontimer 1.0.3__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.0.3 → executiontimer-1.1.1}/CHANGELOG.md +32 -1
- {executiontimer-1.0.3 → executiontimer-1.1.1}/PKG-INFO +6 -6
- {executiontimer-1.0.3 → executiontimer-1.1.1}/README.md +5 -5
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/getting-started.md +11 -9
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/concurrency.md +48 -16
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/counters.md +5 -5
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/reports.md +12 -8
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/timing-code.md +4 -4
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/index.md +5 -5
- {executiontimer-1.0.3 → executiontimer-1.1.1}/src/execution_timer/__init__.py +1 -1
- {executiontimer-1.0.3 → executiontimer-1.1.1}/src/execution_timer/_timer.py +19 -4
- {executiontimer-1.0.3 → executiontimer-1.1.1}/tests/execution_timer_test.py +69 -3
- {executiontimer-1.0.3 → executiontimer-1.1.1}/.gitignore +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/CONTRIBUTING.md +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/LICENSE +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/benchmarks/README.md +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/benchmarks/overhead.py +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/_static/icon-dark.svg +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/_static/icon-light.svg +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/api.md +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/changelog.md +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/conf.py +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/contributing.md +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/categories.md +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/long-running.md +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/overhead.md +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/pyproject.toml +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/src/execution_timer/py.typed +0 -0
- {executiontimer-1.0.3 → executiontimer-1.1.1}/tests/docs_examples_test.py +0 -0
|
@@ -7,6 +7,35 @@ 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
|
+
|
|
27
|
+
## [1.1.0] - 2026-09-30
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- The text report, from `get_execution_times_report()` and `log_execution_times()`, shows
|
|
32
|
+
each time with three decimals in `µs`, `ms` or `s`, whichever keeps the number below
|
|
33
|
+
1000: `48.213 µs`, `315.612 ms`, `2.500 s`. It used to show seconds with four
|
|
34
|
+
decimals, so any section under 50 µs read `0.0000 s`, and fast sections, including a
|
|
35
|
+
whole recursive call tree, looked like zeros. Code that parses the report text needs
|
|
36
|
+
updating. `get_execution_timings()` and the JSON export are unchanged and still return
|
|
37
|
+
seconds.
|
|
38
|
+
|
|
10
39
|
## [1.0.3] - 2026-09-30
|
|
11
40
|
|
|
12
41
|
### Changed
|
|
@@ -194,7 +223,9 @@ First public release on PyPI.
|
|
|
194
223
|
`py.typed` marker so type checkers use the inline annotations.
|
|
195
224
|
- `__version__` attribute on the package.
|
|
196
225
|
|
|
197
|
-
[Unreleased]: https://github.com/seba2390/ExecutionTimer/compare/v1.
|
|
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
|
|
228
|
+
[1.1.0]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.3...v1.1.0
|
|
198
229
|
[1.0.3]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.2...v1.0.3
|
|
199
230
|
[1.0.2]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.1...v1.0.2
|
|
200
231
|
[1.0.1]: https://github.com/seba2390/ExecutionTimer/compare/v1.0.0...v1.0.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: executiontimer
|
|
3
|
-
Version: 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/
|
|
@@ -84,12 +84,12 @@ print(get_execution_times_report())
|
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
```text
|
|
87
|
-
Total time:
|
|
87
|
+
Total time: 312.838 ms.
|
|
88
88
|
|
|
89
|
-
load_data:
|
|
90
|
-
solve:
|
|
91
|
-
.. step:
|
|
92
|
-
.. postprocess:
|
|
89
|
+
load_data: 124.682 ms (39.85%)
|
|
90
|
+
solve: 188.157 ms (60.15%)
|
|
91
|
+
.. step: 154.718 ms (49.46%)
|
|
92
|
+
.. postprocess: 33.295 ms (10.64%)
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
`TimerContext` also works as a decorator, including on `async def` functions:
|
|
@@ -53,12 +53,12 @@ print(get_execution_times_report())
|
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
```text
|
|
56
|
-
Total time:
|
|
56
|
+
Total time: 312.838 ms.
|
|
57
57
|
|
|
58
|
-
load_data:
|
|
59
|
-
solve:
|
|
60
|
-
.. step:
|
|
61
|
-
.. postprocess:
|
|
58
|
+
load_data: 124.682 ms (39.85%)
|
|
59
|
+
solve: 188.157 ms (60.15%)
|
|
60
|
+
.. step: 154.718 ms (49.46%)
|
|
61
|
+
.. postprocess: 33.295 ms (10.64%)
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
`TimerContext` also works as a decorator, including on `async def` functions:
|
|
@@ -42,9 +42,9 @@ print(get_execution_times_report())
|
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
```text
|
|
45
|
-
Total time:
|
|
45
|
+
Total time: 104.727 ms.
|
|
46
46
|
|
|
47
|
-
load_data:
|
|
47
|
+
load_data: 104.727 ms (100.00%)
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
Timings are recorded in one registry for the whole process. Every section you time, in any
|
|
@@ -72,12 +72,12 @@ print(get_execution_times_report())
|
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
```text
|
|
75
|
-
Total time:
|
|
75
|
+
Total time: 89.826 ms.
|
|
76
76
|
|
|
77
|
-
pipeline:
|
|
78
|
-
.. load:
|
|
79
|
-
.. transform:
|
|
80
|
-
.. .. validate:
|
|
77
|
+
pipeline: 89.826 ms (100.00%)
|
|
78
|
+
.. load: 22.073 ms (24.57%)
|
|
79
|
+
.. transform: 67.726 ms (75.40%)
|
|
80
|
+
.. .. validate: 12.549 ms (13.97%)
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
How to read the report:
|
|
@@ -86,8 +86,10 @@ How to read the report:
|
|
|
86
86
|
`pipeline`.
|
|
87
87
|
- **Total time** is the sum of the top-level sections. Nested sections are part of their
|
|
88
88
|
parent's time, so they aren't added again.
|
|
89
|
-
- **Percentages** are shares of the total, at every level. `validate` took 13.
|
|
90
|
-
whole run, not 13.
|
|
89
|
+
- **Percentages** are shares of the total, at every level. `validate` took 13.97% of the
|
|
90
|
+
whole run, not 13.97% of `transform`.
|
|
91
|
+
- **Times** have three decimals, in `µs`, `ms` or `s`, whichever keeps the number below
|
|
92
|
+
1000, so even very fast sections show a real value.
|
|
91
93
|
|
|
92
94
|
Calling {func}`~execution_timer.clear_execution_timings` starts over with an empty
|
|
93
95
|
registry.
|
|
@@ -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
|
|
|
@@ -37,12 +37,12 @@ print(get_execution_times_report(flatten=False))
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
```text
|
|
40
|
-
Total time:
|
|
40
|
+
Total time: 67.003 ms.
|
|
41
41
|
|
|
42
|
-
solve:
|
|
43
|
-
.. step[0]:
|
|
44
|
-
.. step[1]:
|
|
45
|
-
.. step[2]:
|
|
42
|
+
solve: 67.003 ms (100.00%)
|
|
43
|
+
.. step[0]: 12.542 ms (18.72%)
|
|
44
|
+
.. step[1]: 23.851 ms (35.60%)
|
|
45
|
+
.. step[2]: 30.498 ms (45.52%)
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Use the separate view to find a slow iteration, and the merged view to see the loop's
|
|
@@ -32,16 +32,20 @@ print(get_execution_times_report())
|
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
```text
|
|
35
|
-
Total time:
|
|
35
|
+
Total time: 48.159 ms.
|
|
36
36
|
|
|
37
|
-
load_data:
|
|
38
|
-
solve:
|
|
39
|
-
.. step:
|
|
37
|
+
load_data: 24.199 ms (50.25%)
|
|
38
|
+
solve: 23.960 ms (49.75%)
|
|
39
|
+
.. step: 23.904 ms (49.64%)
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
Sections appear depth-first, each followed by its children, in the order they were first
|
|
43
43
|
entered. Percentages are shares of the total time at every level.
|
|
44
44
|
|
|
45
|
+
Times have three decimals, in `µs`, `ms` or `s`, whichever keeps the number below 1000.
|
|
46
|
+
A 48 µs section reads `48.213 µs` rather than rounding to zero. The dictionary and JSON
|
|
47
|
+
readers below return plain seconds.
|
|
48
|
+
|
|
45
49
|
## Logging
|
|
46
50
|
|
|
47
51
|
{func}`~execution_timer.log_execution_times` logs the same report at `INFO` level, starting
|
|
@@ -58,11 +62,11 @@ log_execution_times()
|
|
|
58
62
|
|
|
59
63
|
```text
|
|
60
64
|
INFO:execution_timer._timer:
|
|
61
|
-
Total time:
|
|
65
|
+
Total time: 48.159 ms.
|
|
62
66
|
|
|
63
|
-
load_data:
|
|
64
|
-
solve:
|
|
65
|
-
.. step:
|
|
67
|
+
load_data: 24.199 ms (50.25%)
|
|
68
|
+
solve: 23.960 ms (49.75%)
|
|
69
|
+
.. step: 23.904 ms (49.64%)
|
|
66
70
|
```
|
|
67
71
|
|
|
68
72
|
It logs to the `execution_timer._timer` logger unless you pass your own with
|
|
@@ -152,11 +152,11 @@ print(get_execution_times_report())
|
|
|
152
152
|
```
|
|
153
153
|
|
|
154
154
|
```text
|
|
155
|
-
Total time:
|
|
155
|
+
Total time: 34.704 ms.
|
|
156
156
|
|
|
157
|
-
walk:
|
|
158
|
-
.. walk:
|
|
159
|
-
.. .. walk:
|
|
157
|
+
walk: 34.704 ms (100.00%)
|
|
158
|
+
.. walk: 24.161 ms (69.62%)
|
|
159
|
+
.. .. walk: 11.603 ms (33.44%)
|
|
160
160
|
```
|
|
161
161
|
|
|
162
162
|
For deep recursion, time the top-level call only, for example with a `with` block around
|
|
@@ -23,12 +23,12 @@ print(get_execution_times_report())
|
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
```text
|
|
26
|
-
Total time:
|
|
26
|
+
Total time: 312.838 ms.
|
|
27
27
|
|
|
28
|
-
load_data:
|
|
29
|
-
solve:
|
|
30
|
-
.. step:
|
|
31
|
-
.. postprocess:
|
|
28
|
+
load_data: 124.682 ms (39.85%)
|
|
29
|
+
solve: 188.157 ms (60.15%)
|
|
30
|
+
.. step: 154.718 ms (49.46%)
|
|
31
|
+
.. postprocess: 33.295 ms (10.64%)
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## Why executiontimer
|
|
@@ -198,11 +198,11 @@ class _ExecutionTimer:
|
|
|
198
198
|
# Total the unflattened paths, like get_total_time and the JSON export.
|
|
199
199
|
total_time = _top_level_time(snapshot)
|
|
200
200
|
timings = _flatten(snapshot) if flatten else snapshot
|
|
201
|
-
report = [f"Total time: {total_time
|
|
201
|
+
report = [f"Total time: {_format_duration(total_time)}.\n"]
|
|
202
202
|
for key, depth in _ordered_by_hierarchy(timings):
|
|
203
203
|
elapsed_time = timings[key]["elapsed_time"]
|
|
204
204
|
percentage = (elapsed_time / total_time) * 100 if total_time else 0.0
|
|
205
|
-
report.append(f"{'.. ' * depth}{key[-1]}: {elapsed_time
|
|
205
|
+
report.append(f"{'.. ' * depth}{key[-1]}: {_format_duration(elapsed_time)} ({percentage:.2f}%)")
|
|
206
206
|
return "\n".join(report)
|
|
207
207
|
|
|
208
208
|
def compute_total_time(self) -> float:
|
|
@@ -243,6 +243,20 @@ class _ExecutionTimer:
|
|
|
243
243
|
_TIMER: Final = _ExecutionTimer()
|
|
244
244
|
|
|
245
245
|
|
|
246
|
+
def _format_duration(seconds: float) -> str:
|
|
247
|
+
"""Format seconds with three decimals in the largest of µs, ms and s that stays below 1000.
|
|
248
|
+
|
|
249
|
+
The unit is chosen after rounding, so 999.9996 µs reads ``1.000 ms`` rather than
|
|
250
|
+
``1000.000 µs``. Sections of any length stay readable, where fixed seconds would show a
|
|
251
|
+
fast section as zero.
|
|
252
|
+
"""
|
|
253
|
+
for scale, unit in ((1e6, "µs"), (1e3, "ms")):
|
|
254
|
+
scaled = seconds * scale
|
|
255
|
+
if round(scaled, 3) < 1000:
|
|
256
|
+
return f"{scaled:.3f} {unit}"
|
|
257
|
+
return f"{seconds:.3f} s"
|
|
258
|
+
|
|
259
|
+
|
|
246
260
|
def _flatten(timings: dict[tuple[str, ...], _TimesDict]) -> dict[tuple[str, ...], _TimesDict]:
|
|
247
261
|
flat_map: dict[tuple[str, ...], _TimesDict] = {}
|
|
248
262
|
for key, info in timings.items():
|
|
@@ -378,8 +392,9 @@ def _basic_name_without_counter(name: str) -> str:
|
|
|
378
392
|
def get_execution_times_report(*, flatten: bool = True) -> str:
|
|
379
393
|
"""Get a formatted, indented report of all recorded sections.
|
|
380
394
|
|
|
381
|
-
Each line shows a section's accumulated
|
|
382
|
-
Indentation reflects nesting.
|
|
395
|
+
Each line shows a section's accumulated time and its share of the total time.
|
|
396
|
+
Indentation reflects nesting. Times have three decimals, in ``µs``, ``ms`` or ``s``,
|
|
397
|
+
whichever keeps the number below 1000.
|
|
383
398
|
|
|
384
399
|
Args:
|
|
385
400
|
flatten: Merge ``counter`` variants such as ``step[0]`` and ``step[1]`` into ``step``.
|
|
@@ -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]:
|
|
@@ -208,6 +214,25 @@ class TestReporting:
|
|
|
208
214
|
assert get_execution_times_report() == ""
|
|
209
215
|
assert caplog.records == []
|
|
210
216
|
|
|
217
|
+
@pytest.mark.parametrize(
|
|
218
|
+
("seconds", "expected"),
|
|
219
|
+
[
|
|
220
|
+
(0.0000004, "0.400 µs"),
|
|
221
|
+
(0.0000482134, "48.213 µs"),
|
|
222
|
+
(0.0009999994, "999.999 µs"),
|
|
223
|
+
(0.0009999996, "1.000 ms"),
|
|
224
|
+
(0.3156, "315.600 ms"),
|
|
225
|
+
(0.9999996, "1.000 s"),
|
|
226
|
+
(2.5, "2.500 s"),
|
|
227
|
+
(3725.0, "3725.000 s"),
|
|
228
|
+
],
|
|
229
|
+
)
|
|
230
|
+
def test_report_picks_a_unit_that_keeps_short_sections_visible(self, seconds: float, expected: str) -> None:
|
|
231
|
+
with patch.object(time, "perf_counter", side_effect=[0.0, seconds]), TimerContext("section"):
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
assert get_execution_times_report() == f"Total time: {expected}.\n\nsection: {expected} (100.00%)"
|
|
235
|
+
|
|
211
236
|
def test_report_flatten_flag(self) -> None:
|
|
212
237
|
with patch.object(time, "perf_counter", side_effect=[0, 1, 1, 2]):
|
|
213
238
|
for i in range(2):
|
|
@@ -571,6 +596,47 @@ class TestThreading:
|
|
|
571
596
|
assert ("t0", "c1") not in timings
|
|
572
597
|
assert ("t1", "c0") not in timings
|
|
573
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
|
+
|
|
574
640
|
|
|
575
641
|
class TestAsyncDecorator:
|
|
576
642
|
def test_async_decorator_times_the_await_not_coroutine_creation(self) -> None:
|
|
@@ -757,7 +823,7 @@ class TestReportOrdering:
|
|
|
757
823
|
pass
|
|
758
824
|
|
|
759
825
|
report = get_execution_times_report()
|
|
760
|
-
assert "instant: 0.
|
|
826
|
+
assert "instant: 0.000 µs (0.00%)" in report
|
|
761
827
|
|
|
762
828
|
|
|
763
829
|
class TestRobustness:
|
|
@@ -1059,7 +1125,7 @@ class TestLifecycle:
|
|
|
1059
1125
|
assert list(raw_timings()) == [("parent", "child")]
|
|
1060
1126
|
# The cleared parent is gone, so its child is top-level: it counts toward the total
|
|
1061
1127
|
# and is not indented beneath an unrelated section.
|
|
1062
|
-
assert get_execution_times_report() == "Total time: 2.
|
|
1128
|
+
assert get_execution_times_report() == "Total time: 2.000 s.\n\nchild: 2.000 s (100.00%)"
|
|
1063
1129
|
assert get_total_time() == 2.0
|
|
1064
1130
|
payload = cast(TimingsPayload, json.loads(get_execution_times_json()))
|
|
1065
1131
|
assert payload["total_time"] == 2.0
|
|
@@ -1078,7 +1144,7 @@ class TestLifecycle:
|
|
|
1078
1144
|
with TimerContext("after"):
|
|
1079
1145
|
pass
|
|
1080
1146
|
assert get_execution_times_report() == (
|
|
1081
|
-
"Total time: 5.
|
|
1147
|
+
"Total time: 5.000 s.\n\nload: 4.000 s (80.00%)\n.. parse: 1.000 s (20.00%)\nafter: 1.000 s (20.00%)"
|
|
1082
1148
|
)
|
|
1083
1149
|
assert get_total_time() == 5.0
|
|
1084
1150
|
|
|
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
|