executiontimer 0.2.0__py3-none-any.whl → 1.0.0__py3-none-any.whl

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.
@@ -18,7 +18,7 @@ from execution_timer._timer import (
18
18
  save_execution_timings_json,
19
19
  )
20
20
 
21
- __version__ = "0.2.0"
21
+ __version__ = "1.0.0"
22
22
 
23
23
  __all__ = [
24
24
  "DEFAULT_CATEGORY",
execution_timer/_timer.py CHANGED
@@ -14,6 +14,7 @@ import json
14
14
  import logging
15
15
  import threading
16
16
  import time
17
+ import warnings
17
18
  from collections.abc import Callable, Coroutine, Iterable, Iterator
18
19
  from contextvars import ContextVar
19
20
  from itertools import count
@@ -134,13 +135,27 @@ class _ExecutionTimer:
134
135
  _ = _ACTIVE_CONTEXT.set(_Frame(full_name, category, time.perf_counter(), entry, parent))
135
136
 
136
137
  def stop_timer(self, name: str) -> None:
137
- """Stop timing a section and accumulate its elapsed time."""
138
+ """Stop timing a section and accumulate its elapsed time.
139
+
140
+ Never raises: an exception here would replace one already propagating from the timed
141
+ block. Exiting past still-active inner sections (typically a suspended generator that
142
+ holds one open) discards them with a warning, so the stack cannot stay corrupted.
143
+ Exiting a section that is no longer active, such as one discarded that way when its
144
+ generator is finally closed, does nothing.
145
+ """
138
146
  end_time = time.perf_counter()
139
- frame = _ACTIVE_CONTEXT.get()
147
+ active = _ACTIVE_CONTEXT.get()
148
+ frame = active
149
+ while frame is not None and frame.path[-1] != name:
150
+ frame = frame.parent
140
151
  if frame is None:
141
152
  return
142
- if frame.path[-1] != name:
143
- raise RuntimeError(f"Cannot stop '{name}' while '{frame.path[-1]}' is active.")
153
+ if frame is not active and active is not None:
154
+ msg = (
155
+ f"Section '{name}' exited while '{active.path[-1]}' was still active; discarding the "
156
+ "unfinished inner sections. Close sections before a generator yields."
157
+ )
158
+ warnings.warn(msg, RuntimeWarning, stacklevel=3)
144
159
  with self._lock:
145
160
  # A clear detaches this entry from the registry. Updating the detached object
146
161
  # cannot resurrect an old sample or add it to a replacement at the same path.
@@ -155,21 +170,19 @@ class _ExecutionTimer:
155
170
  """Build a report of all sections with duration and percentage of total time."""
156
171
  timings = self._resolve(flatten=flatten)
157
172
  if not timings:
158
- _LOGGER.warning("No timings to report.")
159
173
  return ""
160
174
 
161
175
  total_time = sum(info["elapsed_time"] for key, info in timings.items() if len(key) == 1)
162
- report = [f"\nTotal calculation time: {total_time:.4f} s.\n"]
176
+ report = [f"Total time: {total_time:.4f} s.\n"]
163
177
  for key in _ordered_by_hierarchy(timings):
164
178
  elapsed_time = timings[key]["elapsed_time"]
165
179
  percentage = (elapsed_time / total_time) * 100 if total_time else 0.0
166
180
  report.append(f"{'.. ' * (len(key) - 1)}{key[-1]}: {elapsed_time:.4f} s ({percentage:.2f}%)")
167
181
  return "\n".join(report)
168
182
 
169
- def compute_total_time(self, *, flatten: bool = True) -> float:
183
+ def compute_total_time(self) -> float:
170
184
  """Compute total elapsed time across all top-level sections."""
171
- # Counter merging cannot change the sum. Avoid allocating and flattening a snapshot.
172
- _ = flatten
185
+ # Counter merging cannot change the sum, so there is no snapshot to copy or flatten.
173
186
  with self._lock:
174
187
  return sum((info["elapsed_time"] for key, info in self.timings.items() if len(key) == 1), 0.0)
175
188
 
@@ -232,16 +245,16 @@ class TimerContext:
232
245
  def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
233
246
  self.name: str = _build_name_with_counter(name, counter)
234
247
  self.category: str = category
235
- self.timer: _ExecutionTimer = _TIMER
248
+ self._timer: _ExecutionTimer = _TIMER
236
249
 
237
250
  def __enter__(self) -> TimerContext:
238
- self.timer.start_timer(self.name, self.category)
251
+ self._timer.start_timer(self.name, self.category)
239
252
  return self
240
253
 
241
254
  def __exit__(
242
255
  self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
243
256
  ) -> None:
244
- self.timer.stop_timer(self.name)
257
+ self._timer.stop_timer(self.name)
245
258
 
246
259
  def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
247
260
  """Decorate a function to time its execution under this context.
@@ -299,18 +312,20 @@ def _basic_name_without_counter(name: str) -> str:
299
312
 
300
313
 
301
314
  def get_execution_times_report(*, flatten: bool = True) -> str:
302
- """Get a formatted report of all recorded sections; flatten counters if requested."""
315
+ """Get a formatted report of all recorded sections (``""`` if none); flatten counters if requested."""
303
316
  return _TIMER.report_timings(flatten=flatten)
304
317
 
305
318
 
306
319
  def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
307
- """Log the execution-times report at INFO level; flatten counters if requested."""
320
+ """Log the execution-times report at INFO level, or a warning if there is nothing to report."""
308
321
  target = logger if logger is not None else _LOGGER
309
322
  if target.isEnabledFor(logging.INFO):
310
323
  report = get_execution_times_report(flatten=flatten)
311
- # An empty report has already been warned about; logging it would add a blank record.
312
324
  if report:
313
- target.info(report)
325
+ # Start the multi-line report on its own line, after the log record's prefix.
326
+ target.info("\n%s", report)
327
+ else:
328
+ target.warning("No timings to report.")
314
329
 
315
330
 
316
331
  def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
@@ -355,13 +370,9 @@ def save_execution_timings_json(path: str | Path, *, flatten: bool = True, inden
355
370
  return out
356
371
 
357
372
 
358
- def get_total_time(*, flatten: bool = True) -> float:
359
- """Get total elapsed seconds across all top-level sections.
360
-
361
- ``flatten`` has no effect, because merging counter variants cannot change the total. It is
362
- accepted for symmetry with the other reporting functions.
363
- """
364
- return _TIMER.compute_total_time(flatten=flatten)
373
+ def get_total_time() -> float:
374
+ """Get total elapsed seconds across all top-level sections."""
375
+ return _TIMER.compute_total_time()
365
376
 
366
377
 
367
378
  def get_total_category_time(category: str) -> float:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: executiontimer
3
- Version: 0.2.0
3
+ Version: 1.0.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
@@ -11,7 +11,7 @@ Author: Sebastian Yde Madsen
11
11
  License-Expression: MIT
12
12
  License-File: LICENSE
13
13
  Keywords: benchmark,context-manager,decorator,execution-time,instrumentation,performance,profiling,timing
14
- Classifier: Development Status :: 4 - Beta
14
+ Classifier: Development Status :: 5 - Production/Stable
15
15
  Classifier: Intended Audience :: Developers
16
16
  Classifier: Intended Audience :: Science/Research
17
17
  Classifier: Operating System :: OS Independent
@@ -103,7 +103,7 @@ print(get_execution_times_report())
103
103
  ```
104
104
 
105
105
  ```text
106
- Total calculation time: 0.3156 s.
106
+ Total time: 0.3156 s.
107
107
 
108
108
  load_data: 0.1219 s (38.62%)
109
109
  solve: 0.1937 s (61.38%)
@@ -156,6 +156,11 @@ Flattening removes the final integer suffix (including negative counters). Other
156
156
  bracketed names such as `array[index]` are preserved. If merged entries have different
157
157
  categories, the category from the most recently entered section is used.
158
158
 
159
+ Each counter value is stored as its own section until `clear_execution_timings()` is
160
+ called, so a long-running process that times an unbounded loop with `counter=` keeps
161
+ growing the registry. Clear it periodically, or drop `counter=` to accumulate into one
162
+ section.
163
+
159
164
  ### Categories
160
165
 
161
166
  Categories are plain strings — use whatever fits your domain:
@@ -252,6 +257,11 @@ across a `yield` therefore also contains whatever the caller times while the gen
252
257
  paused, and its duration includes that paused time. Close sections before yielding, or
253
258
  time the loop that consumes the generator instead.
254
259
 
260
+ If the caller's section exits while a paused generator's section is still open, the
261
+ timer never raises: it emits a `RuntimeWarning`, records the caller's section, and
262
+ discards the generator's unfinished one, so later sections nest correctly. Closing that
263
+ generator afterwards does nothing.
264
+
255
265
  ### Reusing contexts and clearing timings
256
266
 
257
267
  A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
@@ -273,7 +283,7 @@ an active section's current duration is added only when it exits.
273
283
 
274
284
  Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
275
285
  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).
286
+ results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
277
287
  `log_execution_times()` skips building a report when its logger has `INFO` disabled.
278
288
 
279
289
  ## API
@@ -281,12 +291,12 @@ results using the same interpreter and machine; see [benchmarks/README.md](bench
281
291
  | Function | Description |
282
292
  | --- | --- |
283
293
  | `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
284
- | `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections. |
285
- | `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level. |
294
+ | `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
295
+ | `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
286
296
  | `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
287
297
  | `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
288
298
  | `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
289
- | `get_total_time(*, flatten=True)` | Total seconds across all top-level sections (`flatten` has no effect on the sum). |
299
+ | `get_total_time()` | Total seconds across all top-level sections. |
290
300
  | `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
291
301
  | `clear_execution_timings()` | Reset all recorded timings. |
292
302
  | `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
@@ -309,9 +319,9 @@ uv run ruff check --fix && uv run ruff format
309
319
  uv run basedpyright
310
320
  ```
311
321
 
312
- See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, and
313
- [CHANGELOG.md](CHANGELOG.md) for release notes.
322
+ See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
323
+ [CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
314
324
 
315
325
  ## License
316
326
 
317
- MIT — see [LICENSE](LICENSE).
327
+ MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
@@ -0,0 +1,7 @@
1
+ execution_timer/__init__.py,sha256=gv9IbtkZkesfgWFh1oDSJ5DDr2BJFVxmKtY32jcUzbI,959
2
+ execution_timer/_timer.py,sha256=iylgfJ1vBeKimsW5A3NVN5KSUvZs75Km-N4guoHKFPo,15932
3
+ execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ executiontimer-1.0.0.dist-info/METADATA,sha256=ZtN7WHMrB8gTaGIy0VQLHDtu2ySFvd8oZx56KmA5F8s,13386
5
+ executiontimer-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ executiontimer-1.0.0.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
7
+ executiontimer-1.0.0.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- execution_timer/__init__.py,sha256=9A5FYIrOvXbohuQMyIis651hYdNmzvNLQxfR02pk92w,959
2
- execution_timer/_timer.py,sha256=4PSyP501arREnqOKtXEMwjbsb5sFISVYFRc1EXOXGP0,15366
3
- execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- executiontimer-0.2.0.dist-info/METADATA,sha256=B6G1utu8t7BPwVh8KFID_pPr-c9z2bW0M6hd8-eiPWg,12631
5
- executiontimer-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
- executiontimer-0.2.0.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
7
- executiontimer-0.2.0.dist-info/RECORD,,