From 6ceb3ea4aeb55367db0e3e45dbe5d768dfa01f6e Mon Sep 17 00:00:00 2001
From: HH-MWB <50187675+HH-MWB@users.noreply.github.com>
Date: Wed, 21 Jan 2026 21:13:30 -0500
Subject: [PATCH 1/9] switch to pyenforce hook and address linting issues
---
.pre-commit-config.yaml | 42 +++++------
pyproject.toml | 16 -----
tests/__init__.py | 1 +
tests/conftest.py | 145 +++++++++++++++++++++++++-------------
tests/test_elapsedtime.py | 23 ++++--
tests/test_stopwatch.py | 47 +++++++-----
tests/test_timer.py | 51 +++++++++-----
timerun.py | 83 +++++++++++++---------
8 files changed, 244 insertions(+), 164 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 43dfaf4..2cdaa40 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,3 +1,6 @@
+default_language_version:
+ python: python3.11
+
repos:
- repo: 'https://github.com/pre-commit/pre-commit-hooks'
rev: v6.0.0
@@ -11,35 +14,24 @@ repos:
- id: check-yaml
- id: check-toml
- - repo: https://github.com/adrienverge/yamllint
- rev: v1.37.1
- hooks:
- - id: yamllint
-
- - repo: https://github.com/pycqa/isort
- rev: 6.0.1
- hooks:
- - id: isort
-
- - repo: https://github.com/psf/black
- rev: 25.1.0
- hooks:
- - id: black
-
- - repo: https://github.com/pre-commit/mirrors-mypy
- rev: v1.17.1
+ - repo: https://github.com/HH-MWB/pyenforce
+ rev: v0.1.0
hooks:
+ - id: ruff-format
+ - id: ruff-check
- id: mypy
-
- - repo: https://github.com/pylint-dev/pylint
- rev: v3.3.8
- hooks:
+ additional_dependencies:
+ - ".[mypy]" # Required to re-adds mypy as a dependency
+ - pytest
- id: pylint
additional_dependencies:
+ - ".[pylint]" # Required to re-adds Pylint as a dependency
- pytest
+ - id: bandit
+ - id: semgrep
+ - id: vulture
- - repo: https://github.com/pycqa/bandit
- rev: '1.8.6'
+ - repo: https://github.com/adrienverge/yamllint
+ rev: v1.37.1
hooks:
- - id: bandit
- args: ['-c', 'pyproject.toml']
+ - id: yamllint
diff --git a/pyproject.toml b/pyproject.toml
index 4651e33..8f9f3d5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,19 +49,3 @@ license-files = ["LICENSE"]
[tool.setuptools.dynamic]
version = { attr = "timerun.__version__" }
-
-[tool.bandit]
-skips = ["B101"] # Skip assert_used test
-
-[tool.black]
-line-length = 79
-target-version = [
- "py39",
- "py310",
- "py311",
- "py312",
- "py313",
-]
-
-[tool.isort]
-profile = "black"
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29..683ed66 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""Test suite for timerun."""
diff --git a/tests/conftest.py b/tests/conftest.py
index 9709a79..9a42247 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,36 +1,22 @@
"""A collection of shared PyTest fixtures for timerun."""
-from contextlib import contextmanager
-from typing import Callable, ContextManager, Iterable, Iterator, Tuple
+from collections.abc import Callable, Iterable, Iterator
+from contextlib import AbstractContextManager, contextmanager
from unittest.mock import Mock
-from pytest import MonkeyPatch, fixture
+import pytest
from timerun import ElapsedTime, Stopwatch, Timer
-__all__: Tuple[str, ...] = (
- # -- Patcheres --
- "patch_clock",
- "patch_split",
- # -- Initiated Instances --
- "stopwatch",
- "timer",
- # -- Elapsed Time --
- "elapsed_1_ns",
- "elapsed_100_ns",
- "elapsed_1_ms",
- "elapsed_1_pt_5_ms",
- "elapsed_1_sec",
-)
-
-
# =========================================================================== #
# Patcheres #
# =========================================================================== #
-@fixture
-def patch_clock(monkeypatch: MonkeyPatch) -> Callable[[int], ContextManager]:
+@pytest.fixture
+def patch_clock(
+ monkeypatch: pytest.MonkeyPatch,
+) -> Callable[[int], AbstractContextManager[None]]:
"""Patch the clock method in Stopwatch.
Parameters
@@ -40,14 +26,15 @@ def patch_clock(monkeypatch: MonkeyPatch) -> Callable[[int], ContextManager]:
Returns
-------
- Callable[[int], ContextManager]
+ Callable[[int], AbstractContextManager[None]]
A context manager takes integer argument and patch that value as
the return value of the clock method.
Examples
--------
- >>> with patch_clock(elapsed_ns=1):
+ >>> with patch_clock(1):
... pass
+
"""
@contextmanager
@@ -58,34 +45,41 @@ def patch(elapsed_ns: int) -> Iterator[None]:
----------
elapsed_ns : int
The value should be returned by the clock method.
+
+ Yields
+ ------
+ None
+ Control is yielded back to the caller.
+
"""
- monkeypatch.setattr(Stopwatch, "_clock", lambda self: elapsed_ns)
+ monkeypatch.setattr(Stopwatch, "_clock", lambda _: elapsed_ns)
yield
return patch
-@fixture
+@pytest.fixture
def patch_split(
- monkeypatch: MonkeyPatch,
-) -> Callable[[Iterable[int]], ContextManager]:
+ monkeypatch: pytest.MonkeyPatch,
+) -> Callable[[Iterable[int]], AbstractContextManager[None]]:
"""Patch the split method in Timer.
Parameters
----------
monkeypatch : MonkeyPatch
- The fixture has been used to patch the split method.
+ The fixture has been used to patch the split method.
Returns
-------
- Callable[[Iterable[int]], ContextManager]
+ Callable[[Iterable[int]], AbstractContextManager[None]]
A context manager takes a list of integers as nanoseconds and
patch those as the return values of the elapse method.
Examples
--------
- >>> with patch_split(elapsed_times=[100, 200, 300]):
+ >>> with patch_split([100, 200, 300]):
... pass
+
"""
@contextmanager
@@ -96,11 +90,17 @@ def patch(elapsed_times: Iterable[int]) -> Iterator[None]:
----------
elapsed_times : Iterable[int]
The nanoseconds should be returned by the split method.
+
+ Yields
+ ------
+ None
+ Control is yielded back to the caller.
+
"""
mock_stopwatch = Mock(spec=["reset", "split"])
- mock_stopwatch.split.side_effect = [
- ElapsedTime(nanoseconds=t) for t in elapsed_times
- ]
+ mock_stopwatch.split.configure_mock(
+ side_effect=[ElapsedTime(nanoseconds=t) for t in elapsed_times],
+ )
monkeypatch.setattr(Timer, "_stopwatch", mock_stopwatch)
yield
@@ -113,17 +113,31 @@ def patch(elapsed_times: Iterable[int]) -> Iterator[None]:
# =========================================================================== #
-@fixture
+@pytest.fixture
def stopwatch() -> Stopwatch:
- """A newly created Stopwatch started at time ``0``."""
+ """Create a Stopwatch started at time ``0``.
+
+ Returns
+ -------
+ Stopwatch
+ A stopwatch started at time ``0``.
+
+ """
watch: Stopwatch = Stopwatch()
- watch._start = 0 # pylint: disable=protected-access
+ watch._start = 0 # pylint: disable=protected-access # noqa: SLF001
return watch
-@fixture
+@pytest.fixture
def timer() -> Timer:
- """A newly created Timer with unlimited storage size."""
+ """Create a Timer with unlimited storage size.
+
+ Returns
+ -------
+ Timer
+ A newly created Timer.
+
+ """
return Timer()
@@ -132,31 +146,66 @@ def timer() -> Timer:
# =========================================================================== #
-@fixture
+@pytest.fixture
def elapsed_1_ns() -> ElapsedTime:
- """Elapsed Time of 1 nanosecond."""
+ """Elapsed Time of 1 nanosecond.
+
+ Returns
+ -------
+ ElapsedTime
+ Elapsed time of 1 nanosecond.
+
+ """
return ElapsedTime(nanoseconds=1)
-@fixture
+@pytest.fixture
def elapsed_100_ns() -> ElapsedTime:
- """Elapsed Time of 100 nanoseconds."""
+ """Elapsed Time of 100 nanoseconds.
+
+ Returns
+ -------
+ ElapsedTime
+ Elapsed time of 100 nanoseconds.
+
+ """
return ElapsedTime(nanoseconds=100)
-@fixture
+@pytest.fixture
def elapsed_1_ms() -> ElapsedTime:
- """Elapsed Time of 1 microsecond."""
+ """Elapsed Time of 1 microsecond.
+
+ Returns
+ -------
+ ElapsedTime
+ Elapsed time of 1 microsecond.
+
+ """
return ElapsedTime(nanoseconds=1000)
-@fixture
+@pytest.fixture
def elapsed_1_pt_5_ms() -> ElapsedTime:
- """Elapsed Time of 1.5 microseconds."""
+ """Elapsed Time of 1.5 microseconds.
+
+ Returns
+ -------
+ ElapsedTime
+ Elapsed time of 1.5 microseconds.
+
+ """
return ElapsedTime(nanoseconds=1500)
-@fixture
+@pytest.fixture
def elapsed_1_sec() -> ElapsedTime:
- """Elapsed Time of 1 second."""
+ """Elapsed Time of 1 second.
+
+ Returns
+ -------
+ ElapsedTime
+ Elapsed time of 1 second.
+
+ """
return ElapsedTime(nanoseconds=int(1e9))
diff --git a/tests/test_elapsedtime.py b/tests/test_elapsedtime.py
index 99c598c..f54ff70 100644
--- a/tests/test_elapsedtime.py
+++ b/tests/test_elapsedtime.py
@@ -1,9 +1,11 @@
"""A collection of tests for class ``ElapsedTime``."""
+# pylint: disable=no-self-use,magic-value-comparison
+
from dataclasses import FrozenInstanceError
from datetime import timedelta
-from pytest import raises
+import pytest
from timerun import ElapsedTime
@@ -35,9 +37,10 @@ def test_modify_after_init(self, elapsed_1_ns: ElapsedTime) -> None:
----------
elapsed_1_ns : ElapsedTime
A ElapsedTime instance will be using to update attribute.
+
"""
- with raises(FrozenInstanceError):
- elapsed_1_ns.nanoseconds = 0 # type: ignore
+ with pytest.raises(FrozenInstanceError):
+ elapsed_1_ns.nanoseconds = 0 # type: ignore[misc]
assert elapsed_1_ns.nanoseconds == 1
@@ -84,11 +87,13 @@ def test_microseconds_accuracy(self, elapsed_1_ms: ElapsedTime) -> None:
----------
elapsed_1_ms : ElapsedTime
Elapsed Time of 1 microsecond.
+
"""
assert elapsed_1_ms.timedelta == timedelta(microseconds=1)
def test_nanoseconds_accuracy(
- self, elapsed_1_pt_5_ms: ElapsedTime
+ self,
+ elapsed_1_pt_5_ms: ElapsedTime,
) -> None:
"""Test using ElapsedTime of 1.5 microseconds.
@@ -99,6 +104,7 @@ def test_nanoseconds_accuracy(
----------
elapsed_1_pt_5_ms : ElapsedTime
Elapsed Time of 1.5 microseconds.
+
"""
assert elapsed_1_pt_5_ms.timedelta == timedelta(microseconds=1)
@@ -107,7 +113,8 @@ class TestStr:
"""Test suite for calling str function on ElapsedTime."""
def test_elapsed_time_seconds_as_decimals(
- self, elapsed_100_ns: ElapsedTime
+ self,
+ elapsed_100_ns: ElapsedTime,
) -> None:
"""Test elapsed time in seconds is in decimal.
@@ -118,11 +125,13 @@ def test_elapsed_time_seconds_as_decimals(
----------
elapsed_100_ns : ElapsedTime
Elapsed Time to be used to call ``str``.
+
"""
assert str(elapsed_100_ns) == "0:00:00.000000100"
def test_elapsed_time_seconds_as_integer(
- self, elapsed_1_sec: ElapsedTime
+ self,
+ elapsed_1_sec: ElapsedTime,
) -> None:
"""Test elapsed time in seconds is an integer.
@@ -133,6 +142,7 @@ def test_elapsed_time_seconds_as_integer(
----------
elapsed_1_sec : ElapsedTime
Elapsed Time to be used to call ``str``.
+
"""
assert str(elapsed_1_sec) == "0:00:01"
@@ -150,5 +160,6 @@ def test_repr(self, elapsed_100_ns: ElapsedTime) -> None:
----------
elapsed_100_ns : ElapsedTime
Elapsed Time to be used to call ``repr``.
+
"""
assert repr(elapsed_100_ns) == "ElapsedTime(nanoseconds=100)"
diff --git a/tests/test_stopwatch.py b/tests/test_stopwatch.py
index 8d5cca9..1828b1b 100644
--- a/tests/test_stopwatch.py
+++ b/tests/test_stopwatch.py
@@ -1,10 +1,18 @@
"""A collection of tests for class ``Stopwatch``."""
+# pylint: disable=no-self-use
+
+from __future__ import annotations
+
from time import perf_counter_ns, process_time_ns
-from typing import Callable
+from typing import TYPE_CHECKING
from timerun import ElapsedTime, Stopwatch
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from contextlib import AbstractContextManager
+
class TestInit:
"""Test suite for stopwatch initialization."""
@@ -13,7 +21,7 @@ def test_include_sleep(self) -> None:
"""Test initialize stopwatch take sleep in to count."""
stopwatch: Stopwatch = Stopwatch(count_sleep=True)
assert (
- stopwatch._clock # pylint: disable=protected-access
+ stopwatch._clock # pylint: disable=protected-access # noqa: SLF001
== perf_counter_ns
)
@@ -21,7 +29,7 @@ def test_exclude_sleep(self) -> None:
"""Test initialize stopwatch do not take sleep in to count."""
stopwatch: Stopwatch = Stopwatch(count_sleep=False)
assert (
- stopwatch._clock # pylint: disable=protected-access
+ stopwatch._clock # pylint: disable=protected-access # noqa: SLF001
== process_time_ns
)
@@ -30,15 +38,19 @@ def test_default_measurer(self) -> None:
default: Stopwatch = Stopwatch()
include: Stopwatch = Stopwatch(count_sleep=True)
assert (
- default._clock # pylint: disable=protected-access
- == include._clock # pylint: disable=protected-access
+ default._clock # pylint: disable=protected-access # noqa: SLF001
+ == include._clock # pylint: disable=protected-access # noqa: SLF001
)
class TestReset: # pylint: disable=too-few-public-methods
"""Test suite for starting stopwatch."""
- def test_reset(self, patch_clock: Callable, stopwatch: Stopwatch) -> None:
+ def test_reset(
+ self,
+ patch_clock: Callable[[int], AbstractContextManager[None]],
+ stopwatch: Stopwatch,
+ ) -> None:
"""Test to reset a stopwatch.
Expected to have a stopwatch whose `_start` attribute is not
@@ -50,11 +62,12 @@ def test_reset(self, patch_clock: Callable, stopwatch: Stopwatch) -> None:
Patcher has been used to set the starting time at ``1``.
stopwatch : Stopwatch
A started Stopwatch, which will be reset.
+
"""
- assert stopwatch._start != 1 # pylint: disable=protected-access
- with patch_clock(elapsed_ns=1):
+ assert stopwatch._start != 1 # pylint: disable=protected-access # noqa: SLF001
+ with patch_clock(1):
stopwatch.reset()
- assert stopwatch._start == 1 # pylint: disable=protected-access
+ assert stopwatch._start == 1 # pylint: disable=protected-access # noqa: SLF001
class TestSplit:
@@ -62,7 +75,7 @@ class TestSplit:
def test_calculation(
self,
- patch_clock: Callable,
+ patch_clock: Callable[[int], AbstractContextManager[None]],
stopwatch: Stopwatch,
elapsed_100_ns: ElapsedTime,
) -> None:
@@ -80,16 +93,17 @@ def test_calculation(
A stopwatch started at time ``0``.
elapsed_100_ns : ElapsedTime
Elapsed Time of 100 nanoseconds.
+
"""
- assert stopwatch._start == 0 # pylint: disable=protected-access
+ assert not stopwatch._start # pylint: disable=protected-access # noqa: SLF001
- with patch_clock(elapsed_ns=100):
+ with patch_clock(100):
elapsed: ElapsedTime = stopwatch.split()
assert elapsed == elapsed_100_ns
def test_split_multiple_times(
self,
- patch_clock: Callable,
+ patch_clock: Callable[[int], AbstractContextManager[None]],
stopwatch: Stopwatch,
elapsed_100_ns: ElapsedTime,
elapsed_1_ms: ElapsedTime,
@@ -111,13 +125,14 @@ def test_split_multiple_times(
Elapsed Time of 100 nanoseconds.
elapsed_1_ms : ElapsedTime
Elapsed Time of 1 microsecond.
+
"""
- assert stopwatch._start == 0 # pylint: disable=protected-access
+ assert not stopwatch._start # pylint: disable=protected-access # noqa: SLF001
- with patch_clock(elapsed_ns=100):
+ with patch_clock(100):
first_elapsed: ElapsedTime = stopwatch.split()
assert first_elapsed == elapsed_100_ns
- with patch_clock(elapsed_ns=1000):
+ with patch_clock(1000):
second_elapsed: ElapsedTime = stopwatch.split()
assert second_elapsed == elapsed_1_ms
diff --git a/tests/test_timer.py b/tests/test_timer.py
index 4e7e9ca..0ebf8b2 100644
--- a/tests/test_timer.py
+++ b/tests/test_timer.py
@@ -1,10 +1,18 @@
"""A collection of tests for class ``Timer``."""
-from typing import Callable, List
+# pylint: disable=no-self-use
-from pytest import raises
+from __future__ import annotations
-from timerun import ElapsedTime, NoDurationCaptured, Timer
+from typing import TYPE_CHECKING
+
+import pytest
+
+from timerun import ElapsedTime, NoDurationCapturedError, Timer
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterable
+ from contextlib import AbstractContextManager
# =========================================================================== #
# Test suite for using Timer as a context manager. #
@@ -12,7 +20,7 @@
def test_use_timer_as_context_manager_single_run(
- patch_split: Callable,
+ patch_split: Callable[[Iterable[int]], AbstractContextManager[None]],
timer: Timer,
elapsed_1_ms: ElapsedTime,
) -> None:
@@ -29,16 +37,16 @@ def test_use_timer_as_context_manager_single_run(
A newly created Timer with unlimited storage size.
elapsed_1_ms : ElapsedTime
Elapsed Time of 1 microsecond.
+
"""
- with patch_split(elapsed_times=[1000]):
- with timer:
- pass
+ with patch_split([1000]), timer:
+ pass
assert timer.duration == elapsed_1_ms
def test_use_timer_as_context_manager_multiple_run(
- patch_split: Callable,
+ patch_split: Callable[[Iterable[int]], AbstractContextManager[None]],
timer: Timer,
elapsed_100_ns: ElapsedTime,
elapsed_1_ms: ElapsedTime,
@@ -61,8 +69,9 @@ def test_use_timer_as_context_manager_multiple_run(
Elapsed Time of 1 microsecond.
elapsed_1_pt_5_ms : ElapsedTime
Elapsed Time of 1.5 microseconds.
+
"""
- with patch_split(elapsed_times=[100, 1000, 1500]):
+ with patch_split([100, 1000, 1500]):
for _ in range(3):
with timer:
pass
@@ -79,7 +88,7 @@ class TestAsDecorator:
def test_single_run(
self,
- patch_split: Callable,
+ patch_split: Callable[[Iterable[int]], AbstractContextManager[None]],
timer: Timer,
elapsed_1_ms: ElapsedTime,
) -> None:
@@ -96,19 +105,20 @@ def test_single_run(
A newly created Timer with unlimited storage size.
elapsed_1_ms : ElapsedTime
Elapsed Time of 1 microsecond.
+
"""
@timer
def func() -> None:
pass
- with patch_split(elapsed_times=[1000]):
+ with patch_split([1000]):
func()
assert timer.duration == elapsed_1_ms
def test_multiple_run( # pylint: disable=too-many-arguments,too-many-positional-arguments
self,
- patch_split: Callable,
+ patch_split: Callable[[Iterable[int]], AbstractContextManager[None]],
timer: Timer,
elapsed_100_ns: ElapsedTime,
elapsed_1_ms: ElapsedTime,
@@ -131,13 +141,14 @@ def test_multiple_run( # pylint: disable=too-many-arguments,too-many-positional
Elapsed Time of 1 microsecond.
elapsed_1_pt_5_ms : ElapsedTime
Elapsed Time of 1.5 microseconds.
+
"""
@timer
def func() -> None:
pass
- with patch_split(elapsed_times=[100, 1000, 1500]):
+ with patch_split([100, 1000, 1500]):
for _ in range(3):
func()
@@ -155,14 +166,15 @@ def test_access_duration_attr_before_run(self, timer: Timer) -> None:
"""Test access duration attribute before capturing anything.
Test tries to access duration attribute before capturing
- anything, expected to see ``NoDurationCaptured`` exception.
+ anything, expected to see ``NoDurationCapturedError`` exception.
Parameters
----------
timer : Timer
A newly created Timer with unlimited storage size.
+
"""
- with raises(NoDurationCaptured):
+ with pytest.raises(NoDurationCapturedError):
_ = timer.duration
@@ -171,15 +183,15 @@ class TestInit:
def test_use_customized_duration_list(self) -> None:
"""Test capture durations into an existing list."""
- durations: List[ElapsedTime] = []
+ durations: list[ElapsedTime] = []
timer = Timer(storage=durations)
assert (
- timer._durations is durations # pylint: disable=protected-access
+ timer._durations is durations # pylint: disable=protected-access # noqa: SLF001
)
def test_max_storage_limitation(
self,
- patch_split: Callable,
+ patch_split: Callable[[Iterable[int]], AbstractContextManager[None]],
elapsed_1_ms: ElapsedTime,
elapsed_1_pt_5_ms: ElapsedTime,
) -> None:
@@ -196,10 +208,11 @@ def test_max_storage_limitation(
Elapsed Time of 1 microsecond.
elapsed_1_pt_5_ms : ElapsedTime
Elapsed Time of 1.5 microseconds.
+
"""
timer = Timer(max_len=2)
- with patch_split(elapsed_times=[100, 1000, 1500]):
+ with patch_split([100, 1000, 1500]):
for _ in range(3):
with timer:
pass
diff --git a/timerun.py b/timerun.py
index 712f6f8..b935fc5 100644
--- a/timerun.py
+++ b/timerun.py
@@ -3,21 +3,23 @@
from __future__ import annotations
from collections import deque
-from collections.abc import Iterator
from contextlib import ContextDecorator
from dataclasses import dataclass
from datetime import timedelta
from time import perf_counter_ns, process_time_ns
-from typing import Callable, Protocol, TypeVar
+from typing import TYPE_CHECKING, Protocol, TypeVar
-__all__: tuple[str, ...] = (
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterator
+
+__all__: tuple[str, ...] = ( # noqa: RUF022
# -- Core --
"ElapsedTime",
"Stopwatch",
"Timer",
# -- Exceptions --
- "TimeRunException",
- "NoDurationCaptured",
+ "NoDurationCapturedError",
+ "TimeRunError",
)
__version__: str = "0.3.0"
@@ -44,10 +46,10 @@
class AppendableSequence(Protocol[T]):
"""Protocol for sequences that support appending and indexing."""
- def append(self, item: T) -> None:
+ def append(self, _item: T) -> None:
"""Add an item to the sequence."""
- def __getitem__(self, index: int) -> T:
+ def __getitem__(self, _index: int) -> T:
"""Get item by index (supports negative indexing)."""
def __len__(self) -> int:
@@ -70,17 +72,18 @@ def __iter__(self) -> Iterator[T]:
# =========================================================================== #
-class TimeRunException(Exception):
- """Base exception for TimeRun"""
+class TimeRunError(Exception):
+ """Base exception for TimeRun."""
-class NoDurationCaptured(TimeRunException, AttributeError):
- """No Duration Captured Exception"""
+class NoDurationCapturedError(TimeRunError, AttributeError):
+ """No Duration Captured Exception."""
def __init__(self) -> None:
+ """Initialize the exception."""
super().__init__(
"No duration available. This is likely because the Timer has not "
- "been used to measure any code blocks or functions yet."
+ "been used to measure any code blocks or functions yet.",
)
@@ -102,9 +105,7 @@ def __init__(self) -> None:
@dataclass(init=True, repr=False, eq=True, order=True, frozen=True)
class ElapsedTime:
- """Elapsed Time
-
- An immutable object representing elapsed time in nanoseconds.
+ """An immutable object representing elapsed time in nanoseconds.
Attributes
----------
@@ -126,21 +127,23 @@ class ElapsedTime:
ElapsedTime(nanoseconds=10)
>>> print(t)
0:00:00.000000010
+
"""
__slots__ = ["nanoseconds"]
nanoseconds: int
- def __str__(self) -> str:
+ def __str__(self) -> str: # type: ignore[explicit-override]
+ """Return the string representation of the elapsed time."""
integer_part = timedelta(seconds=self.nanoseconds // int(1e9))
- decimal_part = self.nanoseconds % int(1e9)
- if decimal_part == 0:
+ if not (decimal_part := self.nanoseconds % int(1e9)):
return str(integer_part)
return f"{integer_part}.{decimal_part:09}"
- def __repr__(self) -> str:
+ def __repr__(self) -> str: # type: ignore[explicit-override]
+ """Return the representation of the elapsed time."""
return f"ElapsedTime(nanoseconds={self.nanoseconds})"
@property
@@ -166,10 +169,9 @@ def timedelta(self) -> timedelta:
class Stopwatch:
- """Stopwatch
+ """A stopwatch with the highest available resolution (in nanoseconds).
- A stopwatch with the highest available resolution (in nanoseconds)
- to measure elapsed time. It can be set to include or exclude the
+ It measures elapsed time. It can be set to include or exclude the
sleeping time.
Parameters
@@ -192,11 +194,13 @@ class Stopwatch:
>>> stopwatch.reset()
>>> stopwatch.split()
ElapsedTime(nanoseconds=100)
+
"""
__slots__ = ["_clock", "_start"]
- def __init__(self, count_sleep: bool | None = None) -> None:
+ def __init__(self, *, count_sleep: bool | None = None) -> None:
+ """Initialize the stopwatch."""
if count_sleep is None:
count_sleep = True
@@ -211,7 +215,14 @@ def reset(self) -> None:
self._start = self._clock()
def split(self) -> ElapsedTime:
- """Get the elapsed time between now and the starting time."""
+ """Get the elapsed time between now and the starting time.
+
+ Returns
+ -------
+ ElapsedTime
+ The elapsed time captured by the stopwatch.
+
+ """
return ElapsedTime(self._clock() - self._start)
@@ -229,10 +240,7 @@ def split(self) -> ElapsedTime:
class Timer(ContextDecorator):
- """Timer
-
- A context decorator that can capture and save the measured elapsed
- time.
+ """A context decorator that can capture and save the measured elapsed time.
Attributes
----------
@@ -268,26 +276,31 @@ class Timer(ContextDecorator):
>>> func()
>>> print(timer.duration)
0:00:00.000000100
+
"""
- __slots__ = ["_stopwatch", "_durations"]
+ __slots__ = ["_durations", "_stopwatch"]
def __init__(
self,
+ *,
count_sleep: bool | None = None,
storage: AppendableSequence[ElapsedTime] | None = None,
max_len: int | None = None,
) -> None:
- self._stopwatch: Stopwatch = Stopwatch(count_sleep)
+ """Initialize the timer."""
+ self._stopwatch: Stopwatch = Stopwatch(count_sleep=count_sleep)
self._durations: AppendableSequence[ElapsedTime] = (
storage if storage is not None else deque(maxlen=max_len)
)
- def __enter__(self) -> Timer:
+ def __enter__(self) -> Timer: # noqa: PYI034
+ """Start the timer."""
self._stopwatch.reset()
return self
- def __exit__(self, *exc) -> None:
+ def __exit__(self, *_: object) -> None:
+ """Stop the timer and save the duration."""
duration: ElapsedTime = self._stopwatch.split()
self._durations.append(duration)
@@ -301,6 +314,7 @@ def durations(self) -> tuple[ElapsedTime, ...]:
Examples
--------
>>> first_duration, second_duration = timer.durations
+
"""
return tuple(self._durations)
@@ -310,12 +324,13 @@ def duration(self) -> ElapsedTime:
Raises
------
- NoDurationCaptured
+ NoDurationCapturedError
Error that occurs when accessing an empty durations list,
which is usually because the measurer has not been triggered
yet.
+
"""
try:
return self._durations[-1]
except IndexError as error:
- raise NoDurationCaptured from error
+ raise NoDurationCapturedError from error
From f90ed56892121b64ca78db0f1d2e276c85233a33 Mon Sep 17 00:00:00 2001
From: HH-MWB <50187675+HH-MWB@users.noreply.github.com>
Date: Sat, 24 Jan 2026 21:25:54 -0500
Subject: [PATCH 2/9] add async support
---
LICENSE | 2 +-
README.md | 90 +++++++++++++++
pyproject.toml | 2 +-
tests/test_timer.py | 266 +++++++++++++++++++++++++++++++++++++++++++-
timerun.py | 122 +++++++++++++++++++-
5 files changed, 473 insertions(+), 9 deletions(-)
diff --git a/LICENSE b/LICENSE
index 179f8f7..91e918a 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2019-2025 HH-MWB
+Copyright (c) 2019-2026 HH-MWB
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 3139015..b799d94 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,96 @@ pip install git+https://github.com/HH-MWB/timerun.git
0:00:00.000000100
```
+### Measure Async Function
+
+```python
+>>> import asyncio
+>>> from timerun import Timer
+>>> timer = Timer()
+>>> @timer
+... async def async_func():
+... await asyncio.sleep(0.1)
+>>> asyncio.run(async_func())
+>>> print(timer.duration)
+0:00:00.100000000
+```
+
+### Measure Async Code Block
+
+```python
+>>> import asyncio
+>>> from timerun import Timer
+>>> async def async_code():
+... async with Timer() as timer:
+... await asyncio.sleep(0.1)
+... print(timer.duration)
+>>> asyncio.run(async_code())
+0:00:00.100000000
+```
+
+### Multiple Measurements
+
+```python
+>>> from timerun import Timer
+>>> timer = Timer()
+>>> with timer:
+... pass
+>>> with timer:
+... pass
+>>> print(timer.duration) # Last duration
+0:00:00.000000100
+>>> print(timer.durations) # All durations
+(ElapsedTime(nanoseconds=100), ElapsedTime(nanoseconds=100))
+```
+
+### Advanced Options
+
+```python
+>>> from timerun import Timer
+>>> # Exclude sleep time from measurements
+>>> timer = Timer(count_sleep=False)
+>>> # Limit storage to last 10 measurements
+>>> timer = Timer(max_len=10)
+```
+
+## Usage
+
+### Stopwatch
+
+The `Stopwatch` class provides manual control over timing measurements:
+
+```python
+>>> from timerun import Stopwatch
+>>> stopwatch = Stopwatch()
+>>> stopwatch.reset()
+>>> # ... your code here ...
+>>> elapsed = stopwatch.split()
+>>> print(elapsed)
+0:00:00.000000100
+```
+
+You can configure whether to count sleep time:
+
+```python
+>>> # Exclude sleep time from measurements
+>>> stopwatch = Stopwatch(count_sleep=False)
+```
+
+### ElapsedTime
+
+The `ElapsedTime` class represents elapsed time in nanoseconds with high precision:
+
+```python
+>>> from timerun import ElapsedTime
+>>> t = ElapsedTime(1000000000) # 1 second in nanoseconds
+>>> print(t)
+0:00:01
+>>> print(t.nanoseconds)
+1000000000
+>>> print(t.timedelta) # Convert to timedelta
+0:00:01
+```
+
## Contributing
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to this project.
diff --git a/pyproject.toml b/pyproject.toml
index 8f9f3d5..e9b1b33 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,7 +34,7 @@ classifiers = [
dynamic = ["version"]
[project.optional-dependencies]
-dev = ["pytest", "pytest-cov"]
+dev = ["pytest", "pytest-asyncio", "pytest-cov"]
[project.urls]
Homepage = "https://github.com/HH-MWB/timerun"
diff --git a/tests/test_timer.py b/tests/test_timer.py
index 0ebf8b2..af8fd96 100644
--- a/tests/test_timer.py
+++ b/tests/test_timer.py
@@ -4,14 +4,15 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
+import asyncio
+from typing import TYPE_CHECKING, cast
import pytest
from timerun import ElapsedTime, NoDurationCapturedError, Timer
if TYPE_CHECKING:
- from collections.abc import Callable, Iterable
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable
from contextlib import AbstractContextManager
# =========================================================================== #
@@ -218,3 +219,264 @@ def test_max_storage_limitation(
pass
assert timer.durations == (elapsed_1_ms, elapsed_1_pt_5_ms)
+
+
+# =========================================================================== #
+# Test suite for using Timer as an async context manager. #
+# =========================================================================== #
+
+
+@pytest.mark.asyncio
+async def test_use_timer_as_async_context_manager_single_run(
+ patch_split: Callable[[Iterable[int]], AbstractContextManager[None]],
+ timer: Timer,
+ elapsed_1_ms: ElapsedTime,
+) -> None:
+ """Test using it as an async context manager.
+
+ Test using the timer and ``async with`` to capture the duration time
+ for async code block.
+
+ Parameters
+ ----------
+ patch_split : Callable
+ Patcher has been used to set the captured duration time.
+ timer : Timer
+ A newly created Timer with unlimited storage size.
+ elapsed_1_ms : ElapsedTime
+ Elapsed Time of 1 microsecond.
+
+ """
+ with patch_split([1000]):
+ async with timer:
+ await asyncio.sleep(0)
+
+ assert timer.duration == elapsed_1_ms
+
+
+@pytest.mark.asyncio
+async def test_use_timer_as_async_context_manager_multiple_run(
+ patch_split: Callable[[Iterable[int]], AbstractContextManager[None]],
+ timer: Timer,
+ elapsed_100_ns: ElapsedTime,
+ elapsed_1_ms: ElapsedTime,
+ elapsed_1_pt_5_ms: ElapsedTime,
+) -> None:
+ """Test run multiple times with the same timer (async).
+
+ Test run timer using ``async with`` ``3`` times and expected to see
+ all three captured duration times.
+
+ Parameters
+ ----------
+ patch_split : Callable
+ Patcher has been used to set the captured duration time.
+ timer : Timer
+ A newly created Timer with unlimited storage size.
+ elapsed_100_ns : ElapsedTime
+ Elapsed Time of 100 nanoseconds.
+ elapsed_1_ms : ElapsedTime
+ Elapsed Time of 1 microsecond.
+ elapsed_1_pt_5_ms : ElapsedTime
+ Elapsed Time of 1.5 microseconds.
+
+ """
+ with patch_split([100, 1000, 1500]):
+ for _ in range(3):
+ async with timer:
+ await asyncio.sleep(0)
+
+ assert timer.durations == (
+ elapsed_100_ns,
+ elapsed_1_ms,
+ elapsed_1_pt_5_ms,
+ )
+
+
+class TestAsAsyncDecorator:
+ """Test suite for using Timer as an async function decorator."""
+
+ @pytest.mark.asyncio
+ async def test_single_run(
+ self,
+ patch_split: Callable[[Iterable[int]], AbstractContextManager[None]],
+ timer: Timer,
+ elapsed_1_ms: ElapsedTime,
+ ) -> None:
+ """Test the async function with a single run.
+
+ Test run decorated async function and expected to get the captured
+ duration afterward.
+
+ Parameters
+ ----------
+ patch_split : Callable
+ Patcher has been used to set the captured duration time.
+ timer : Timer
+ A newly created Timer with unlimited storage size.
+ elapsed_1_ms : ElapsedTime
+ Elapsed Time of 1 microsecond.
+
+ """
+
+ @timer
+ async def async_func() -> None:
+ await asyncio.sleep(0)
+
+ with patch_split([1000]):
+ await cast("Awaitable[None]", async_func())
+ assert timer.duration == elapsed_1_ms
+
+ @pytest.mark.asyncio
+ async def test_multiple_run( # pylint: disable=too-many-arguments,too-many-positional-arguments
+ self,
+ patch_split: Callable[[Iterable[int]], AbstractContextManager[None]],
+ timer: Timer,
+ elapsed_100_ns: ElapsedTime,
+ elapsed_1_ms: ElapsedTime,
+ elapsed_1_pt_5_ms: ElapsedTime,
+ ) -> None:
+ """Test the async function with multiple runs.
+
+ Test run decorated async function ``3`` times and expected to see all
+ three captured duration times.
+
+ Parameters
+ ----------
+ patch_split : Callable
+ Patcher has been used to set the captured duration time.
+ timer : Timer
+ A newly created Timer with unlimited storage size.
+ elapsed_100_ns : ElapsedTime
+ Elapsed Time of 100 nanoseconds.
+ elapsed_1_ms : ElapsedTime
+ Elapsed Time of 1 microsecond.
+ elapsed_1_pt_5_ms : ElapsedTime
+ Elapsed Time of 1.5 microseconds.
+
+ """
+
+ @timer
+ async def async_func() -> None:
+ await asyncio.sleep(0)
+
+ with patch_split([100, 1000, 1500]):
+ for _ in range(3):
+ await cast("Awaitable[None]", async_func())
+
+ assert timer.durations == (
+ elapsed_100_ns,
+ elapsed_1_ms,
+ elapsed_1_pt_5_ms,
+ )
+
+
+class TestAsAsyncGeneratorDecorator:
+ """Test suite for using Timer as an async generator function decorator."""
+
+ @pytest.mark.asyncio
+ async def test_single_run(
+ self,
+ patch_split: Callable[[Iterable[int]], AbstractContextManager[None]],
+ timer: Timer,
+ elapsed_1_ms: ElapsedTime,
+ ) -> None:
+ """Test the async generator function with a single run.
+
+ Test run decorated async generator function and expected to get the
+ captured duration afterward.
+
+ Parameters
+ ----------
+ patch_split : Callable
+ Patcher has been used to set the captured duration time.
+ timer : Timer
+ A newly created Timer with unlimited storage size.
+ elapsed_1_ms : ElapsedTime
+ Elapsed Time of 1 microsecond.
+
+ """
+
+ @timer
+ async def async_gen_func() -> AsyncGenerator[int]:
+ """Async generator function for testing.
+
+ Yields
+ ------
+ int
+ Sequential integers for testing.
+
+ """
+ await asyncio.sleep(0)
+ yield 1
+ await asyncio.sleep(0)
+ yield 2
+
+ with patch_split([1000]):
+ items: list[int] = [
+ item
+ async for item in cast(
+ "AsyncGenerator[int]",
+ async_gen_func(),
+ )
+ ]
+
+ assert items == [1, 2]
+ assert timer.duration == elapsed_1_ms
+
+ @pytest.mark.asyncio
+ async def test_multiple_run( # pylint: disable=too-many-arguments,too-many-positional-arguments
+ self,
+ patch_split: Callable[[Iterable[int]], AbstractContextManager[None]],
+ timer: Timer,
+ elapsed_100_ns: ElapsedTime,
+ elapsed_1_ms: ElapsedTime,
+ elapsed_1_pt_5_ms: ElapsedTime,
+ ) -> None:
+ """Test the async generator function with multiple runs.
+
+ Test run decorated async generator function ``3`` times and expected
+ to see all three captured duration times.
+
+ Parameters
+ ----------
+ patch_split : Callable
+ Patcher has been used to set the captured duration time.
+ timer : Timer
+ A newly created Timer with unlimited storage size.
+ elapsed_100_ns : ElapsedTime
+ Elapsed Time of 100 nanoseconds.
+ elapsed_1_ms : ElapsedTime
+ Elapsed Time of 1 microsecond.
+ elapsed_1_pt_5_ms : ElapsedTime
+ Elapsed Time of 1.5 microseconds.
+
+ """
+
+ @timer
+ async def async_gen_func() -> AsyncGenerator[int]:
+ """Async generator function for testing.
+
+ Yields
+ ------
+ int
+ Sequential integers for testing.
+
+ """
+ await asyncio.sleep(0)
+ yield 1
+
+ with patch_split([100, 1000, 1500]):
+ for _ in range(3):
+ async_gen: AsyncGenerator[int] = cast(
+ "AsyncGenerator[int]",
+ async_gen_func(),
+ )
+ async for _ in async_gen:
+ pass
+
+ assert timer.durations == (
+ elapsed_100_ns,
+ elapsed_1_ms,
+ elapsed_1_pt_5_ms,
+ )
diff --git a/timerun.py b/timerun.py
index b935fc5..d3e6fda 100644
--- a/timerun.py
+++ b/timerun.py
@@ -6,11 +6,17 @@
from contextlib import ContextDecorator
from dataclasses import dataclass
from datetime import timedelta
+from inspect import isasyncgenfunction, iscoroutinefunction
from time import perf_counter_ns, process_time_ns
-from typing import TYPE_CHECKING, Protocol, TypeVar
+from typing import TYPE_CHECKING, Protocol, TypeVar, cast
if TYPE_CHECKING:
- from collections.abc import Callable, Iterator
+ from collections.abc import (
+ AsyncGenerator,
+ Awaitable,
+ Callable,
+ Iterator,
+ )
__all__: tuple[str, ...] = ( # noqa: RUF022
# -- Core --
@@ -22,7 +28,7 @@
"TimeRunError",
)
-__version__: str = "0.3.0"
+__version__: str = "0.4.0"
# =========================================================================== #
@@ -267,7 +273,6 @@ class Timer(ContextDecorator):
>>> with Timer() as timer:
... pass
>>> print(timer.duration)
- 0:00:00.000000100
>>> timer = Timer()
>>> @timer
@@ -275,7 +280,20 @@ class Timer(ContextDecorator):
... pass
>>> func()
>>> print(timer.duration)
- 0:00:00.000000100
+
+ >>> import asyncio
+ >>> timer = Timer()
+ >>> @timer
+ ... async def async_func():
+ ... await asyncio.sleep(0.1)
+ >>> asyncio.run(async_func())
+ >>> print(timer.duration)
+
+ >>> async def async_code():
+ ... async with Timer() as timer:
+ ... await asyncio.sleep(0.1)
+ ... print(timer.duration)
+ >>> asyncio.run(async_code())
"""
@@ -304,6 +322,100 @@ def __exit__(self, *_: object) -> None:
duration: ElapsedTime = self._stopwatch.split()
self._durations.append(duration)
+ async def __aenter__(self) -> Timer: # noqa: PYI034
+ """Start the timer (async context manager)."""
+ self._stopwatch.reset()
+ return self
+
+ async def __aexit__(self, *_: object) -> None:
+ """Stop the timer and save the duration (async context manager)."""
+ duration: ElapsedTime = self._stopwatch.split()
+ self._durations.append(duration)
+
+ def _wrap_async_function( # type: ignore[explicit-any]
+ self,
+ func: Callable[..., Awaitable[object]],
+ ) -> Callable[..., Awaitable[object]]:
+ """Wrap an async function to measure its execution time."""
+
+ async def async_wrapper(*args: object, **kwargs: object) -> object:
+ """Wrap async function execution with timing.
+
+ Parameters
+ ----------
+ *args : object
+ Positional arguments passed to the wrapped function.
+ **kwargs : object
+ Keyword arguments passed to the wrapped function.
+
+ Returns
+ -------
+ object
+ The result of the wrapped async function.
+
+ """
+ async with self:
+ return await func(*args, **kwargs)
+
+ return async_wrapper
+
+ def _wrap_async_generator( # type: ignore[explicit-any]
+ self,
+ func: Callable[..., object],
+ ) -> Callable[..., AsyncGenerator[object]]:
+ """Wrap an async generator function to measure its execution time."""
+
+ async def async_gen_wrapper(
+ *args: object,
+ **kwargs: object,
+ ) -> AsyncGenerator[object]:
+ """Wrap async generator function execution with timing.
+
+ Parameters
+ ----------
+ *args : object
+ Positional arguments passed to the wrapped function.
+ **kwargs : object
+ Keyword arguments passed to the wrapped function.
+
+ Yields
+ ------
+ object
+ Items yielded from the wrapped async generator function.
+
+ """
+ async with self:
+ async for item in cast(
+ "AsyncGenerator[object]",
+ func(*args, **kwargs),
+ ):
+ yield item
+
+ return async_gen_wrapper
+
+ def __call__( # type: ignore[override,explicit-override,explicit-any]
+ self,
+ func: Callable[..., object] | Callable[..., Awaitable[object]],
+ ) -> Callable[..., object] | Callable[..., Awaitable[object]]:
+ """Wrap a function (sync or async) to measure its execution time.
+
+ Parameters
+ ----------
+ func : Callable
+ The function to be decorated (can be sync or async).
+
+ Returns
+ -------
+ Callable
+ A wrapped function that measures execution time.
+
+ """
+ if iscoroutinefunction(func):
+ return self._wrap_async_function(func)
+ if isasyncgenfunction(func):
+ return self._wrap_async_generator(func)
+ return super().__call__(func)
+
@property
def durations(self) -> tuple[ElapsedTime, ...]:
"""The captured duration times as a tuple.
From c8a5bea319ac1c8af8e5071a831cc2dfbbd2ef63 Mon Sep 17 00:00:00 2001
From: HH-MWB <50187675+HH-MWB@users.noreply.github.com>
Date: Sat, 24 Jan 2026 21:32:27 -0500
Subject: [PATCH 3/9] update docs with latest features
---
README.md | 63 +++++++++++-------------------------------------------
timerun.py | 10 +++++----
2 files changed, 19 insertions(+), 54 deletions(-)
diff --git a/README.md b/README.md
index b799d94..3a8ee43 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,6 @@
-
-
TimeRun - Python library for elapsed time measurement.
TimeRun is a simple, yet elegant elapsed time measurement library for [Python](https://www.python.org). It is distributed as a single file module and has no dependencies other than the [Python Standard Library](https://docs.python.org/3/library/). @@ -124,4 +125,4 @@ We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guid ## License -This project is licensed under the MIT License - see the [LICENSE](https://github.com/HH-MWB/timerun/blob/master/LICENSE) file for details. +This project is licensed under the MIT License - see the [LICENSE](https://github.com/HH-MWB/timerun/blob/main/LICENSE) file for details. From beb8cd847c77ef73cb62202e9bd8c1aa39f1620a Mon Sep 17 00:00:00 2001 From: HH-MWB <50187675+HH-MWB@users.noreply.github.com> Date: Sun, 25 Jan 2026 09:39:22 -0500 Subject: [PATCH 5/9] fix pre-commit config to be flexible on Python version --- .pre-commit-config.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2cdaa40..098c0ca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,3 @@ -default_language_version: - python: python3.11 - repos: - repo: 'https://github.com/pre-commit/pre-commit-hooks' rev: v6.0.0 From fdf4cc20916fc52b29bbe737319815e60e190704 Mon Sep 17 00:00:00 2001 From: HH-MWB <50187675+HH-MWB@users.noreply.github.com> Date: Sun, 25 Jan 2026 10:02:47 -0500 Subject: [PATCH 6/9] install test dependencies based on pyproject.toml --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0d3111e..db4b410 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install test dependencies - run: pip install pytest + run: pip install -e ".[dev]" - name: Run tests run: python -m pytest From 400ed0795d8e742874e863c73b7bb3137af18c66 Mon Sep 17 00:00:00 2001 From: HH-MWB <50187675+HH-MWB@users.noreply.github.com> Date: Sun, 25 Jan 2026 10:07:54 -0500 Subject: [PATCH 7/9] support Python 3.14 --- .github/workflows/ci.yaml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index db4b410..6a032a6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,7 +29,7 @@ jobs: needs: lint strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: Checkout code uses: actions/checkout@v5 diff --git a/pyproject.toml b/pyproject.toml index e9b1b33..67d8443 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Monitoring", ] From 3c457dcc1938a724b640057785777ec7a8d66806 Mon Sep 17 00:00:00 2001 From: HH-MWB <50187675+HH-MWB@users.noreply.github.com> Date: Sun, 25 Jan 2026 11:03:09 -0500 Subject: [PATCH 8/9] introduce codecov for coverage report --- .github/workflows/ci.yaml | 17 +++++++++++++++-- README.md | 3 ++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6a032a6..88e0265 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,5 +1,7 @@ +--- name: Continuous Integration +# yamllint disable-line rule:truthy on: pull_request: branches: [main] @@ -42,8 +44,19 @@ jobs: - name: Install test dependencies run: pip install -e ".[dev]" - - name: Run tests - run: python -m pytest + - name: Run tests with coverage + run: >- + python -m pytest tests/ + --cov=timerun + --cov-branch + --cov-report=xml + --cov-report=term + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: python${{ matrix.python-version }} build: runs-on: ubuntu-latest diff --git a/README.md b/README.md index c9047f0..45a9db9 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@TimeRun - Python library for elapsed time measurement.
From 792bd58775858403fad15c236861894ef7f07935 Mon Sep 17 00:00:00 2001 From: HH-MWB <50187675+HH-MWB@users.noreply.github.com> Date: Sun, 25 Jan 2026 11:41:22 -0500 Subject: [PATCH 9/9] use latest actions and Python 3.11 --- .github/workflows/ci.yaml | 20 ++++++++++---------- .github/workflows/release.yaml | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 88e0265..c2f9f82 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,12 +16,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 + - name: Set up Python 3.11 + uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.11' - name: Run pre-commit hooks uses: pre-commit/action@v3.0.1 @@ -34,10 +34,10 @@ jobs: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -63,12 +63,12 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 + - name: Set up Python 3.11 + uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.11' - name: Install build dependencies run: pip install build twine diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 09ad364..c5113cc 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,12 +12,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 + - name: Set up Python 3.11 + uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.11' - name: Install build dependencies run: pip install build