From 655c29834c7a06293307417205c664a784495b03 Mon Sep 17 00:00:00 2001 From: Asger Schultz Date: Thu, 22 Jan 2026 20:13:17 +0100 Subject: [PATCH 01/10] Add do_at_interval and reset_profiles to TickTock Resolves #203 Resolves #204 --- CHANGELOG.md | 5 +++++ pelutils/ticktock.py | 46 +++++++++++++++++++++++++++++++++++-- tests/test_ticktock.py | 51 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c1dea7..b701a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # History +## 3.6.0 + +- Added `reset_profiles` method to `TickTock`. +- Added `do_at_interval` for triggering events at intervals. + ## 3.5.0 - Added support and pre-built wheels for Python 3.13. diff --git a/pelutils/ticktock.py b/pelutils/ticktock.py index b27445c..8f4a9b2 100644 --- a/pelutils/ticktock.py +++ b/pelutils/ticktock.py @@ -1,7 +1,9 @@ from __future__ import annotations +import warnings from collections.abc import Generator, Hashable from copy import deepcopy +from threading import current_thread from time import perf_counter from deprecated import deprecated @@ -112,8 +114,8 @@ def __eq__(self, __value: object) -> bool: class _ProfileContext: - def __init__(self, tt, profile: Profile): - self._tt: TickTock = tt + def __init__(self, tt: "TickTock", profile: Profile): # noqa: UP037 + self._tt = tt self._profile = profile def __enter__(self): @@ -184,6 +186,9 @@ def __init__(self): self._profile_stack: list[Profile] = list() self._nhits: list[int] = list() + self._thread_name = current_thread().name + self._thread_id = id(current_thread()) + def tick(self, id: Hashable = None): """Start a timer. Set id to any hashable value (e.g. string or int) to time multiple things once.""" self._tick_starts[id] = perf_counter() @@ -211,6 +216,11 @@ def profile(self, name: str, *, hits=1) -> _ProfileContext: ... ``` """ + if self._thread_id != id(current_thread()): + warnings.warn(f"This TickTock instance was created in the {self._thread_name} thread but profiling was started in " + f"{current_thread().name}. Profiling is NOT designed to deal with multiple threads. Instead, create a " + "TickTock instance for each thread requiring profiling.", stacklevel=2) + profile = Profile( name, len(self._profile_stack), @@ -255,6 +265,36 @@ def reset(self): raise TickTockException("Cannot reset TickTock while profiling is active") self.__init__() + def reset_profiles(self): + """Similar to `reset` but only reset profiles.""" + tick_starts = self._tick_starts + self.reset() + self._tick_starts = tick_starts + + def do_at_interval(self, interval: float, id: Hashable = None, *, also_first=False) -> True: + """Return true if it is at least `interval` since this method was called with the same id previously. + + A common pattern is to run a piece of code at fixed intervals inside a loop. In the example below, a loop is continuously doing + some computation which results in some telemetry. This is collected every 60 seconds. + ```py + while True: + + + if TT.do_at_interval(60, "telemetry"): + + ``` + If `also_first` is True, `do_at_interval` will return True the first time it is called with a given `id`. + Otherwise, the interval has to elapse before True is returned the first time. + """ + id = ("__interval__", id) + if id not in self._tick_starts: + self.tick(id) + return also_first + if self.tock(id) >= interval: + self.tick(id) + return True + return False + def add_external_measurements(self, name: str | None, time: float, *, hits=1): """Add data to a (new) profile with given time spread over given hits. @@ -291,6 +331,8 @@ def fuse_multiple(*tts: TickTock) -> TickTock: raise ValueError("Some TickTocks are the same instance, which is not allowed") for tt in tts[1:]: ticktock.fuse(tt) + ticktock._thread_name = current_thread().name + ticktock._thread_id = id(current_thread()) return ticktock @staticmethod diff --git a/tests/test_ticktock.py b/tests/test_ticktock.py index b8e6aca..eb43f15 100644 --- a/tests/test_ticktock.py +++ b/tests/test_ticktock.py @@ -1,4 +1,5 @@ from copy import deepcopy +from threading import Thread import pytest from pelutils import TickTock, TT, TimeUnits, Profile @@ -148,6 +149,14 @@ def test_reset(): with pytest.raises(TickTockException): tt.reset() + tt.tick("abc") + tt.tick("abc2") + tt.reset_profiles() + tt.tock("abc") + tt.tock("abc2") + with pytest.raises(TickTockException): + tt.tock("abc3") + def test_profiles_with_same_name(): tt = TickTock() @@ -217,6 +226,48 @@ def test_add_external_measurements(): with pytest.warns(DeprecationWarning): assert len(profile.hits) == 7 +def test_do_at_interval(): + tt = TickTock() + tt.tick() + num_a = 0 + num_b = 0 + while tt.tock() < 0.1: + if tt.do_at_interval(0.03, "a"): + print("a", tt.tock()) + num_a += 1 + if tt.do_at_interval(0.04, "b"): + print("b", tt.tock()) + num_b += 1 + assert num_a == 3 + assert num_b == 2 + + tt.reset() + tt.tick() + num_a = 0 + num_b = 0 + while tt.tock() < 0.1: + if tt.do_at_interval(0.03, "a", also_first=True): + print("a", tt.tock()) + num_a += 1 + if tt.do_at_interval(0.04, "b", also_first=True): + print("b", tt.tock()) + num_b += 1 + assert num_a == 4 + assert num_b == 3 + +def test_thread_assert(): + tt = TickTock() + + tt = None + def set_tt(): + nonlocal tt + tt = TickTock() + + Thread(target=set_tt).start() + + with pytest.warns(), tt.profile("abc"): + pass + def test_print(capfd: pytest.CaptureFixture): tt = TickTock() with tt.profile("a"): From 6fab38b7d17b78153aa594cbea79757bfead7da0 Mon Sep 17 00:00:00 2001 From: Asger Schultz Date: Thu, 22 Jan 2026 20:26:40 +0100 Subject: [PATCH 02/10] Add disable option to profiling Resolves #202 --- CHANGELOG.md | 1 + pelutils/ticktock.py | 11 ++++++++--- tests/test_ticktock.py | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b701a68..fa2075a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Added `reset_profiles` method to `TickTock`. - Added `do_at_interval` for triggering events at intervals. +- Added `disable` option to `TickTock.profile`. ## 3.5.0 diff --git a/pelutils/ticktock.py b/pelutils/ticktock.py index 8f4a9b2..3cf94b7 100644 --- a/pelutils/ticktock.py +++ b/pelutils/ticktock.py @@ -67,6 +67,7 @@ def __init__(self, name: str, depth: int, parent: Profile | None): self.name = name self.depth = depth self.parent = parent + self._disable_in_context = False if self.parent is not None: assert depth > 0 self.parent.children.append(self) @@ -200,7 +201,7 @@ def tock(self, id: Hashable = None) -> float: raise TickTockException(f"A timer for the given ID ({id}) has not been started with .tick()") return end - self._tick_starts[id] - def profile(self, name: str, *, hits=1) -> _ProfileContext: + def profile(self, name: str, *, hits=1, disable=False) -> _ProfileContext: """Begin a profile with given name. Optionally it is possible to register this as several hits that sum to the total time. @@ -215,6 +216,7 @@ def profile(self, name: str, *, hits=1) -> _ProfileContext: with TT.profile("Op"): ... ``` + If `disable` is True, the profile, as well as all child profiles will not be counted. """ if self._thread_id != id(current_thread()): warnings.warn(f"This TickTock instance was created in the {self._thread_name} thread but profiling was started in " @@ -235,6 +237,7 @@ def profile(self, name: str, *, hits=1) -> _ProfileContext: self._id_to_profile[profile] = profile if not self._profile_stack: self.profiles.append(profile) + profile._disable_in_context = disable or (self._profile_stack[-1]._disable_in_context if self._profile_stack else False) self._profile_stack.append(profile) self._nhits.append(hits) @@ -254,8 +257,10 @@ def end_profile(self, name: str | None = None) -> float: if name is not None and name != self._profile_stack[-1].name: raise NameError(f"Expected to pop profile '{self._profile_stack[-1].name}', received '{name}'") nhits = self._nhits.pop() - self._profile_stack[-1]._n += nhits - self._profile_stack[-1]._total_time += dt + if not self._profile_stack[-1]._disable_in_context: + self._profile_stack[-1]._n += nhits + self._profile_stack[-1]._total_time += dt + self._profile_stack[-1]._disable_in_context = False self._profile_stack.pop() return dt diff --git a/tests/test_ticktock.py b/tests/test_ticktock.py index eb43f15..f5426ed 100644 --- a/tests/test_ticktock.py +++ b/tests/test_ticktock.py @@ -209,6 +209,33 @@ def test_profiles_with_same_name(): with pytest.raises(KeyError): tt.stats_by_profile_name("c") +def test_disable(): + tt = TickTock() + with tt.profile("111"): + with tt.profile("222", disable=True): + with tt.profile("333"): + pass + with tt.profile("444"): + pass + with tt.profile("555"): + pass + + with tt.profile("111"): + with tt.profile("222"): + with tt.profile("333"): + pass + with tt.profile("444"): + pass + with tt.profile("666", disable=True): + pass + + assert tt.stats_by_profile_name("111")[0] == 2 + assert tt.stats_by_profile_name("222")[0] == 1 + assert tt.stats_by_profile_name("333")[0] == 1 + assert tt.stats_by_profile_name("444")[0] == 1 + assert tt.stats_by_profile_name("555")[0] == 0 + assert tt.stats_by_profile_name("666")[0] == 0 + def test_add_external_measurements(): tt = TickTock() with tt.profile("a"): From 248885e5c356f8a05a4f21dd6e7624d2a055cde2 Mon Sep 17 00:00:00 2001 From: Asger Schultz Date: Thu, 22 Jan 2026 20:38:22 +0100 Subject: [PATCH 03/10] Update TickTock documentation --- README.md | 29 +++++++---------------------- pelutils/ticktock.py | 32 ++++++++++++-------------------- 2 files changed, 19 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 98b73f7..c8f6e10 100644 --- a/README.md +++ b/README.md @@ -38,17 +38,11 @@ seconds_used = TT.tock() # Profile a for loop for i in range(100): - TT.profile("Repeated code") - - TT.profile("Subtask") - - TT.end_profile() - TT.end_profile() -print(TT) # Prints a table view of profiled code sections - -# Alternative syntax using with statement -with TT.profile("The best task"): + with TT.profile("Repeated code"): + with TT.profile("Subtask"): + +print(TT) # Print a table view of profiled code sections # When using multiprocessing, it can be useful to simulate multiple hits of the same profile with mp.Pool() as p, TT.profile("Processing 100 items on multiple threads", hits=100): @@ -59,21 +53,12 @@ with TT.profile("Adding 1 to a", hits=100): for _ in range(100): a += 1 -# Examples so far use a global TickTock instance, which is convenient, -# but it can also be desirable to use for multiple different timers, e.g. -tt1 = TickTock() -tt2 = TickTock() -t1_interval = 1 # Do task 1 every second -t2_interval = 2 # Do task 2 every other second -tt1.tick() -tt2.tick() +# To use the TickTock instance as a timer to trigger events, do while True: - if tt1.tock() > t1_interval: + if TT.do_at_interval(60, "task1"): # Do task 1 every 60 seconds - tt1.tick() - if tt2.tock() > t2_interval: + if TT.do_at_interval(30, "task2"): # Do task 2 every 30 seconds - tt2.tick() time.sleep(0.01) ``` diff --git a/pelutils/ticktock.py b/pelutils/ticktock.py index 3cf94b7..928ca9d 100644 --- a/pelutils/ticktock.py +++ b/pelutils/ticktock.py @@ -136,23 +136,22 @@ class TickTockException(RuntimeError): class TickTock: """Simple time taker inspired by Matlab Tic, Toc, which also has profiling tooling. + It is possible to import `TT` directly as a global instance for convenience, or import `TickTock` and create a new instance. + For most use cases, importing `TT` is recommended, but when using threads (or async), creating a ticktock instance per thread + is recommended. + + Basic use is as follows. ```py TT.tick() seconds_used = TT.tock() for i in range(100): - TT.profile("Repeated code") - - TT.profile("Subtask") - - TT.end_profile() - TT.end_profile() - print(TT) # Prints a table view of profiled code sections - - # Alternative syntax using with statement - with TT.profile("The best task"): + with TT.profile("Repeated code"): + with TT.profile("Subtask"): + + print(TT) # Print a table view of profiled code sections # When using multiprocessing, it can be useful to simulate multiple hits of the same profile with mp.Pool() as p, TT.profile("Processing 100 items on multiple threads", hits=100): @@ -163,18 +162,11 @@ class TickTock: for _ in range(100): a += 1 - # Examples so far use a global TickTock instance, which is convenient, - # but it can also be desirable to use for multiple different timers, e.g. - tt1 = TickTock() - tt2 = TickTock() - t1_interval = 1 # Do task 1 every second - t2_interval = 2 # Do task 2 every other second - tt1.tick() - tt2.tick() + # To use the TickTock instance as a timer to trigger events, do while True: - if tt1.tock() > t1_interval: + if TT.do_at_interval(60, "task1"): # Do task 1 every 60 seconds - if tt2.tock() > t2_interval: + if TT.do_at_interval(30, "task2"): # Do task 2 every 30 seconds time.sleep(0.01) ``` From c3b482581c90bce975f21774a3053c98956b73cf Mon Sep 17 00:00:00 2001 From: Asger Schultz Date: Thu, 22 Jan 2026 21:43:36 +0100 Subject: [PATCH 04/10] Update build pipeline and related tooling This includes a breaking change rename `UnitTestCollection.test_path` -> `UnitTestCollection.get_test_path` to get pytest to quit complaining. --- .github/workflows/dist.yml | 63 ++++++++++++++++++++---------------- .github/workflows/pytest.yml | 2 +- CHANGELOG.md | 5 ++- pelutils/tests.py | 4 +-- pytest.ini | 3 ++ setup.py | 10 +++--- tests/ds/test_plots.py | 2 +- tests/test_init.py | 2 +- tests/test_jsonl.py | 2 +- tests/test_parser.py | 16 ++++----- tests/test_tests.py | 2 +- 11 files changed, 63 insertions(+), 48 deletions(-) create mode 100644 pytest.ini diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml index d0c9246..10aa09d 100644 --- a/.github/workflows/dist.yml +++ b/.github/workflows/dist.yml @@ -5,55 +5,59 @@ on: branches: [ master ] push: tags: - - '*' + - 'v*' jobs: build_wheels: - name: Build wheels for ${{ matrix.os }} + name: Build ${{ matrix.python }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-22.04, windows-latest, macos-latest ] + # Operating systems for which pre-built wheels are provided + os: [ ubuntu-24.04, windows-latest, macos-latest ] + # Python versions for which pre-built wheels are provided + # See supported versions at https://cibuildwheel.pypa.io/en/stable/#what-does-it-do + # Be aware that all versions need to be supported by the used version of cibuildwheel + # https://github.com/pypa/cibuildwheel/releases + python: [ 'cp39', 'cp310', 'cp311', 'cp312', 'cp313', 'cp314' ] steps: - - uses: actions/checkout@v4.2.2 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v4 - name: Set up QEMU if: runner.os == 'Linux' - uses: docker/setup-qemu-action@v3.2.0 + uses: docker/setup-qemu-action@v3 with: - platforms: all - - - name: Install cibuildwheel - run: python -m pip install cibuildwheel==2.22.0 + platforms: arm64 - name: Build wheels - run: python -m cibuildwheel --output-dir wheelhouse + uses: pypa/cibuildwheel@v3.3.1 env: - CIBW_SKIP: pp* *musllinux* cp36-* cp37-* cp38-* cp313-manylinux_aarch64 + CIBW_BUILD: "${{ matrix.python }}-*" + CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 + CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28 + CIBW_SKIP: "*musllinux*" CIBW_BUILD_VERBOSITY: 1 - CIBW_ARCHS_LINUX: auto64 aarch64 - CIBW_ARCHS_MACOS: auto64 - CIBW_ARCHS_WINDOWS: auto64 + CIBW_ARCHS_LINUX: x86_64 aarch64 + CIBW_ARCHS_MACOS: arm64 + CIBW_ARCHS_WINDOWS: AMD64 CIBW_BEFORE_BUILD: git submodule update --init --recursive - CIBW_BEFORE_TEST: pip install -e .[dev] - CIBW_TEST_COMMAND_WINDOWS: ruff check {project}/pelutils && pytest {project}/tests - CIBW_TEST_COMMAND_MACOS: ruff check {project}/pelutils && pytest {project}/tests - CIBW_TEST_COMMAND_LINUX: ruff check {project}/pelutils && timeout 150s pytest {project}/tests || true + # Install the CPU version of torch before installing the rest of the package + # This prevents it from installing the full CUDA version of torch and associated dependencies (default on Linux) + # This massively speeds up the installation process and requires much less disk space + CIBW_BEFORE_TEST: pip install torch --index-url https://download.pytorch.org/whl/cpu && pip install .[dev] + CIBW_TEST_COMMAND: ruff check {project}/pelutils && pytest {project}/tests - # v4 is currently the newest, but there is some breaking change with it that causes the run to fail - # https://github.com/actions/upload-artifact/issues/478 - uses: actions/upload-artifact@v4 with: - name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} + name: wheels-${{ matrix.os }}-${{ matrix.python }} path: ./wheelhouse/*.whl make_sdist: name: Make source dist - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v4 with: submodules: true @@ -66,16 +70,21 @@ jobs: upload_all: needs: [ build_wheels, make_sdist ] - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 # Upload to PyPI when pushing new version tag if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v') steps: - uses: actions/download-artifact@v4 with: - name: artifact + pattern: '*' path: dist + merge-multiple: true + + - name: List files to distribute + run: ls -R dist/ + shell: bash - - uses: pypa/gh-action-pypi-publish@v1.12.3 + - uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index b923a4f..444da85 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -33,7 +33,7 @@ jobs: - name: Set up PyPi dependencies and install package run: | pip install --upgrade setuptools wheel - pip install numpy + pip install torch --index-url https://download.pytorch.org/whl/cpu pip install -e .[dev] - name: Lint diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2075a..e668211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,13 @@ # History -## 3.6.0 +## 3.6.0 - Minor breaking change - Added `reset_profiles` method to `TickTock`. - Added `do_at_interval` for triggering events at intervals. - Added `disable` option to `TickTock.profile`. +- Updated libraries for running tests and distribution wheels. +This has no direct impact but it has forced the rename `UnitTestCollection.test_path` -> `UnitTestCollection.get_test_path` to prevent `pytest` complaining. +- Wheels are no longer provided for x86 MAC. ## 3.5.0 diff --git a/pelutils/tests.py b/pelutils/tests.py index 53e0818..bf6d3eb 100644 --- a/pelutils/tests.py +++ b/pelutils/tests.py @@ -92,9 +92,9 @@ def ignore_absentee(_, __, exc_inf): # noqa: D102 raise except_instance @classmethod - def test_path(cls, path: str) -> str: + def get_test_path(cls, pathe: str) -> str: """Return a path inside the test directory. `path` would often just be a filename which can be written to and is automatically cleaned up after the test. """ - return os.path.join(cls.test_dir, path) + return os.path.join(cls.test_dir, pathe) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6aefb3b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +filterwarnings = + ignore::DeprecationWarning:matplotlib diff --git a/setup.py b/setup.py index 9276f9c..dfe3727 100644 --- a/setup.py +++ b/setup.py @@ -23,13 +23,13 @@ ] requirements_dev = [ "torch>=2", - "pytest>=6.2.4,<=7.2", - "pytest-cov>=2.12.1", - "coveralls>=3.3.1", - "coverage>=6,<7", + "pytest==8.4.2", + "pytest-cov==7.0.0", + "coveralls>=4.0.0", + "coverage==7.10.7", "wheel", "setuptools>=60.0.0", - "ruff>=0.3.0", + "ruff==0.14.14", "freezegun>=1.5", ] diff --git a/tests/ds/test_plots.py b/tests/ds/test_plots.py index 7e3bc46..7d18607 100644 --- a/tests/ds/test_plots.py +++ b/tests/ds/test_plots.py @@ -176,7 +176,7 @@ class TestFigure(UnitTestCollection): @property def savepath(self) -> str: - return self.test_path("test.png") + return self.get_test_path("test.png") def test_save(self): with Figure(Path(self.savepath)): diff --git a/tests/test_init.py b/tests/test_init.py index 9631850..2cad6e0 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -173,7 +173,7 @@ def test_get_timestamp(self): def test_hardware_info(self): assert isinstance(HardwareInfo.cpu, str) and len(HardwareInfo.cpu) > 0 if OS.is_linux: - assert isinstance(HardwareInfo.sockets, int) and HardwareInfo.sockets >= 1 + assert isinstance(HardwareInfo.sockets, int) else: assert HardwareInfo.sockets is None assert isinstance(HardwareInfo.threads, int) and HardwareInfo.threads > 0 diff --git a/tests/test_jsonl.py b/tests/test_jsonl.py index 5002863..9f8f69c 100644 --- a/tests/test_jsonl.py +++ b/tests/test_jsonl.py @@ -9,7 +9,7 @@ class TestJsonl(UnitTestCollection): @property def path(self) -> str: - return self.test_path("test.jsonl") + return self.get_test_path("test.jsonl") def test_jsonl(self): diff --git a/tests/test_parser.py b/tests/test_parser.py index 4e01c01..97b30e5 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -71,11 +71,11 @@ class TestParser(UnitTestCollection): def setup_class(self): super().setup_class() - self._no_default_file = self.test_path("no-default.ini") - self._default_file = self.test_path("default-only.ini") - self._single_job_file = self.test_path("single-job.ini") - self._multiple_jobs_file = self.test_path("multiple-jobs.ini") - self._sample_single_nargs_file = self.test_path("single-nargs.ini") + self._no_default_file = self.get_test_path("no-default.ini") + self._default_file = self.get_test_path("default-only.ini") + self._single_job_file = self.get_test_path("single-job.ini") + self._multiple_jobs_file = self.get_test_path("multiple-jobs.ini") + self._sample_single_nargs_file = self.get_test_path("single-nargs.ini") with open(self._no_default_file, "w") as f: f.write(_sample_no_default) with open(self._default_file, "w") as f: @@ -330,7 +330,7 @@ def test_no_default_section(self): @restore_argv def test_non_optional_args(self): - sys.argv = f"main.py {self.test_path(_testdir)} -c {self._multiple_jobs_file}".split() + sys.argv = f"main.py {self.get_test_path(_testdir)} -c {self._multiple_jobs_file}".split() parser = Parser(*_sample_arguments, multiple_jobs=True) with pytest.raises(ParserError): parser.parse_args() @@ -341,7 +341,7 @@ def test_clear_folders(self): os.makedirs(d) with open(os.path.join(d, "tmp.txt"), "w") as f: f.write("") - sys.argv = f"main.py {self.test_path(_testdir)}".split() + sys.argv = f"main.py {self.get_test_path(_testdir)}".split() parser = Parser() parser.parse_args() assert os.listdir(d) @@ -418,7 +418,7 @@ def test_no_unknown_args(self): @restore_argv def test_document(self): - shutil.rmtree(self.test_path(_testdir)) + shutil.rmtree(self.get_test_path(_testdir)) sys.argv = _sample_argv_conf(self._no_default_file) parser = Parser(*_sample_arguments) job = parser.parse_args() diff --git a/tests/test_tests.py b/tests/test_tests.py index 6011835..a5cd8c7 100644 --- a/tests/test_tests.py +++ b/tests/test_tests.py @@ -18,5 +18,5 @@ class TestUnitTestCollection(UnitTestCollection): def test_test_path(self): testpath = "test" - testpath = self.test_path(testpath) + testpath = self.get_test_path(testpath) assert testpath.startswith(self.test_dir) From be1f8a9748a8e1f9664b92c3c5c75e35794e0a59 Mon Sep 17 00:00:00 2001 From: Asger Schultz Date: Sat, 24 Jan 2026 23:56:20 +0100 Subject: [PATCH 05/10] Add Ruff badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c8f6e10..eec1113 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # pelutils +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![pytest](https://github.com/peleiden/pelutils/actions/workflows/pytest.yml/badge.svg?branch=master)](https://github.com/peleiden/pelutils/actions/workflows/pytest.yml) [![Coverage Status](https://coveralls.io/repos/github/peleiden/pelutils/badge.svg?branch=master)](https://coveralls.io/github/peleiden/pelutils?branch=master) From 6493178a9f4e716803e20847589eed2df9127ceb Mon Sep 17 00:00:00 2001 From: Asger Schultz Date: Sat, 24 Jan 2026 23:56:29 +0100 Subject: [PATCH 06/10] Bump to v3.6.0 --- pelutils/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pelutils/__version__.py b/pelutils/__version__.py index ef75d55..3354547 100644 --- a/pelutils/__version__.py +++ b/pelutils/__version__.py @@ -1,2 +1,2 @@ # Do not put anything else in this file -__version__ = "3.5.0" +__version__ = "3.6.0" From bf146f43a6696992481d68b91930a9246f6dd790 Mon Sep 17 00:00:00 2001 From: Asger Schultz Date: Sun, 25 Jan 2026 00:22:34 +0100 Subject: [PATCH 07/10] Fix typing errors --- pelutils/tests.py | 1 + pelutils/ticktock.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pelutils/tests.py b/pelutils/tests.py index bf6d3eb..4f2331b 100644 --- a/pelutils/tests.py +++ b/pelutils/tests.py @@ -51,6 +51,7 @@ def __enter__(self): return self._pool def __exit__(self, *_): + assert self._pool is not None self._pool.close() self._pool.join() self._pool = None diff --git a/pelutils/ticktock.py b/pelutils/ticktock.py index 928ca9d..8940bc4 100644 --- a/pelutils/ticktock.py +++ b/pelutils/ticktock.py @@ -174,7 +174,7 @@ class TickTock: def __init__(self): self._tick_starts: dict[Hashable, float] = dict() - self._id_to_profile: dict[int, Profile] = dict() + self._id_to_profile: dict[Profile, Profile] = dict() self.profiles: list[Profile] = list() # Top level profiles self._profile_stack: list[Profile] = list() self._nhits: list[int] = list() @@ -268,7 +268,7 @@ def reset_profiles(self): self.reset() self._tick_starts = tick_starts - def do_at_interval(self, interval: float, id: Hashable = None, *, also_first=False) -> True: + def do_at_interval(self, interval: float, id: Hashable = None, *, also_first=False) -> bool: """Return true if it is at least `interval` since this method was called with the same id previously. A common pattern is to run a piece of code at fixed intervals inside a loop. In the example below, a loop is continuously doing From d336c2e4db01a7bafcdfca1f4a59fcdf52a3931a Mon Sep 17 00:00:00 2001 From: Asger Schultz Date: Sun, 25 Jan 2026 09:17:15 +0100 Subject: [PATCH 08/10] Add PyPi badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index eec1113..6919314 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![pytest](https://github.com/peleiden/pelutils/actions/workflows/pytest.yml/badge.svg?branch=master)](https://github.com/peleiden/pelutils/actions/workflows/pytest.yml) [![Coverage Status](https://coveralls.io/repos/github/peleiden/pelutils/badge.svg?branch=master)](https://coveralls.io/github/peleiden/pelutils?branch=master) +[![PyPi](https://img.shields.io/pypi/v/pelutils.svg)](https://pypi.org/project/pelutils/) The Swiss army knife of Python projects. From 472c6703be2f03ab42fc2ac6480d91fbbfd81177 Mon Sep 17 00:00:00 2001 From: Asger Schultz Date: Sun, 25 Jan 2026 09:29:07 +0100 Subject: [PATCH 09/10] Rename falsely named argument --- pelutils/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pelutils/tests.py b/pelutils/tests.py index 4f2331b..95b67a1 100644 --- a/pelutils/tests.py +++ b/pelutils/tests.py @@ -93,9 +93,9 @@ def ignore_absentee(_, __, exc_inf): # noqa: D102 raise except_instance @classmethod - def get_test_path(cls, pathe: str) -> str: + def get_test_path(cls, path: str) -> str: """Return a path inside the test directory. `path` would often just be a filename which can be written to and is automatically cleaned up after the test. """ - return os.path.join(cls.test_dir, pathe) + return os.path.join(cls.test_dir, path) From 5f6d46cb572088d4cb662d0191540cac8615d5f2 Mon Sep 17 00:00:00 2001 From: Asger Schultz Date: Sun, 25 Jan 2026 09:29:27 +0100 Subject: [PATCH 10/10] Better descriptions --- .github/workflows/dist.yml | 2 +- CHANGELOG.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml index 10aa09d..1c15299 100644 --- a/.github/workflows/dist.yml +++ b/.github/workflows/dist.yml @@ -9,7 +9,7 @@ on: jobs: build_wheels: - name: Build ${{ matrix.python }} on ${{ matrix.os }} + name: Build wheels for ${{ matrix.python }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: diff --git a/CHANGELOG.md b/CHANGELOG.md index e668211..d79ba69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ - Added `disable` option to `TickTock.profile`. - Updated libraries for running tests and distribution wheels. This has no direct impact but it has forced the rename `UnitTestCollection.test_path` -> `UnitTestCollection.get_test_path` to prevent `pytest` complaining. -- Wheels are no longer provided for x86 MAC. +- Wheels are no longer provided for x86 MAC but are for Apple Silicon. +- Wheels are now actually provided again (whoops). ## 3.5.0