executiontimer 0.1.0__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.
@@ -0,0 +1,91 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
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
+
37
+ ## [0.1.1] - 2026-09-20
38
+
39
+ ### Fixed
40
+
41
+ - Overlapping calls to the same section now accumulate each call's actual duration in
42
+ threads and asyncio tasks, including calls sharing one context or decorator.
43
+ - Clearing active timings cannot add a discarded sample to a new entry at the same path.
44
+ - JSON sections and totals now use one consistent snapshot, and category totals retain
45
+ categories that disappear when counter variants are merged.
46
+ - Flattened categories follow the most recently entered section, including revisited counters.
47
+ - Flattening preserves non-integer bracket suffixes such as `array[index]` and `empty[]`.
48
+ - An out-of-order context exit raises `RuntimeError` without changing another section's
49
+ elapsed time or active stack.
50
+
51
+ ### Performance
52
+
53
+ - Cache each active section's path and parent, and reuse decorator contexts to reduce
54
+ recording allocations and avoid rebuilding paths on exit.
55
+ - Sum total time directly without copying and flattening the registry.
56
+ - Calculate all JSON category totals in one pass over a shared snapshot.
57
+ - Skip report generation when logging at `INFO` is disabled.
58
+
59
+ ### Added
60
+
61
+ - Deterministic regression tests for concurrency, clearing, category attribution, and
62
+ snapshot consistency, plus coverage for recursion, cancellation, and deep nesting.
63
+ - A repeatable benchmark for recording and reporting overhead in `benchmarks/overhead.py`.
64
+ - Expanded the suite from 53 to 88 tests, achieving 100% statement and branch coverage.
65
+
66
+ ## [0.1.0] - 2026-08-20
67
+
68
+ First public release on PyPI.
69
+
70
+ ### Added
71
+
72
+ - `TimerContext` — context manager and decorator for timing a named section of code, with
73
+ optional `category` and `counter` arguments.
74
+ - Automatic hierarchical nesting: section names reflect the enclosing timing contexts.
75
+ - Native `async def` support — decorating a coroutine function times the whole `await`
76
+ rather than the creation of the coroutine object.
77
+ - Per-task and per-thread context isolation via `contextvars`, so concurrently recorded
78
+ sections nest independently and merge into one process-wide report.
79
+ - Reporting and export helpers: `get_execution_times_report`, `log_execution_times`,
80
+ `get_execution_timings`, `get_execution_times_json`, `save_execution_timings_json`,
81
+ `get_total_time`, `get_total_category_time`.
82
+ - Optional nesting rules via `register_forbidden_nesting` / `clear_forbidden_nesting`.
83
+ - `clear_execution_timings` to reset the registry.
84
+ - Exported `TimingReport`, `SectionRecord` and `TimingsPayload` typed dictionaries, plus a
85
+ `py.typed` marker so type checkers use the inline annotations.
86
+ - `__version__` attribute on the package.
87
+
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
90
+ [0.1.1]: https://github.com/seba2390/ExecutionTimer/compare/v0.1.0...v0.1.1
91
+ [0.1.0]: https://github.com/seba2390/ExecutionTimer/releases/tag/v0.1.0
@@ -0,0 +1,78 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time to contribute.
4
+
5
+ ## Getting set up
6
+
7
+ This project uses [uv](https://docs.astral.sh/uv/) for dependency management.
8
+
9
+ ```bash
10
+ git clone https://github.com/seba2390/ExecutionTimer.git
11
+ cd ExecutionTimer
12
+ uv sync
13
+ ```
14
+
15
+ ## Before opening a pull request
16
+
17
+ Run the same checks CI runs:
18
+
19
+ ```bash
20
+ uv run pytest --cov
21
+ uv run ruff check --fix
22
+ uv run ruff format
23
+ uv run basedpyright
24
+ ```
25
+
26
+ All four must pass. Coverage is enforced at 95%; the current suite has 100% statement
27
+ and branch coverage. Preserve coverage when adding or changing behavior.
28
+
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.
31
+ To check another interpreter locally:
32
+
33
+ ```bash
34
+ uv run --python 3.11 pytest
35
+ ```
36
+
37
+ For changes to recording or reporting performance, compare the benchmark on the same
38
+ machine and Python version, without coverage instrumentation:
39
+
40
+ ```bash
41
+ uv run python benchmarks/overhead.py --number 100000 --repeat 9
42
+ ```
43
+
44
+ See [benchmarks/README.md](benchmarks/README.md) for methodology and reference results.
45
+ Timing results are advisory rather than CI pass/fail thresholds.
46
+
47
+ ## Guidelines
48
+
49
+ - Tests exercise the **public API** only — import from `execution_timer`, not
50
+ `execution_timer._timer`. This keeps internals free to change.
51
+ - Every behaviour change needs a test that fails before the fix and passes after it.
52
+ - Public functions carry type annotations and a one-line docstring.
53
+ - Add an entry under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md).
54
+
55
+ ## Releasing
56
+
57
+ Maintainers only:
58
+
59
+ 1. Move the `## [Unreleased]` entries into a new version section in `CHANGELOG.md`, and
60
+ update the link definitions at the bottom.
61
+ 2. Bump `__version__` in `src/execution_timer/__init__.py`, then run `uv lock --check`
62
+ to verify the lockfile. Package metadata reads the version from `__version__`.
63
+ 3. Run the checks above, build with `uv build`, and validate metadata with
64
+ `uvx twine check --strict dist/*`. Use a clean output directory so old versions are
65
+ not included in release artifacts.
66
+ 4. Commit, then push to `main` and wait for CI to pass.
67
+ 5. Publish a GitHub release tagged `vX.Y.Z`, targeting the validated commit on `main`.
68
+ Use that version's changelog entries as release notes.
69
+
70
+ The [Publish to PyPI workflow](.github/workflows/publish.yml) runs when a GitHub release
71
+ is **published**. Pushing to `main`, pushing a tag alone, or saving a draft release does
72
+ not trigger it. The workflow builds and validates the distributions, checks that the tag
73
+ matches `__version__`, and uploads them to PyPI via Trusted Publishing. No manual
74
+ `twine upload` or PyPI API token is needed. If the `pypi` GitHub environment requires
75
+ approval, approve the publishing job there.
76
+
77
+ After publishing the release, check that the workflow succeeds and the new version
78
+ appears on [PyPI](https://pypi.org/project/executiontimer/).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: executiontimer
3
- Version: 0.1.0
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
@@ -145,6 +152,10 @@ get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
145
152
  get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
146
153
  ```
147
154
 
155
+ Flattening removes the final integer suffix (including negative counters). Other
156
+ bracketed names such as `array[index]` are preserved. If merged entries have different
157
+ categories, the category from the most recently entered section is used.
158
+
148
159
  ### Categories
149
160
 
150
161
  Categories are plain strings — use whatever fits your domain:
@@ -161,6 +172,10 @@ get_total_category_time("gpu")
161
172
  `get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
162
173
  section nested inside another `gpu` section is not double-counted.
163
174
 
175
+ Repeated calls to the same path accumulate time. If its category changes, the latest
176
+ category applies to that path's entire accumulated time. Use consistent categories per
177
+ path when you need separate category totals.
178
+
164
179
  You can also forbid a category from appearing inside another, which raises a `ValueError`
165
180
  as soon as the invalid nesting happens:
166
181
 
@@ -198,6 +213,9 @@ save_execution_timings_json("timings.json")
198
213
  }
