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.
Files changed (29) hide show
  1. {executiontimer-1.0.3 → executiontimer-1.1.1}/CHANGELOG.md +32 -1
  2. {executiontimer-1.0.3 → executiontimer-1.1.1}/PKG-INFO +6 -6
  3. {executiontimer-1.0.3 → executiontimer-1.1.1}/README.md +5 -5
  4. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/getting-started.md +11 -9
  5. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/concurrency.md +48 -16
  6. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/counters.md +5 -5
  7. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/reports.md +12 -8
  8. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/timing-code.md +4 -4
  9. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/index.md +5 -5
  10. {executiontimer-1.0.3 → executiontimer-1.1.1}/src/execution_timer/__init__.py +1 -1
  11. {executiontimer-1.0.3 → executiontimer-1.1.1}/src/execution_timer/_timer.py +19 -4
  12. {executiontimer-1.0.3 → executiontimer-1.1.1}/tests/execution_timer_test.py +69 -3
  13. {executiontimer-1.0.3 → executiontimer-1.1.1}/.gitignore +0 -0
  14. {executiontimer-1.0.3 → executiontimer-1.1.1}/CONTRIBUTING.md +0 -0
  15. {executiontimer-1.0.3 → executiontimer-1.1.1}/LICENSE +0 -0
  16. {executiontimer-1.0.3 → executiontimer-1.1.1}/benchmarks/README.md +0 -0
  17. {executiontimer-1.0.3 → executiontimer-1.1.1}/benchmarks/overhead.py +0 -0
  18. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/_static/icon-dark.svg +0 -0
  19. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/_static/icon-light.svg +0 -0
  20. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/api.md +0 -0
  21. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/changelog.md +0 -0
  22. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/conf.py +0 -0
  23. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/contributing.md +0 -0
  24. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/categories.md +0 -0
  25. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/long-running.md +0 -0
  26. {executiontimer-1.0.3 → executiontimer-1.1.1}/docs/guide/overhead.md +0 -0
  27. {executiontimer-1.0.3 → executiontimer-1.1.1}/pyproject.toml +0 -0
  28. {executiontimer-1.0.3 → executiontimer-1.1.1}/src/execution_timer/py.typed +0 -0
  29. {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.0.3...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
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.0.3
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: 0.3156 s.
87
+ Total time: 312.838 ms.
88
88
 
89
- load_data: 0.1219 s (38.62%)
90
- solve: 0.1937 s (61.38%)
91
- .. step: 0.1585 s (50.24%)
92
- .. postprocess: 0.0350 s (11.10%)
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: 0.3156 s.
56
+ Total time: 312.838 ms.
57
57
 
58
- load_data: 0.1219 s (38.62%)
59
- solve: 0.1937 s (61.38%)
60
- .. step: 0.1585 s (50.24%)
61
- .. postprocess: 0.0350 s (11.10%)
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: 0.1035 s.
45
+ Total time: 104.727 ms.
46
46
 
47
- load_data: 0.1035 s (100.00%)
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: 0.0905 s.
75
+ Total time: 89.826 ms.
76
76
 
77
- pipeline: 0.0905 s (100.00%)
78
- .. load: 0.0232 s (25.60%)
79
- .. transform: 0.0673 s (74.32%)
80
- .. .. validate: 0.0126 s (13.88%)
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.88% of the
90
- whole run, not 13.88% of `transform`.
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
- 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
 
@@ -37,12 +37,12 @@ print(get_execution_times_report(flatten=False))
37
37
  ```
38
38
 
39
39
  ```text
40
- Total time: 0.0724 s.
40
+ Total time: 67.003 ms.
41
41
 
42
- solve: 0.0724 s (100.00%)
43
- .. step[0]: 0.0125 s (17.31%)
44
- .. step[1]: 0.0250 s (34.60%)
45
- .. step[2]: 0.0347 s (47.97%)
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: 0.0499 s.
35
+ Total time: 48.159 ms.
36
36
 
37
- load_data: 0.0249 s (49.92%)
38
- solve: 0.0250 s (50.08%)
39
- .. step: 0.0249 s (49.97%)
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: 0.0499 s.
65
+ Total time: 48.159 ms.
62
66
 
63
- load_data: 0.0249 s (49.92%)
64
- solve: 0.0250 s (50.08%)
65
- .. step: 0.0249 s (49.97%)
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: 0.0354 s.
155
+ Total time: 34.704 ms.
156
156
 
157
- walk: 0.0354 s (100.00%)
158
- .. walk: 0.0247 s (69.77%)
159
- .. .. walk: 0.0125 s (35.38%)
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: 0.3156 s.
26
+ Total time: 312.838 ms.
27
27
 
28
- load_data: 0.1219 s (38.62%)
29
- solve: 0.1937 s (61.38%)
30
- .. step: 0.1585 s (50.24%)
31
- .. postprocess: 0.0350 s (11.10%)
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
@@ -18,7 +18,7 @@ from execution_timer._timer import (
18
18
  save_execution_timings_json,
19
19
  )
20
20
 
21
- __version__ = "1.0.3"
21
+ __version__ = "1.1.1"
22
22
 
23
23
  __all__ = [
24
24
  "DEFAULT_CATEGORY",
@@ -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:.4f} s.\n"]
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:.4f} s ({percentage:.2f}%)")
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 seconds and its share of the total time.
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.0000 s (0.00%)" in report
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.0000 s.\n\nchild: 2.0000 s (100.00%)"
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.0000 s.\n\nload: 4.0000 s (80.00%)\n.. parse: 1.0000 s (20.00%)\nafter: 1.0000 s (20.00%)"
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