executiontimer 1.0.1__py3-none-any.whl → 1.0.3__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.
- execution_timer/__init__.py +1 -1
- execution_timer/_timer.py +151 -34
- executiontimer-1.0.3.dist-info/METADATA +117 -0
- executiontimer-1.0.3.dist-info/RECORD +7 -0
- executiontimer-1.0.1.dist-info/METADATA +0 -329
- executiontimer-1.0.1.dist-info/RECORD +0 -7
- {executiontimer-1.0.1.dist-info → executiontimer-1.0.3.dist-info}/WHEEL +0 -0
- {executiontimer-1.0.1.dist-info → executiontimer-1.0.3.dist-info}/licenses/LICENSE +0 -0
execution_timer/__init__.py
CHANGED
execution_timer/_timer.py
CHANGED
|
@@ -20,39 +20,49 @@ from contextvars import ContextVar
|
|
|
20
20
|
from itertools import count
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from types import TracebackType
|
|
23
|
-
from typing import Final,
|
|
23
|
+
from typing import Final, ParamSpec, TypeAlias, TypedDict, TypeVar, cast
|
|
24
24
|
|
|
25
25
|
P = ParamSpec("P")
|
|
26
26
|
R = TypeVar("R")
|
|
27
27
|
T = TypeVar("T")
|
|
28
28
|
|
|
29
29
|
DEFAULT_CATEGORY: Final = "default"
|
|
30
|
+
"""The category of sections created without one."""
|
|
30
31
|
|
|
31
32
|
_LOGGER: Final = logging.getLogger(__name__)
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
class TimingReport(TypedDict):
|
|
35
|
-
"""Timing entry for one section
|
|
36
|
+
"""Timing entry for one section, as returned by :func:`get_execution_timings`."""
|
|
36
37
|
|
|
37
38
|
time: float
|
|
39
|
+
"""Accumulated elapsed seconds."""
|
|
38
40
|
category: str
|
|
41
|
+
"""The section's category."""
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
class SectionRecord(TypedDict):
|
|
42
|
-
"""One section in the JSON export
|
|
45
|
+
"""One section in the JSON export, see :class:`TimingsPayload`."""
|
|
43
46
|
|
|
44
47
|
name: str
|
|
48
|
+
"""The section's own name, the last element of :attr:`path`."""
|
|
45
49
|
path: list[str]
|
|
50
|
+
"""Names from the top-level section down to this one."""
|
|
46
51
|
time: float
|
|
52
|
+
"""Accumulated elapsed seconds, rounded to microseconds."""
|
|
47
53
|
category: str
|
|
54
|
+
"""The section's category."""
|
|
48
55
|
|
|
49
56
|
|
|
50
57
|
class TimingsPayload(TypedDict):
|
|
51
|
-
"""Top-level JSON export
|
|
58
|
+
"""Top-level object of the JSON export from :func:`get_execution_times_json`."""
|
|
52
59
|
|
|
53
60
|
total_time: float
|
|
61
|
+
"""Seconds across all top-level sections, as returned by :func:`get_total_time`."""
|
|
54
62
|
total_category_time: dict[str, float]
|
|
63
|
+
"""Seconds per category, counting only the top-most section of each category."""
|
|
55
64
|
sections: list[SectionRecord]
|
|
65
|
+
"""Every section, ordered depth-first so children follow their parent."""
|
|
56
66
|
|
|
57
67
|
|
|
58
68
|
class _TimesDict(TypedDict):
|
|
@@ -61,14 +71,10 @@ class _TimesDict(TypedDict):
|
|
|
61
71
|
category: str
|
|
62
72
|
|
|
63
73
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
category: str
|
|
69
|
-
start_time: float
|
|
70
|
-
entry: _TimesDict
|
|
71
|
-
parent: _Frame | None
|
|
74
|
+
# Per-invocation state, with a cached path and an immutable parent link:
|
|
75
|
+
# ``(path, category, start_time, entry, parent)``. A plain tuple rather than a NamedTuple,
|
|
76
|
+
# whose constructor runs in Python and costs more than the rest of section entry combined.
|
|
77
|
+
_Frame: TypeAlias = "tuple[tuple[str, ...], str, float, _TimesDict, _Frame | None]"
|
|
72
78
|
|
|
73
79
|
|
|
74
80
|
_ACTIVE_CONTEXT: ContextVar[_Frame | None] = ContextVar("execution_timer_context", default=None)
|
|
@@ -133,10 +139,10 @@ class _ExecutionTimer:
|
|
|
133
139
|
def start_timer(self, name: str, category: str) -> None:
|
|
134
140
|
"""Start timing a section under the given name within the active context."""
|
|
135
141
|
parent = _ACTIVE_CONTEXT.get()
|
|
136
|
-
full_name = (*parent
|
|
142
|
+
full_name = (*parent[0], name) if parent is not None else (name,)
|
|
137
143
|
with self._lock:
|
|
138
|
-
if parent is not None and (parent
|
|
139
|
-
msg = f"Category '{category}' is not allowed inside category '{parent
|
|
144
|
+
if parent is not None and (parent[1], category) in self.forbidden_nesting:
|
|
145
|
+
msg = f"Category '{category}' is not allowed inside category '{parent[1]}'."
|
|
140
146
|
raise ValueError(msg)
|
|
141
147
|
sequence = next(self._sequence)
|
|
142
148
|
entry: _TimesDict | None = self.timings.get(full_name)
|
|
@@ -146,7 +152,7 @@ class _ExecutionTimer:
|
|
|
146
152
|
else:
|
|
147
153
|
entry["sequence"] = sequence
|
|
148
154
|
entry["category"] = category
|
|
149
|
-
_ = _ACTIVE_CONTEXT.set(
|
|
155
|
+
_ = _ACTIVE_CONTEXT.set((full_name, category, time.perf_counter(), entry, parent))
|
|
150
156
|
|
|
151
157
|
def stop_timer(self, name: str) -> None:
|
|
152
158
|
"""Stop timing a section and accumulate its elapsed time.
|
|
@@ -161,19 +167,20 @@ class _ExecutionTimer:
|
|
|
161
167
|
end_time = time.perf_counter()
|
|
162
168
|
active = _ACTIVE_CONTEXT.get()
|
|
163
169
|
frame = active
|
|
164
|
-
while frame is not None and frame
|
|
165
|
-
frame = frame
|
|
170
|
+
while frame is not None and frame[0][-1] != name:
|
|
171
|
+
frame = frame[4]
|
|
166
172
|
if frame is None:
|
|
167
173
|
return
|
|
174
|
+
_, _, start_time, entry, parent = frame
|
|
168
175
|
with self._lock:
|
|
169
176
|
# A clear detaches this entry from the registry. Updating the detached object
|
|
170
177
|
# cannot resurrect an old sample or add it to a replacement at the same path.
|
|
171
|
-
|
|
172
|
-
_ = _ACTIVE_CONTEXT.set(
|
|
178
|
+
entry["elapsed_time"] += end_time - start_time
|
|
179
|
+
_ = _ACTIVE_CONTEXT.set(parent)
|
|
173
180
|
if frame is not active and active is not None:
|
|
174
181
|
# Warn only once the state is consistent: warnings configured as errors raise here.
|
|
175
182
|
msg = (
|
|
176
|
-
f"Section '{name}' exited while '{active
|
|
183
|
+
f"Section '{name}' exited while '{active[0][-1]}' was still active; discarding the "
|
|
177
184
|
"unfinished inner sections. Close sections before a generator yields."
|
|
178
185
|
)
|
|
179
186
|
warnings.warn(msg, RuntimeWarning, stacklevel=3)
|
|
@@ -258,7 +265,32 @@ def _has_ancestor_with_category(
|
|
|
258
265
|
|
|
259
266
|
|
|
260
267
|
class TimerContext:
|
|
261
|
-
"""Context manager and decorator for timing a named section of code.
|
|
268
|
+
"""Context manager and decorator for timing a named section of code.
|
|
269
|
+
|
|
270
|
+
Sections entered inside another section are recorded beneath it, so nesting ``with``
|
|
271
|
+
blocks or decorated calls builds the hierarchy shown in reports. Repeated entries of the
|
|
272
|
+
same section accumulate their durations. A context can be reused, re-entered while it is
|
|
273
|
+
active, and shared between threads and asyncio tasks.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
name: The section's name within its parent.
|
|
277
|
+
category: Any string used to group sections, for example ``"io"`` or ``"gpu"``.
|
|
278
|
+
counter: Records the section as ``name[counter]``, typically a loop index. Reports
|
|
279
|
+
merge these variants unless you pass ``flatten=False``.
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
ValueError: On entry, if a rule from :func:`register_forbidden_nesting` forbids this
|
|
283
|
+
category directly inside the enclosing section's category.
|
|
284
|
+
|
|
285
|
+
Example:
|
|
286
|
+
.. code-block:: python
|
|
287
|
+
|
|
288
|
+
with TimerContext("load", category="io"):
|
|
289
|
+
data = load()
|
|
290
|
+
|
|
291
|
+
@TimerContext("solve")
|
|
292
|
+
def solve(data): ...
|
|
293
|
+
"""
|
|
262
294
|
|
|
263
295
|
def __init__(self, name: str, category: str = DEFAULT_CATEGORY, counter: int | None = None) -> None:
|
|
264
296
|
self.name: str = _build_name_with_counter(name, counter)
|
|
@@ -278,8 +310,12 @@ class TimerContext:
|
|
|
278
310
|
"""Decorate a function to time its execution under this context.
|
|
279
311
|
|
|
280
312
|
Coroutine functions are wrapped so the timing spans the entire ``await``, not just
|
|
281
|
-
creation of the coroutine object.
|
|
282
|
-
|
|
313
|
+
creation of the coroutine object. So is a coroutine returned by a plain function,
|
|
314
|
+
typically another decorator stacked on an ``async def``.
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
TypeError: If ``func`` is a generator or async generator function. A wrapper
|
|
318
|
+
would time only creation of the generator object, not its iteration.
|
|
283
319
|
"""
|
|
284
320
|
if inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func):
|
|
285
321
|
msg = (
|
|
@@ -297,10 +333,20 @@ class TimerContext:
|
|
|
297
333
|
@functools.wraps(func)
|
|
298
334
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
299
335
|
with self:
|
|
300
|
-
|
|
336
|
+
result = func(*args, **kwargs)
|
|
337
|
+
if inspect.iscoroutine(result):
|
|
338
|
+
# A decorator between this one and an ``async def`` hides the coroutine function,
|
|
339
|
+
# so the call above only created the coroutine. Time awaiting it as well.
|
|
340
|
+
return cast("R", self._time_await(result))
|
|
341
|
+
return result
|
|
301
342
|
|
|
302
343
|
return wrapper
|
|
303
344
|
|
|
345
|
+
async def _time_await(self, coroutine: Coroutine[object, object, T]) -> T:
|
|
346
|
+
"""Await a coroutine that was created outside this context, timing the whole await."""
|
|
347
|
+
with self:
|
|
348
|
+
return await coroutine
|
|
349
|
+
|
|
304
350
|
def _wrap_async(self, func: Callable[P, Coroutine[object, object, T]]) -> Callable[P, Coroutine[object, object, T]]:
|
|
305
351
|
"""Wrap a coroutine function so the timing spans the whole await."""
|
|
306
352
|
|
|
@@ -330,12 +376,30 @@ def _basic_name_without_counter(name: str) -> str:
|
|
|
330
376
|
|
|
331
377
|
|
|
332
378
|
def get_execution_times_report(*, flatten: bool = True) -> str:
|
|
333
|
-
"""Get a formatted report of all recorded sections
|
|
379
|
+
"""Get a formatted, indented report of all recorded sections.
|
|
380
|
+
|
|
381
|
+
Each line shows a section's accumulated seconds and its share of the total time.
|
|
382
|
+
Indentation reflects nesting.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
flatten: Merge ``counter`` variants such as ``step[0]`` and ``step[1]`` into ``step``.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
The report, or ``""`` if nothing has been recorded.
|
|
389
|
+
"""
|
|
334
390
|
return _TIMER.report_timings(flatten=flatten)
|
|
335
391
|
|
|
336
392
|
|
|
337
393
|
def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None = None) -> None:
|
|
338
|
-
"""Log the
|
|
394
|
+
"""Log the report from :func:`get_execution_times_report` at ``INFO`` level.
|
|
395
|
+
|
|
396
|
+
Logs a warning instead if nothing has been recorded. Does nothing, and skips building
|
|
397
|
+
the report, if the logger has ``INFO`` disabled.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
flatten: Merge ``counter`` variants of a section.
|
|
401
|
+
logger: The logger to use. Defaults to the ``execution_timer._timer`` logger.
|
|
402
|
+
"""
|
|
339
403
|
target = logger if logger is not None else _LOGGER
|
|
340
404
|
if target.isEnabledFor(logging.INFO):
|
|
341
405
|
report = get_execution_times_report(flatten=flatten)
|
|
@@ -347,7 +411,15 @@ def log_execution_times(*, flatten: bool = True, logger: logging.Logger | None =
|
|
|
347
411
|
|
|
348
412
|
|
|
349
413
|
def get_execution_timings(*, flatten: bool = True) -> dict[tuple[str, ...], TimingReport]:
|
|
350
|
-
"""Get elapsed seconds and category for every recorded section
|
|
414
|
+
"""Get elapsed seconds and category for every recorded section.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
flatten: Merge ``counter`` variants of a section.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
A new dictionary keyed by section path, such as ``("solve", "step")``. Changing it
|
|
421
|
+
does not affect the recorded timings.
|
|
422
|
+
"""
|
|
351
423
|
return _TIMER.get_execution_timings(flatten=flatten)
|
|
352
424
|
|
|
353
425
|
|
|
@@ -377,34 +449,79 @@ def _build_payload(*, flatten: bool = True) -> TimingsPayload:
|
|
|
377
449
|
|
|
378
450
|
|
|
379
451
|
def get_execution_times_json(*, flatten: bool = True, indent: int | None = 2) -> str:
|
|
380
|
-
"""Get all timings as a JSON
|
|
452
|
+
"""Get all timings as a JSON document shaped like :class:`TimingsPayload`.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
flatten: Merge ``counter`` variants of a section.
|
|
456
|
+
indent: Passed to :func:`json.dumps`; ``None`` gives the most compact output.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
The JSON text.
|
|
460
|
+
"""
|
|
381
461
|
return json.dumps(_build_payload(flatten=flatten), indent=indent)
|
|
382
462
|
|
|
383
463
|
|
|
384
464
|
def save_execution_timings_json(path: str | Path, *, flatten: bool = True, indent: int | None = 2) -> Path:
|
|
385
|
-
"""Write
|
|
465
|
+
"""Write the JSON from :func:`get_execution_times_json` to a UTF-8 file.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
path: The file to write, replacing it if it exists. Its directory must exist.
|
|
469
|
+
flatten: Merge ``counter`` variants of a section.
|
|
470
|
+
indent: Passed to :func:`json.dumps`.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
The path written to.
|
|
474
|
+
"""
|
|
386
475
|
out = Path(path)
|
|
387
476
|
_ = out.write_text(get_execution_times_json(flatten=flatten, indent=indent) + "\n", encoding="utf-8")
|
|
388
477
|
return out
|
|
389
478
|
|
|
390
479
|
|
|
391
480
|
def get_total_time() -> float:
|
|
392
|
-
"""Get total elapsed seconds across all top-level sections.
|
|
481
|
+
"""Get total elapsed seconds across all top-level sections.
|
|
482
|
+
|
|
483
|
+
Nested sections are part of their parent's time, so they are not added again.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
The total, or ``0.0`` if nothing has been recorded.
|
|
487
|
+
"""
|
|
393
488
|
return _TIMER.compute_total_time()
|
|
394
489
|
|
|
395
490
|
|
|
396
491
|
def get_total_category_time(category: str) -> float:
|
|
397
|
-
"""Get total elapsed seconds in a category
|
|
492
|
+
"""Get total elapsed seconds in a category.
|
|
493
|
+
|
|
494
|
+
Only the top-most section of the category counts: a section nested inside another of
|
|
495
|
+
the same category is part of that section's time and is not added again.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
category: The category to total.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
The total, or ``0.0`` for a category with no sections.
|
|
502
|
+
"""
|
|
398
503
|
return _TIMER.compute_total_category_time(category)
|
|
399
504
|
|
|
400
505
|
|
|
401
506
|
def clear_execution_timings() -> None:
|
|
402
|
-
"""
|
|
507
|
+
"""Discard all recorded timings.
|
|
508
|
+
|
|
509
|
+
A section that is active during the clear is not recorded when it exits. Sections
|
|
510
|
+
started inside it afterwards are recorded as top-level sections. Nesting rules are kept;
|
|
511
|
+
see :func:`clear_forbidden_nesting`.
|
|
512
|
+
"""
|
|
403
513
|
_TIMER.clear()
|
|
404
514
|
|
|
405
515
|
|
|
406
516
|
def register_forbidden_nesting(outer: str, inner: str) -> None:
|
|
407
|
-
"""Forbid
|
|
517
|
+
"""Forbid sections of category ``inner`` directly inside sections of category ``outer``.
|
|
518
|
+
|
|
519
|
+
Entering such a section raises :class:`ValueError`. Only the direct parent is checked.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
outer: The enclosing section's category.
|
|
523
|
+
inner: The category that may not appear directly inside it.
|
|
524
|
+
"""
|
|
408
525
|
_TIMER.register_forbidden_nesting(outer, inner)
|
|
409
526
|
|
|
410
527
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: executiontimer
|
|
3
|
+
Version: 1.0.3
|
|
4
|
+
Summary: Hierarchical execution timing with user-defined categories.
|
|
5
|
+
Project-URL: Homepage, https://github.com/seba2390/ExecutionTimer
|
|
6
|
+
Project-URL: Documentation, https://seba2390.github.io/ExecutionTimer/
|
|
7
|
+
Project-URL: Repository, https://github.com/seba2390/ExecutionTimer
|
|
8
|
+
Project-URL: Issues, https://github.com/seba2390/ExecutionTimer/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md
|
|
10
|
+
Author: Sebastian Yde Madsen
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: benchmark,context-manager,decorator,execution-time,instrumentation,performance,profiling,timing
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
24
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
25
|
+
Classifier: Topic :: Software Development
|
|
26
|
+
Classifier: Topic :: Software Development :: Testing
|
|
27
|
+
Classifier: Topic :: System :: Benchmark
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<picture>
|
|
34
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-dark.svg">
|
|
35
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-light.svg">
|
|
36
|
+
<img src="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-light.svg" alt="executiontimer" width="520">
|
|
37
|
+
</picture>
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
<p align="center">
|
|
41
|
+
<a href="https://pypi.org/project/executiontimer/"><img src="https://img.shields.io/pypi/v/executiontimer?color=blue" alt="PyPI version"></a>
|
|
42
|
+
<a href="https://pypi.org/project/executiontimer/"><img src="https://img.shields.io/pypi/pyversions/executiontimer" alt="Python versions"></a>
|
|
43
|
+
<a href="https://github.com/seba2390/ExecutionTimer/actions/workflows/ci.yml"><img src="https://github.com/seba2390/ExecutionTimer/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
44
|
+
<a href="https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
|
|
45
|
+
<a href="https://seba2390.github.io/ExecutionTimer/"><img src="https://img.shields.io/badge/docs-GitHub%20Pages-4F46E5" alt="Documentation"></a>
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
Time named sections of your code with a `with` block or a decorator. Sections nested inside
|
|
49
|
+
each other form a tree, so you see where the time actually went, not just one number at the
|
|
50
|
+
end.
|
|
51
|
+
|
|
52
|
+
Zero dependencies. Fully typed. Works with threads and `asyncio`.
|
|
53
|
+
|
|
54
|
+
## Documentation
|
|
55
|
+
[seba2390.github.io/ExecutionTimer](https://seba2390.github.io/ExecutionTimer/)
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install executiontimer
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires Python 3.11+. The install name is `executiontimer`; the import name is
|
|
64
|
+
`execution_timer`.
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import time
|
|
70
|
+
|
|
71
|
+
from execution_timer import TimerContext, get_execution_times_report
|
|
72
|
+
|
|
73
|
+
with TimerContext("load_data"):
|
|
74
|
+
time.sleep(0.12)
|
|
75
|
+
|
|
76
|
+
with TimerContext("solve"):
|
|
77
|
+
for i in range(3):
|
|
78
|
+
with TimerContext("step", category="gpu", counter=i):
|
|
79
|
+
time.sleep(0.05)
|
|
80
|
+
with TimerContext("postprocess", category="cpu"):
|
|
81
|
+
time.sleep(0.03)
|
|
82
|
+
|
|
83
|
+
print(get_execution_times_report())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
Total time: 0.3156 s.
|
|
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%)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`TimerContext` also works as a decorator, including on `async def` functions:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
@TimerContext("preprocess")
|
|
99
|
+
def preprocess(rows: list[str]) -> list[str]:
|
|
100
|
+
return [row.strip() for row in rows]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Learn more
|
|
104
|
+
|
|
105
|
+
- [Getting started](https://seba2390.github.io/ExecutionTimer/getting-started.html)
|
|
106
|
+
- User guide: [timing code](https://seba2390.github.io/ExecutionTimer/guide/timing-code.html),
|
|
107
|
+
[counters](https://seba2390.github.io/ExecutionTimer/guide/counters.html),
|
|
108
|
+
[categories](https://seba2390.github.io/ExecutionTimer/guide/categories.html),
|
|
109
|
+
[reports and JSON export](https://seba2390.github.io/ExecutionTimer/guide/reports.html),
|
|
110
|
+
[threads, asyncio and generators](https://seba2390.github.io/ExecutionTimer/guide/concurrency.html)
|
|
111
|
+
- [API reference](https://seba2390.github.io/ExecutionTimer/api.html)
|
|
112
|
+
- [Changelog](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) and
|
|
113
|
+
[contributing guide](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md)
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
execution_timer/__init__.py,sha256=chCRC3XhPx8siDmgixnvyOGEfS1-6LOQOKv7e6s6hLs,959
|
|
2
|
+
execution_timer/_timer.py,sha256=ld91tKWPwAm5ewK35hBljdqfMGkgf7LxeDL2l9GlWwg,21372
|
|
3
|
+
execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
executiontimer-1.0.3.dist-info/METADATA,sha256=Lh0LaMQBbmoTkGM4k_x2JKBe6Q95sSW-esmDjOTIfgg,4957
|
|
5
|
+
executiontimer-1.0.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
executiontimer-1.0.3.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
|
|
7
|
+
executiontimer-1.0.3.dist-info/RECORD,,
|
|
@@ -1,329 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: executiontimer
|
|
3
|
-
Version: 1.0.1
|
|
4
|
-
Summary: Hierarchical execution timing with user-defined categories.
|
|
5
|
-
Project-URL: Homepage, https://github.com/seba2390/ExecutionTimer
|
|
6
|
-
Project-URL: Documentation, https://github.com/seba2390/ExecutionTimer#readme
|
|
7
|
-
Project-URL: Repository, https://github.com/seba2390/ExecutionTimer
|
|
8
|
-
Project-URL: Issues, https://github.com/seba2390/ExecutionTimer/issues
|
|
9
|
-
Project-URL: Changelog, https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md
|
|
10
|
-
Author: Sebastian Yde Madsen
|
|
11
|
-
License-Expression: MIT
|
|
12
|
-
License-File: LICENSE
|
|
13
|
-
Keywords: benchmark,context-manager,decorator,execution-time,instrumentation,performance,profiling,timing
|
|
14
|
-
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
-
Classifier: Intended Audience :: Developers
|
|
16
|
-
Classifier: Intended Audience :: Science/Research
|
|
17
|
-
Classifier: Operating System :: OS Independent
|
|
18
|
-
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
-
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
24
|
-
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
25
|
-
Classifier: Topic :: Software Development
|
|
26
|
-
Classifier: Topic :: Software Development :: Testing
|
|
27
|
-
Classifier: Topic :: System :: Benchmark
|
|
28
|
-
Classifier: Typing :: Typed
|
|
29
|
-
Requires-Python: >=3.11
|
|
30
|
-
Description-Content-Type: text/markdown
|
|
31
|
-
|
|
32
|
-
<p align="center">
|
|
33
|
-
<picture>
|
|
34
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-dark.svg">
|
|
35
|
-
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-light.svg">
|
|
36
|
-
<img src="https://raw.githubusercontent.com/seba2390/ExecutionTimer/main/assets/logo-light.svg" alt="executiontimer" width="520">
|
|
37
|
-
</picture>
|
|
38
|
-
</p>
|
|
39
|
-
|
|
40
|
-
<p align="center">
|
|
41
|
-
<a href="https://pypi.org/project/executiontimer/"><img src="https://img.shields.io/pypi/v/executiontimer?color=blue" alt="PyPI version"></a>
|
|
42
|
-
<a href="https://pypi.org/project/executiontimer/"><img src="https://img.shields.io/pypi/pyversions/executiontimer" alt="Python versions"></a>
|
|
43
|
-
<a href="https://github.com/seba2390/ExecutionTimer/actions/workflows/ci.yml"><img src="https://github.com/seba2390/ExecutionTimer/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
44
|
-
<a href="https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
|
|
45
|
-
<img src="https://img.shields.io/badge/types-py.typed-blue" alt="Typed">
|
|
46
|
-
</p>
|
|
47
|
-
|
|
48
|
-
Time named sections of your code with a `with` block or a decorator. Nested sections
|
|
49
|
-
automatically form a hierarchy, so you get a breakdown of where time actually went — not
|
|
50
|
-
just a single number.
|
|
51
|
-
|
|
52
|
-
Zero dependencies. Fully type annotated. Works with threads and `asyncio`.
|
|
53
|
-
|
|
54
|
-
## Features
|
|
55
|
-
|
|
56
|
-
- ⏱️ **One primitive** — `TimerContext` is both a context manager and a decorator
|
|
57
|
-
- 🌳 **Automatic hierarchy** — nesting `with` blocks nests the report, no wiring required
|
|
58
|
-
- 🏷️ **User-defined categories** — tag sections with any string (`"gpu"`, `"io"`, `"db"`) and get per-category totals
|
|
59
|
-
- ⚡ **Native async** — decorating an `async def` times the whole `await`, not the coroutine object
|
|
60
|
-
- 🧵 **Thread and task safe** — context stacks are isolated per thread and per asyncio task,
|
|
61
|
-
including on free-threaded Python builds
|
|
62
|
-
- 🔢 **Loop counters** — time each iteration separately, then merge them back together
|
|
63
|
-
- 📤 **JSON export** — structured output for dashboards, CI, or an LLM
|
|
64
|
-
- 🚫 **Nesting rules** — optionally forbid one category inside another to catch mistakes early
|
|
65
|
-
- 📦 **Zero dependencies**
|
|
66
|
-
|
|
67
|
-
## Installation
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
pip install executiontimer
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
uv add executiontimer
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Requires Python 3.11+.
|
|
78
|
-
|
|
79
|
-
> **Note** — the install name is `executiontimer`, the import name is `execution_timer`:
|
|
80
|
-
>
|
|
81
|
-
> ```python
|
|
82
|
-
> from execution_timer import TimerContext
|
|
83
|
-
> ```
|
|
84
|
-
|
|
85
|
-
## Quick start
|
|
86
|
-
|
|
87
|
-
```python
|
|
88
|
-
import time
|
|
89
|
-
|
|
90
|
-
from execution_timer import TimerContext, get_execution_times_report
|
|
91
|
-
|
|
92
|
-
with TimerContext("load_data"):
|
|
93
|
-
time.sleep(0.12)
|
|
94
|
-
|
|
95
|
-
with TimerContext("solve"):
|
|
96
|
-
for i in range(3):
|
|
97
|
-
with TimerContext("step", category="gpu", counter=i):
|
|
98
|
-
time.sleep(0.05)
|
|
99
|
-
with TimerContext("postprocess", category="cpu"):
|
|
100
|
-
time.sleep(0.03)
|
|
101
|
-
|
|
102
|
-
print(get_execution_times_report())
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
```text
|
|
106
|
-
Total time: 0.3156 s.
|
|
107
|
-
|
|
108
|
-
load_data: 0.1219 s (38.62%)
|
|
109
|
-
solve: 0.1937 s (61.38%)
|
|
110
|
-
.. step: 0.1585 s (50.24%)
|
|
111
|
-
.. postprocess: 0.0350 s (11.10%)
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
Indentation reflects nesting. Percentages are relative to the total of all top-level
|
|
115
|
-
sections, so nested entries show their share of the whole run.
|
|
116
|
-
|
|
117
|
-
## Usage
|
|
118
|
-
|
|
119
|
-
### As a decorator
|
|
120
|
-
|
|
121
|
-
```python
|
|
122
|
-
from execution_timer import TimerContext
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
@TimerContext("preprocess")
|
|
126
|
-
def preprocess(rows: list[str]) -> list[str]:
|
|
127
|
-
return [row.strip() for row in rows]
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
Coroutine functions are supported natively — the timing spans the entire `await`:
|
|
131
|
-
|
|
132
|
-
```python
|
|
133
|
-
@TimerContext("fetch", category="io")
|
|
134
|
-
async def fetch(url: str) -> bytes: ...
|
|
135
|
-
```
|
|
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
|
-
|
|
141
|
-
### Counters
|
|
142
|
-
|
|
143
|
-
Pass `counter=i` to time loop iterations separately. The report merges them by default
|
|
144
|
-
(`flatten=True`) and keeps them apart when you ask for it:
|
|
145
|
-
|
|
146
|
-
```python
|
|
147
|
-
for i in range(3):
|
|
148
|
-
with TimerContext("step", counter=i):
|
|
149
|
-
...
|
|
150
|
-
|
|
151
|
-
get_execution_timings(flatten=True) # {("step",): {"time": 0.158, ...}}
|
|
152
|
-
get_execution_timings(flatten=False) # {("step[0]",): ..., ("step[1]",): ..., ...}
|
|
153
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
### Categories
|
|
165
|
-
|
|
166
|
-
Categories are plain strings — use whatever fits your domain:
|
|
167
|
-
|
|
168
|
-
```python
|
|
169
|
-
from execution_timer import get_total_category_time
|
|
170
|
-
|
|
171
|
-
with TimerContext("matmul", category="gpu"):
|
|
172
|
-
...
|
|
173
|
-
|
|
174
|
-
get_total_category_time("gpu")
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
`get_total_category_time` counts only the *top-most* section of a category, so a `gpu`
|
|
178
|
-
section nested inside another `gpu` section is not double-counted.
|
|
179
|
-
|
|
180
|
-
Repeated calls to the same path accumulate time. If its category changes, the latest
|
|
181
|
-
category applies to that path's entire accumulated time. Use consistent categories per
|
|
182
|
-
path when you need separate category totals.
|
|
183
|
-
|
|
184
|
-
You can also forbid a category from appearing inside another, which raises a `ValueError`
|
|
185
|
-
as soon as the invalid nesting happens:
|
|
186
|
-
|
|
187
|
-
```python
|
|
188
|
-
from execution_timer import register_forbidden_nesting
|
|
189
|
-
|
|
190
|
-
register_forbidden_nesting(outer="gpu", inner="cpu")
|
|
191
|
-
|
|
192
|
-
with TimerContext("kernel", category="gpu"):
|
|
193
|
-
with TimerContext("reduce", category="cpu"): # ValueError
|
|
194
|
-
...
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
### JSON export
|
|
198
|
-
|
|
199
|
-
`get_execution_times_json` and `save_execution_timings_json` emit a structured snapshot —
|
|
200
|
-
convenient for dashboards, CI artifacts, or handing to a language model:
|
|
201
|
-
|
|
202
|
-
```python
|
|
203
|
-
from execution_timer import save_execution_timings_json
|
|
204
|
-
|
|
205
|
-
save_execution_timings_json("timings.json")
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
```json
|
|
209
|
-
{
|
|
210
|
-
"total_time": 0.31556,
|
|
211
|
-
"total_category_time": { "cpu": 0.035024, "default": 0.31556, "gpu": 0.158528 },
|
|
212
|
-
"sections": [
|
|
213
|
-
{ "name": "load_data", "path": ["load_data"], "time": 0.121869, "category": "default" },
|
|
214
|
-
{ "name": "solve", "path": ["solve"], "time": 0.193691, "category": "default" },
|
|
215
|
-
{ "name": "step", "path": ["solve", "step"], "time": 0.158528, "category": "gpu" },
|
|
216
|
-
{ "name": "postprocess", "path": ["solve", "postprocess"], "time": 0.035024, "category": "cpu" }
|
|
217
|
-
]
|
|
218
|
-
}
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
Sections and totals come from one snapshot. Category totals use the original paths,
|
|
222
|
-
even when flattening merges sections with different categories.
|
|
223
|
-
|
|
224
|
-
### Concurrency
|
|
225
|
-
|
|
226
|
-
The recorded timings live in one process-wide registry guarded by a lock. The *active
|
|
227
|
-
context stack* is stored in a `ContextVar`, so it is isolated per thread and per asyncio
|
|
228
|
-
task: concurrently recorded sections nest independently and merge into a single report.
|
|
229
|
-
|
|
230
|
-
```python
|
|
231
|
-
async def worker(n: int) -> None:
|
|
232
|
-
with TimerContext(f"task{n}"):
|
|
233
|
-
await fetch(...) # recorded as ("task{n}", "fetch")
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
await asyncio.gather(worker(0), worker(1))
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
Overlapping calls to the same section path are supported: each call keeps its own start
|
|
240
|
-
time, and their durations are added together. These totals measure accumulated elapsed
|
|
241
|
-
time and can exceed wall-clock duration. Use distinct names (or `counter=`) to report
|
|
242
|
-
concurrent calls separately.
|
|
243
|
-
|
|
244
|
-
New asyncio tasks inherit the timing context in which they are created. Their sections
|
|
245
|
-
nest under that parent; changes to each task's active stack remain independent. Await
|
|
246
|
-
child tasks inside the parent section if you want the parent duration to include them.
|
|
247
|
-
|
|
248
|
-
New threads do *not* inherit the timing context, so sections recorded in a thread
|
|
249
|
-
appear at the top level of the report, and their time is added to the total alongside
|
|
250
|
-
the section that started the thread. To nest thread work under the current section, run
|
|
251
|
-
it with `contextvars.copy_context().run(...)` or `asyncio.to_thread(...)`. Either way,
|
|
252
|
-
concurrent threads accumulate overlapping time. (Free-threaded builds of Python 3.14 make
|
|
253
|
-
threads inherit the context by default.)
|
|
254
|
-
|
|
255
|
-
Generators run in their caller's context. A `with TimerContext(...)` block that stays open
|
|
256
|
-
across a `yield` therefore also contains whatever the caller times while the generator is
|
|
257
|
-
paused, and its duration includes that paused time. Close sections before yielding, or
|
|
258
|
-
time the loop that consumes the generator instead.
|
|
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. If warnings are configured as errors, the warning is
|
|
264
|
-
raised only after that cleanup, so the timings and nesting stay consistent.
|
|
265
|
-
|
|
266
|
-
### Reusing contexts and clearing timings
|
|
267
|
-
|
|
268
|
-
A `TimerContext` can be reused, nested within itself, or shared by concurrent calls.
|
|
269
|
-
For a tight loop, reuse a context to avoid constructing one on every iteration:
|
|
270
|
-
|
|
271
|
-
```python
|
|
272
|
-
step_timer = TimerContext("step")
|
|
273
|
-
for item in items:
|
|
274
|
-
with step_timer:
|
|
275
|
-
process(item)
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
Timings accumulate until `clear_execution_timings()` is called. Clearing also discards
|
|
279
|
-
samples from sections that were already active, without disturbing their nesting stack.
|
|
280
|
-
Sections started after the clear are recorded normally; if their parent was cleared, they
|
|
281
|
-
are reported as top-level sections and count toward the total. Reports include completed calls;
|
|
282
|
-
an active section's current duration is added only when it exits.
|
|
283
|
-
|
|
284
|
-
### Measuring overhead
|
|
285
|
-
|
|
286
|
-
Run the repeatable benchmark with `uv run python benchmarks/overhead.py`. It measures
|
|
287
|
-
fresh and reused contexts, sync and async decorators, nesting, and reporting. Compare
|
|
288
|
-
results using the same interpreter and machine; see [benchmarks/README.md](https://github.com/seba2390/ExecutionTimer/blob/main/benchmarks/README.md).
|
|
289
|
-
`log_execution_times()` skips building a report when its logger has `INFO` disabled.
|
|
290
|
-
|
|
291
|
-
## API
|
|
292
|
-
|
|
293
|
-
| Function | Description |
|
|
294
|
-
| --- | --- |
|
|
295
|
-
| `TimerContext(name, category=DEFAULT_CATEGORY, counter=None)` | Context manager **and** decorator for timing a section. |
|
|
296
|
-
| `get_execution_times_report(*, flatten=True)` | Formatted, indented report of all sections (`""` if none). |
|
|
297
|
-
| `log_execution_times(*, flatten=True, logger=None)` | Log that report at `INFO` level (a warning if empty). |
|
|
298
|
-
| `get_execution_timings(*, flatten=True)` | Timings as `dict[tuple[str, ...], TimingReport]`. |
|
|
299
|
-
| `get_execution_times_json(*, flatten=True, indent=2)` | All timings as a JSON string. |
|
|
300
|
-
| `save_execution_timings_json(path, *, flatten=True, indent=2)` | Write timings to a JSON file; returns the `Path`. |
|
|
301
|
-
| `get_total_time()` | Total seconds across all top-level sections. |
|
|
302
|
-
| `get_total_category_time(category)` | Total seconds in a category (top-most entries only). |
|
|
303
|
-
| `clear_execution_timings()` | Reset all recorded timings. |
|
|
304
|
-
| `register_forbidden_nesting(outer, inner)` | Forbid `inner` category directly inside `outer`. |
|
|
305
|
-
| `clear_forbidden_nesting()` | Remove all nesting rules. |
|
|
306
|
-
|
|
307
|
-
`flatten=True` merges `counter` variants of a section back together; `flatten=False`
|
|
308
|
-
keeps each `name[i]` separate.
|
|
309
|
-
|
|
310
|
-
Exported types: `TimingReport`, `SectionRecord`, `TimingsPayload`, and `DEFAULT_CATEGORY`.
|
|
311
|
-
The package ships a `py.typed` marker, so type checkers use the inline annotations.
|
|
312
|
-
|
|
313
|
-
## Development
|
|
314
|
-
|
|
315
|
-
Requires [uv](https://docs.astral.sh/uv/).
|
|
316
|
-
|
|
317
|
-
```bash
|
|
318
|
-
uv sync
|
|
319
|
-
uv run pytest --cov
|
|
320
|
-
uv run ruff check --fix && uv run ruff format
|
|
321
|
-
uv run basedpyright
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
See [CONTRIBUTING.md](https://github.com/seba2390/ExecutionTimer/blob/main/CONTRIBUTING.md) for the full workflow, and
|
|
325
|
-
[CHANGELOG.md](https://github.com/seba2390/ExecutionTimer/blob/main/CHANGELOG.md) for release notes.
|
|
326
|
-
|
|
327
|
-
## License
|
|
328
|
-
|
|
329
|
-
MIT — see [LICENSE](https://github.com/seba2390/ExecutionTimer/blob/main/LICENSE).
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
execution_timer/__init__.py,sha256=SDCT34iz3KKhr1CyiCpvMrjjdf1umy55sScSp4bKUBo,959
|
|
2
|
-
execution_timer/_timer.py,sha256=4RP4SDki9TasaCfk56lmJX54EiYnQsvHOd5WnZuuoxI,16778
|
|
3
|
-
execution_timer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
executiontimer-1.0.1.dist-info/METADATA,sha256=o5McC2gZg2rh2G65qv0Hh6tdmGo6Mk_TfPfym_OmKhA,13612
|
|
5
|
-
executiontimer-1.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
-
executiontimer-1.0.1.dist-info/licenses/LICENSE,sha256=rkJbDOYRrUX8k3S55pPiEquJXZGh07KvD01F1sBBppw,1077
|
|
7
|
-
executiontimer-1.0.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|