199
214
  ```
200
215
 
216
+ Sections and totals come from one snapshot. Category totals use the original paths,
217
+ even when flattening merges sections with different categories.
218
+
201
219
  ### Concurrency
202
220
 
203
221
  The recorded timings live in one process-wide registry guarded by a lock. The *active
@@ -213,9 +231,50 @@ async def worker(n: int) -> None:
213
231
  await asyncio.gather(worker(0), worker(1))
214
232
  ```
215
233
 
216
- Recording the *same* section path from overlapping threads or tasks is not meaningful —
217
- the elapsed times would overlap and sum to more than the wall-clock duration. Give
218
- concurrent sections distinct names (or use `counter=`).
234
+ Overlapping calls to the same section path are supported: each call keeps its own start
235
+ time, and their durations are added together. These totals measure accumulated elapsed
236
+ time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
237
+ concurrent calls separately.
238
+
239
+ New asyncio tasks inherit the timing context in which they are created. Their sections
240
+ nest under that parent; changes to each task's active stack remain independent. Await
241
+ child tasks inside the parent section if you want the parent duration to include them.
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
+
255
+ ### Reusing contexts and clearing timings
256
+
257
+ A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
258
+ For a tight loop, reuse a context to avoid constructing one on every iteration:
259
+
260
+ ```python
261
+ step_timer = TimerContext("step")
262
+ for item in items:
263
+ with step_timer:
264
+ process(item)
265
+ ```
266
+
267
+ Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
268
+ samples from sections that were already active, without disturbing their nesting stack.
269
+ Sections started after the clear are recorded normally. Reports include completed calls;
270
+ an active section's current duration is added only when it exits.
271
+
272
+ ### Measuring overhead
273
+
274
+ Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
275
+ fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
276
+ results using the same interpreter and machine; see [benchmarks/README.md](benchmarks/README.md).
277
+ `log_execution_times()` skips building a report when its logger has `INFO` disabled.
219
278
 
220
279
  ## API
221
280
 
@@ -227,7 +286,7 @@ concurrent sections distinct names (or use `counter=`).
227
286
  | `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
228
287
  | `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
229
288
  | `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
230
- | `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). |
231
290
  | `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
232
291
  | `clear_execution_timings()` | Reset all recorded timings. |
233
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
@@ -116,6 +121,10 @@ get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
116
121
  get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
117
122
  ```
118
123
 
124
+ Flattening removes the final integer suffix (including negative counters). Other
125
+ bracketed names such as `array[index]` are preserved. If merged entries have different
126
+ categories, the category from the most recently entered section is used.
127
+
119
128
  ### Categories
120
129
 
121
130
  Categories are plain strings — use whatever fits your domain:
@@ -132,6 +141,10 @@ get_total_category_time("gpu")
132
141
  `get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
133
142
  section nested inside another `gpu` section is not double-counted.
134
143
 
144
+ Repeated calls to the same path accumulate time. If its category changes, the latest
145
+ category applies to that path's entire accumulated time. Use consistent categories per
146
+ path when you need separate category totals.
147
+
135
148
  You can also forbid a category from appearing inside another, which raises a `ValueError`
136
149
  as soon as the invalid nesting happens:
137
150
 
@@ -169,6 +182,9 @@ save_execution_timings_json("timings.json")
169
182
  }
170
183
  ```
171
184
 
185
+ Sections and totals come from one snapshot. Category totals use the original paths,
186
+ even when flattening merges sections with different categories.
187
+
172
188
  ### Concurrency
173
189
 
174
190
  The recorded timings live in one process-wide registry guarded by a lock. The *active
@@ -184,9 +200,50 @@ async def worker(n: int) -> None:
184
200
  await asyncio.gather(worker(0), worker(1))
185
201
  ```
186
202
 
187
- Recording the *same* section path from overlapping threads or tasks is not meaningful —
188
- the elapsed times would overlap and sum to more than the wall-clock duration. Give
189
- concurrent sections distinct names (or use `counter=`).
203
+ Overlapping calls to the same section path are supported: each call keeps its own start
204
+ time, and their durations are added together. These totals measure accumulated elapsed
205
+ time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
206
+ concurrent calls separately.
207
+
208
+ New asyncio tasks inherit the timing context in which they are created. Their sections
209
+ nest under that parent; changes to each task's active stack remain independent. Await
210
+ child tasks inside the parent section if you want the parent duration to include them.
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
+
224
+ ### Reusing contexts and clearing timings
225
+
226
+ A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
227
+ For a tight loop, reuse a context to avoid constructing one on every iteration:
228
+
229
+ ```python
230
+ step_timer = TimerContext("step")
231
+ for item in items:
232
+ with step_timer:
233
+ process(item)
234
+ ```
235
+
236
+ Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
237
+ samples from sections that were already active, without disturbing their nesting stack.
238
+ Sections started after the clear are recorded normally. Reports include completed calls;
239
+ an active section's current duration is added only when it exits.
240
+
241
+ ### Measuring overhead
242
+
243
+ Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
244
+ fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
245
+ results using the same interpreter and machine; see [benchmarks/README.md](benchmarks/README.md).
246
+ `log_execution_times()` skips building a report when its logger has `INFO` disabled.
190
247
 
191
248
  ## API
192
249
 
@@ -198,7 +255,7 @@ concurrent sections distinct names (or use `counter=`).
198
255
  | `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
199
256
  | `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
200
257
  | `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
201
- | `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). |
202
259
  | `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
203
260
  | `clear_execution_timings()` | Reset all recorded timings. |
204
261
  | `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
@@ -0,0 +1,40 @@
1
+ # Overhead benchmarks
2
+
3
+ From a repository checkout, run:
4
+
5
+ ```bash
6
+ uv run python benchmarks/overhead.py --number 100000 --repeat 9
7
+ ```
8
+
9
+ The script prints JSON with the Python version, platform, and median nanoseconds per
10
+ operation. Recording cases run `--number` operations per repeat. Reporting cases run
11
+ `max(1, number // 1000)` operations against 1,000 recorded counter variants with ten
12
+ categories. Async calls run in one event loop, so loop startup is excluded.
13
+
14
+ Compare the same interpreter and machine under similar load, without coverage or a
15
+ profiler enabled. These are microbenchmarks, not CI timing thresholds. The no-op bodies
16
+ make instrumentation costs visible; application speedups depend on the work being timed.
17
+
18
+ ## Local comparison
19
+
20
+ Measured on macOS 26.6.2, ARM64, CPython 3.14.5, using 100,000 operations and nine repeats.
21
+ The baseline is commit `008494c` (version 0.1.0); the updated column uses version 0.1.1.
22
+ Both versions ran the same script. Values below are microseconds per operation and
23
+ include the benchmark function call; plain calls cost approximately 0.017 µs and plain
24
+ awaits 0.056 µs in both runs.
25
+
26
+ | Operation | Baseline (µs) | Updated (µs) | Reduction |
27
+ | --- | ---: | ---: | ---: |
28
+ | New context | 1.550 | 1.028 | 34% |
29
+ | Reused context | 1.425 | 0.947 | 34% |
30
+ | Decorated call | 1.604 | 1.011 | 37% |
31
+ | Decorated await | 1.696 | 1.093 | 36% |
32
+ | Five nested contexts | 7.792 | 5.116 | 34% |
33
+ | Total time, 1,000 sections | 524.809 | 43.068 | 92% |
34
+ | JSON, 1,000 sections | 1,177.635 | 1,005.600 | 15% |
35
+ | Disabled logging, 1,000 sections | 520.167 | 0.099 | >99.9% |
36
+
37
+ Recording keeps each invocation's start time and cached path in a context-local frame.
38
+ Decorators reuse their context instead of constructing one per call. Total-time queries
39
+ avoid copying and flattening entries, JSON exports share one snapshot, and disabled
40
+ logging returns before building a report.
@@ -0,0 +1,120 @@
1
+ """Measure recording and reporting costs; run with ``uv run python benchmarks/overhead.py``.
2
+
3
+ Use the same interpreter, machine, iteration count, and repeat count for comparisons.
4
+ Results are medians in nanoseconds per operation; they are not CI pass/fail thresholds.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import platform
14
+ import statistics
15
+ import time
16
+ import timeit
17
+ from collections.abc import Callable
18
+
19
+ from execution_timer import (
20
+ TimerContext,
21
+ clear_execution_timings,
22
+ get_execution_times_json,
23
+ get_total_time,
24
+ log_execution_times,
25
+ )
26
+
27
+
28
+ def main() -> None:
29
+ parser = argparse.ArgumentParser(description=__doc__)
30
+ _ = parser.add_argument("--number", type=int, default=100_000)
31
+ _ = parser.add_argument("--repeat", type=int, default=7)
32
+ args = parser.parse_args()
33
+ number = int(args.number)
34
+ repeat = int(args.repeat)
35
+ if number < 1 or repeat < 1:
36
+ parser.error("--number and --repeat must be positive")
37
+
38
+ results: dict[str, float] = {}
39
+
40
+ def measure(name: str, operation: Callable[[], object], count: int = number) -> None:
41
+ clear_execution_timings()
42
+ results[name] = statistics.median(timeit.repeat(operation, number=count, repeat=repeat)) * 1e9 / count
43
+
44
+ def plain() -> None:
45
+ pass
46
+
47
+ def context() -> None:
48
+ with TimerContext("section"):
49
+ pass
50
+
51
+ shared = TimerContext("section")
52
+
53
+ def reused_context() -> None:
54
+ with shared:
55
+ pass
56
+
57
+ @TimerContext("section")
58
+ def decorated() -> None:
59
+ pass
60
+
61
+ def nested() -> None:
62
+ with shared, shared, shared, shared, shared:
63
+ pass
64
+
65
+ measure("plain_call", plain)
66
+ measure("new_context", context)
67
+ measure("reused_context", reused_context)
68
+ measure("decorated_call", decorated)
69
+ measure("five_nested_contexts", nested)
70
+
71
+ async def plain_async() -> None:
72
+ pass
73
+
74
+ decorated_async = TimerContext("async_section")(plain_async)
75
+
76
+ async def measure_async() -> None:
77
+ for name, function in (("plain_await", plain_async), ("decorated_await", decorated_async)):
78
+ samples: list[float] = []
79
+ for _ in range(repeat):
80
+ clear_execution_timings()
81
+ started = time.perf_counter()
82
+ for _ in range(number):
83
+ await function()
84
+ samples.append((time.perf_counter() - started) * 1e9 / number)
85
+ results[name] = statistics.median(samples)
86
+
87
+ asyncio.run(measure_async())
88
+
89
+ clear_execution_timings()
90
+ for index in range(1_000):
91
+ with TimerContext("section", category=f"category{index % 10}", counter=index):
92
+ pass
93
+ quiet_logger = logging.getLogger("executiontimer.benchmark")
94
+ quiet_logger.setLevel(logging.WARNING)
95
+ reporting_count = max(1, number // 1_000)
96
+ for name, operation in (
97
+ ("total_1000_sections", get_total_time),
98
+ ("json_1000_sections", get_execution_times_json),
99
+ ("disabled_logging_1000_sections", lambda: log_execution_times(logger=quiet_logger)),
100
+ ):
101
+ results[name] = (
102
+ statistics.median(timeit.repeat(operation, number=reporting_count, repeat=repeat)) * 1e9 / reporting_count
103
+ )
104
+
105
+ print(
106
+ json.dumps(
107
+ {
108
+ "python": platform.python_version(),
109
+ "platform": platform.platform(),
110
+ "number": number,
111
+ "repeat": repeat,
112
+ "nanoseconds_per_operation": {name: round(value, 1) for name, value in results.items()},
113
+ },
114
+ indent=2,
115
+ )
116
+ )
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
@@ -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",
@@ -52,7 +54,16 @@ path = "src/execution_timer/__init__.py"
52
54
  packages = ["src/execution_timer"]
53
55
 
54
56
  [tool.hatch.build.targets.sdist]
55
- include = ["/src", "/tests", "/README.md", "/LICENSE", "/CHANGELOG.md", "/pyproject.toml"]
57
+ include = [
58
+ "/src",
59
+ "/tests",
60
+ "/benchmarks",
61
+ "/README.md",
62
+ "/LICENSE",
63
+ "/CHANGELOG.md",
64
+ "/CONTRIBUTING.md",
65
+ "/pyproject.toml",
66
+ ]
56
67
 
57
68
  [dependency-groups]
58
69
  dev = [
@@ -76,7 +87,7 @@ fail_under = 95
76
87
  exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]
77
88
 
78
89
  [tool.ruff]
79
- target-version = "py312"
90
+ target-version = "py311"
80
91
  line-length = 120
81
92
  src = ["src", "tests"]
82
93
 
@@ -94,5 +105,5 @@ select = [
94
105
 
95
106
  [tool.basedpyright]
96
107
  typeCheckingMode = "recommended"
97
- pythonVersion = "3.12"
108
+ pythonVersion = "3.11"
98
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.0"
21
+ __version__ = "0.2.0"
22
22
 
23
23
  __all__ = [
24
24
  "DEFAULT_CATEGORY",