From 4a4b9c494a6e5fea9c77bae7b919b439f18d9eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Thu, 16 Jul 2026 08:31:21 +0200 Subject: [PATCH 01/15] mark tests using symlinks on windows as xfail --- tests/test_dirhash.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_dirhash.py b/tests/test_dirhash.py index 68df656..0e348f4 100644 --- a/tests/test_dirhash.py +++ b/tests/test_dirhash.py @@ -164,7 +164,11 @@ def mkfile(self, relpath, content=None): f.write(content) def symlink(self, src, dst): - os.symlink(self.path_to(src), self.path_to(dst)) + try: + os.symlink(self.path_to(src), self.path_to(dst)) + except OSError: + if os.name == "nt": + pytest.xfail("Windows may lack symlink privilege.") def remove(self, relpath): if os.path.isdir(self.path_to(relpath)): @@ -699,6 +703,11 @@ def test_multiproc_speedup(self): assert elapsed_muliproc < 0.9 * expected_min_elapsed_sequential # just check "any speedup", the overhead varies (and is high on Travis) + @pytest.mark.xfail( + os.name == "nt", + raises=OSError, + reason="Windows may lack symlink privilege.", + ) def test_cache_by_real_path_speedup(self, tmpdir): num_links = 10 @@ -735,6 +744,11 @@ def test_cache_by_real_path_speedup(self, tmpdir): elapsed_with_links = end - start assert elapsed_with_links < expected_max_elapsed_with_links + @pytest.mark.xfail( + os.name == "nt", + raises=OSError, + reason="Windows may lack symlink privilege.", + ) def test_cache_together_with_multiprocess_speedup(self, tmpdir): target_file_names = ["target_file_1", "target_file_2"] num_links_per_file = 10 From 355271758b7be30b4fdc5918808f2fde4b431af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Thu, 16 Jul 2026 09:55:22 +0200 Subject: [PATCH 02/15] use parametrize to test available and guaranteed hashers --- tests/test_dirhash.py | 53 +++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/tests/test_dirhash.py b/tests/test_dirhash.py index 0e348f4..6d17af6 100644 --- a/tests/test_dirhash.py +++ b/tests/test_dirhash.py @@ -33,36 +33,29 @@ def map_osp(paths): class TestGetHasherFactory: - def test_get_guaranteed(self): - algorithm_and_hasher_factory = [ - ("md5", hashlib.md5), - ("sha1", hashlib.sha1), - ("sha224", hashlib.sha224), - ("sha256", hashlib.sha256), - ("sha384", hashlib.sha384), - ("sha512", hashlib.sha512), - ] - assert algorithms_guaranteed == {a for a, _ in algorithm_and_hasher_factory} - for algorithm, expected_hasher_factory in algorithm_and_hasher_factory: - hasher_factory = _get_hasher_factory(algorithm) - assert hasher_factory == expected_hasher_factory - - def test_get_available(self): - for algorithm in algorithms_available: - hasher_factory = _get_hasher_factory(algorithm) - try: - hasher = hasher_factory() - except ValueError as exc: - # Some "available" algorithms are not necessarily available - # (fails for e.g. 'ripemd160' in github actions for python 3.8). - # See: https://stackoverflow.com/questions/72409563/unsupported-hash-type-ripemd160-with-hashlib-in-python # noqa: E501 - print(f"Failed to create hasher for {algorithm}: {exc}") - assert exc.args[0] == f"unsupported hash type {algorithm}" - hasher = None - - if hasher is not None: - assert hasattr(hasher, "update") - assert hasattr(hasher, "hexdigest") + @pytest.mark.parametrize("algorithm", algorithms_guaranteed) + def test_get_guaranteed(self, algorithm): + expected_hasher_factory = getattr(hashlib, algorithm) + + hasher_factory = _get_hasher_factory(algorithm) + assert hasher_factory == expected_hasher_factory + + @pytest.mark.parametrize("algorithm", algorithms_available) + def test_get_available(self, algorithm): + hasher_factory = _get_hasher_factory(algorithm) + try: + hasher = hasher_factory() + except ValueError as exc: + # Some "available" algorithms are not necessarily available + # (fails for e.g. 'ripemd160' in github actions for python 3.8). + # See: https://stackoverflow.com/questions/72409563/unsupported-hash-type-ripemd160-with-hashlib-in-python # noqa: E501 + print(f"Failed to create hasher for {algorithm}: {exc}") + assert exc.args[0] == f"unsupported hash type {algorithm}" + hasher = None + + if hasher is not None: + assert hasattr(hasher, "update") + assert hasattr(hasher, "hexdigest") def test_not_available(self): with pytest.raises(ValueError): From 22afea30544e179fe9ff818d9227dd8599e5d280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Thu, 16 Jul 2026 10:52:56 +0200 Subject: [PATCH 03/15] added test to show issue with multiprocessor.Pool when jobs > 1 --- tests/test_dirhash.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_dirhash.py b/tests/test_dirhash.py index 6d17af6..c5949c7 100644 --- a/tests/test_dirhash.py +++ b/tests/test_dirhash.py @@ -1,5 +1,6 @@ import hashlib import os +import pickle import shutil import tempfile from time import sleep, time @@ -57,6 +58,15 @@ def test_get_available(self, algorithm): assert hasattr(hasher, "update") assert hasattr(hasher, "hexdigest") + @pytest.mark.parametrize( + "algorithm", list(algorithms_guaranteed | algorithms_available) + ) + def test_hasher_pickleable(self, algorithm): + hasher_factory = _get_hasher_factory(algorithm) + + unpickled = pickle.loads(pickle.dumps(hasher_factory)) + assert unpickled(b"data").hexdigest() == hasher_factory(b"data").hexdigest() + def test_not_available(self): with pytest.raises(ValueError): _get_hasher_factory("not available") @@ -855,3 +865,16 @@ def mock_func(x): def test_parmap(jobs): inputs = [1, 2, 3, 4] assert _parmap(mock_func, inputs, jobs=jobs) == [2, 4, 6, 8] + + +@pytest.mark.parametrize( + "algorithm", list(algorithms_guaranteed | algorithms_available) +) +@pytest.mark.parametrize("jobs", [1, 2]) +def test_parmap_hasher(jobs, algorithm): + hasher_factory = _get_hasher_factory(algorithm) + dataset = [b"", b"data"] + result = _parmap(hasher_factory, dataset, jobs=jobs) + + assert len(result) == len(dataset) + assert all(hasattr(hash, "hexdigest") for hash in result) From 931bd9770d0987fe8b737bf58cb2bb0d1b331bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Thu, 16 Jul 2026 17:45:32 +0200 Subject: [PATCH 04/15] added tests to check cli with algorithms and jobs --- tests/test_cli.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index ef34ecd..91a1488 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,6 @@ +import contextlib import os +import re import shlex import subprocess import sys @@ -252,3 +254,23 @@ def test_error_bad_argument(self, tmpdir): o, error, returncode = dirhash_run(". --chunk-size not_an_int") assert returncode > 0 assert error != "" + + +@pytest.fixture(scope="module") +def default_tree(tmpdir_factory: pytest.TempPathFactory): + tmpdir = tmpdir_factory.mktemp("default_tree") + + create_default_tree(tmpdir) + with contextlib.chdir(tmpdir): + yield tmpdir + + +@pytest.mark.parametrize("jobs", [1, 2]) +@pytest.mark.parametrize( + "algorithm", sorted(dirhash.algorithms_available | dirhash.algorithms_guaranteed) +) +@pytest.mark.usefixtures("default_tree") +def test_run_algorithms(algorithm, jobs): + o, error, returncode = dirhash_run(f". -a {algorithm} --jobs {jobs}") + assert returncode == 0, error + assert re.match(r"^[0-9a-f]{32,}$", o.strip()) From 31571737f106cc8bfbad9f7424191125c4e127fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Thu, 16 Jul 2026 19:37:48 +0200 Subject: [PATCH 05/15] sort algorithms in parameterized tests for consistency --- tests/test_dirhash.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_dirhash.py b/tests/test_dirhash.py index c5949c7..621bc1d 100644 --- a/tests/test_dirhash.py +++ b/tests/test_dirhash.py @@ -59,7 +59,7 @@ def test_get_available(self, algorithm): assert hasattr(hasher, "hexdigest") @pytest.mark.parametrize( - "algorithm", list(algorithms_guaranteed | algorithms_available) + "algorithm", sorted(algorithms_guaranteed | algorithms_available) ) def test_hasher_pickleable(self, algorithm): hasher_factory = _get_hasher_factory(algorithm) @@ -868,7 +868,7 @@ def test_parmap(jobs): @pytest.mark.parametrize( - "algorithm", list(algorithms_guaranteed | algorithms_available) + "algorithm", sorted(algorithms_guaranteed | algorithms_available) ) @pytest.mark.parametrize("jobs", [1, 2]) def test_parmap_hasher(jobs, algorithm): From c478a2a1f3a0beee2337a4bb87c3357951acc3af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Fri, 17 Jul 2026 15:21:46 +0200 Subject: [PATCH 06/15] refactor test_run_algorithms to compare results for different job counts --- tests/test_cli.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 91a1488..0651f89 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,5 @@ import contextlib import os -import re import shlex import subprocess import sys @@ -265,12 +264,14 @@ def default_tree(tmpdir_factory: pytest.TempPathFactory): yield tmpdir -@pytest.mark.parametrize("jobs", [1, 2]) @pytest.mark.parametrize( "algorithm", sorted(dirhash.algorithms_available | dirhash.algorithms_guaranteed) ) @pytest.mark.usefixtures("default_tree") -def test_run_algorithms(algorithm, jobs): - o, error, returncode = dirhash_run(f". -a {algorithm} --jobs {jobs}") - assert returncode == 0, error - assert re.match(r"^[0-9a-f]{32,}$", o.strip()) +def test_run_algorithms(algorithm): + result1 = dirhash_run(f". -a {algorithm} --jobs 2") + result2 = dirhash_run(f". -a {algorithm} --jobs 1") + + assert result1[0] == result2[0] # hash + assert result1[1] == result2[1] # error + assert result1[2] == result2[2] == 0 # returncode From a97af0593148538f6b3363a41565072fc9de3efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Fri, 17 Jul 2026 15:25:23 +0200 Subject: [PATCH 07/15] added Protocol and Wrapper Class to pickle hashlib objects for multiprocessing --- src/dirhash/hasher.py | 144 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/dirhash/hasher.py diff --git a/src/dirhash/hasher.py b/src/dirhash/hasher.py new file mode 100644 index 0000000..959fe87 --- /dev/null +++ b/src/dirhash/hasher.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import hashlib + +try: + import typing_extensions as t +except ImportError: + import typing as t # type: ignore[no-redef] + + +@t.runtime_checkable +class FactoryHasher(t.Protocol): + """ + A runtime protocol to check for hashlib hasher objects. + + >>> import hashlib + >>> isinstance(hashlib.new("md5-sha1"), FactoryHasher) + True + """ + + def update(self, data: bytes) -> None: ... + def hexdigest(self, **kwargs) -> str: + """ + algorithms `shake_128` and `shake_256` require a `length` keyword argument. + """ + ... + + +class PickableHasher(FactoryHasher): + """ + A wrapper around hashlib hasher objects that makes them picklable. + + >>> import hashlib + >>> isinstance(PickableHasher("md5-sha1"), FactoryHasher) + True + + """ + + algorithms: t.AbstractSet[str] = frozenset( + hashlib.algorithms_available | hashlib.algorithms_guaranteed + ) + """A set of all algorithms available in hashlib, including guaranteed ones.""" + + def __init__( + self, + algorithm: str, + data: bytes = b"", + **kwargs, + ) -> None: + if algorithm not in self.algorithms: + raise ValueError(f"Unknown hash algorithm: {algorithm!r}") + + self._state = { + "name": algorithm, + "data": data, + "kwargs": kwargs, + } + self._hash = hashlib.new(algorithm, data, **kwargs) + + @property + def hash(self) -> hashlib._Hash: + """wrapped hashlib.new() object""" + return self._hash + + def update(self, data: bytes) -> None: + """ + Update this hash object's state with the provided string. + + >>> hash = PickableHasher("md5") + >>> hash.update(b"data") + >>> hash.hexdigest() == hashlib.new("md5", b"data").hexdigest() + True + """ + self._hash.update(data) + + def hexdigest(self, **kwargs) -> str: + """ + Return the digest value as a string of hexadecimal digits. + + >>> PickableHasher("md5", b"data").hexdigest() + '8d777f385d3dfec8815d20f7496026dc' + """ + return self._hash.hexdigest(**kwargs) + + def copy(self) -> t.Self: + """ + Return a copy of the hash object. + + >>> hash = PickableHasher("md5", b"data") + >>> copy = hash.copy() + >>> id(hash) != id(copy) + True + """ + return self.__copy__() + + def __call__(self, data: bytes = b"") -> t.Self: + self._state["data"] = data + self._hash = hashlib.new(self._state["name"], data, **self._state["kwargs"]) + + return self + + def __copy__(self) -> t.Self: + obj = object.__new__(self.__class__) + obj.__dict__.update(self.__dict__) + obj._hash = self._hash.copy() + + return obj + + def __str__(self) -> str: + return str(self._hash) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self._state['name']!r})" + + def __getattr__(self, name: str) -> t.Any: + return getattr(self._hash, name) + + def __getstate__(self) -> dict[str, t.Any]: + return self._state + + def __setstate__(self, state: dict[str, t.Any]) -> None: + self._state = state + self._hash = hashlib.new(state["name"], state["data"], **state["kwargs"]) + + +def new(algorithm: str, data: bytes = b"", **kwargs) -> PickableHasher: + """ + Create a new picklable hasher object. + + >>> import hashlib, pickle + >>> hash = new("md5", b"data") + >>> hash.hexdigest() == hashlib.new("md5", b"data").hexdigest() + True + >>> pickled = pickle.loads(pickle.dumps(hash)) + >>> hash.hexdigest() == pickled.hexdigest() + True + """ + return PickableHasher(algorithm, data, **kwargs) + + +if __name__ == "__main__": + import doctest + + doctest.testmod(exclude_empty=True) From bb30a2151e548f3a506c46a9f06aaf5cbf84fc45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Fri, 17 Jul 2026 15:28:51 +0200 Subject: [PATCH 08/15] refactor _get_hasher_factory to return an instance of PickleHasher --- src/dirhash/__init__.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/dirhash/__init__.py b/src/dirhash/__init__.py index 0e49b64..302cc8e 100644 --- a/src/dirhash/__init__.py +++ b/src/dirhash/__init__.py @@ -9,6 +9,7 @@ from scantree import CyclicLinkedDir, RecursionFilter, scantree from . import _version +from .hasher import FactoryHasher, new __version__ = _version.get_versions()["version"] @@ -526,21 +527,13 @@ def _get_hasher_factory(algorithm): name. Bypasses input argument `algorithm` if it is already a hasher factory (verified by attempting calls to required methods). """ - if algorithm in algorithms_guaranteed: - return getattr(hashlib, algorithm) - if algorithm in algorithms_available: - return partial(hashlib.new, algorithm) - - try: # bypass algorithm if already a hasher factory - hasher = algorithm(b"") - hasher.update(b"") - hasher.hexdigest() + if isinstance(algorithm, FactoryHasher) or ( + callable(algorithm) and isinstance(algorithm(), FactoryHasher) + ): return algorithm - except: # noqa: E722 - pass - raise ValueError(f"`algorithm` must be one of: {algorithms_available}`") + return new(algorithm) def _parmap(func, iterable, jobs=1): From 35cce0cc8ac7f94a535c92856bf3b2c763cb2158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Fri, 17 Jul 2026 15:29:41 +0200 Subject: [PATCH 09/15] fix: correct expected hasher factory assertion in TestGetHasherFactory --- tests/test_dirhash.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_dirhash.py b/tests/test_dirhash.py index 621bc1d..a5dd3a5 100644 --- a/tests/test_dirhash.py +++ b/tests/test_dirhash.py @@ -36,10 +36,10 @@ def map_osp(paths): class TestGetHasherFactory: @pytest.mark.parametrize("algorithm", algorithms_guaranteed) def test_get_guaranteed(self, algorithm): - expected_hasher_factory = getattr(hashlib, algorithm) + expected_hasher_factory = getattr(hashlib, algorithm)() hasher_factory = _get_hasher_factory(algorithm) - assert hasher_factory == expected_hasher_factory + assert hasher_factory.hash.name == expected_hasher_factory.name @pytest.mark.parametrize("algorithm", algorithms_available) def test_get_available(self, algorithm): From e8dab5302dcea735d5f6c460c2098cbff9569a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Fri, 17 Jul 2026 18:22:10 +0200 Subject: [PATCH 10/15] added default length for shake_128 and shake_256 for hexdigest --- src/dirhash/hasher.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/dirhash/hasher.py b/src/dirhash/hasher.py index 959fe87..fa7b394 100644 --- a/src/dirhash/hasher.py +++ b/src/dirhash/hasher.py @@ -80,6 +80,12 @@ def hexdigest(self, **kwargs) -> str: >>> PickableHasher("md5", b"data").hexdigest() '8d777f385d3dfec8815d20f7496026dc' """ + + if self._hash.digest_size == 0: + *_, bits = self._hash.name.split("_") + + kwargs.setdefault("length", int(bits) // 8) + return self._hash.hexdigest(**kwargs) def copy(self) -> t.Self: From 327f55c67fe33f6ec3220628c58256984eb4d6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Thu, 16 Jul 2026 13:16:23 +0200 Subject: [PATCH 11/15] updated to tests to run also with py3.13 and py3.14 - updated tox and github action - added pre-commit hooks to format yaml and tox --- .github/workflows/ci.yml | 6 +----- .pre-commit-config.yaml | 8 ++++++++ tox.ini | 16 +++++++++++++--- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47e7d0d..a17f4b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,4 @@ name: CI - on: push: branches: @@ -10,7 +9,6 @@ on: workflow_dispatch: release: types: [published, edited] - jobs: pre-commit: runs-on: ubuntu-latest @@ -20,15 +18,13 @@ jobs: with: python-version: "3.8" - uses: pre-commit/action@v3.0.1 - tests: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] os: [ubuntu-latest, windows-latest] - steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 393ce81..031ca38 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,3 +11,11 @@ repos: args: - --fix - id: ruff-format + - repo: https://github.com/tox-dev/tox-ini-fmt + rev: 1.7.2 + hooks: + - id: tox-ini-fmt + - repo: https://github.com/google/yamlfmt + rev: v0.21.0 + hooks: + - id: yamlfmt diff --git a/tox.ini b/tox.ini index 168724a..6088f7f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,25 @@ [tox] -envlist = pre-commit,py{38,39,310,311,312} +requires = + tox>=4.2 +env_list = + pre-commit + py{314, 313, 312, 311, 310, 39, 38} [testenv] deps = pytest pytest-cov +set_env = + COVERAGE_FILE = {env_dir}/.coverage commands = pytest --cov=dirhash --cov-report=xml --cov-report=term-missing --cov-config=.coveragerc {posargs:tests} [testenv:pre-commit] skip_install = true -deps = pre-commit -commands = pre-commit run --all-files --show-diff-on-failure +deps = + pre-commit +commands = + pre-commit run --all-files --show-diff-on-failure [gh-actions] python = @@ -20,3 +28,5 @@ python = 3.10: py310 3.11: py311 3.12: py312 + 3.13: py313 + 3.14: py314 From 7ded7e5bee55cbb2ac075a82509bc98647458456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Fri, 17 Jul 2026 18:49:13 +0200 Subject: [PATCH 12/15] refactoring due legacy python versions - change cwd using os functiosn - hashlib hexdigest does not accept kwargs --- src/dirhash/hasher.py | 10 +++++----- tests/test_cli.py | 8 ++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/dirhash/hasher.py b/src/dirhash/hasher.py index fa7b394..3060f4f 100644 --- a/src/dirhash/hasher.py +++ b/src/dirhash/hasher.py @@ -73,7 +73,7 @@ def update(self, data: bytes) -> None: """ self._hash.update(data) - def hexdigest(self, **kwargs) -> str: + def hexdigest(self, length: int | None = None) -> str: """ Return the digest value as a string of hexadecimal digits. @@ -81,12 +81,12 @@ def hexdigest(self, **kwargs) -> str: '8d777f385d3dfec8815d20f7496026dc' """ - if self._hash.digest_size == 0: - *_, bits = self._hash.name.split("_") + if self._hash.digest_size: + return self._hash.hexdigest() - kwargs.setdefault("length", int(bits) // 8) + *_, bits = self._hash.name.split("_") # "shake_128" or "shake_256" - return self._hash.hexdigest(**kwargs) + return self._hash.hexdigest(length or int(bits) // 8) def copy(self) -> t.Self: """ diff --git a/tests/test_cli.py b/tests/test_cli.py index 0651f89..d37ab9a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,3 @@ -import contextlib import os import shlex import subprocess @@ -260,8 +259,13 @@ def default_tree(tmpdir_factory: pytest.TempPathFactory): tmpdir = tmpdir_factory.mktemp("default_tree") create_default_tree(tmpdir) - with contextlib.chdir(tmpdir): + + cwd = os.getcwd() + try: + os.chdir(tmpdir) yield tmpdir + finally: + os.chdir(cwd) @pytest.mark.parametrize( From 2db2e15c58051deb59f598b620a6dc73f60aaad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Sat, 18 Jul 2026 08:11:13 +0200 Subject: [PATCH 13/15] fixed some mypy issues with minor refactoring --- src/dirhash/hasher.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/dirhash/hasher.py b/src/dirhash/hasher.py index 3060f4f..f4de19d 100644 --- a/src/dirhash/hasher.py +++ b/src/dirhash/hasher.py @@ -7,6 +7,13 @@ except ImportError: import typing as t # type: ignore[no-redef] +if t.TYPE_CHECKING: + + class HasherState(t.TypedDict): + name: str + data: bytes + kwargs: dict[str, t.Any] + @t.runtime_checkable class FactoryHasher(t.Protocol): @@ -50,7 +57,7 @@ def __init__( if algorithm not in self.algorithms: raise ValueError(f"Unknown hash algorithm: {algorithm!r}") - self._state = { + self._state: HasherState = { "name": algorithm, "data": data, "kwargs": kwargs, @@ -73,20 +80,25 @@ def update(self, data: bytes) -> None: """ self._hash.update(data) - def hexdigest(self, length: int | None = None) -> str: + def hexdigest(self, length: int | None = None) -> str: # type: ignore[override] """ Return the digest value as a string of hexadecimal digits. - >>> PickableHasher("md5", b"data").hexdigest() - '8d777f385d3dfec8815d20f7496026dc' + >>> PickableHasher("shake_128", b"data").hexdigest() + '6d5d29b8058bdf6517f2487f8dc537ab' + >>> PickableHasher("shake_128", b"data").hexdigest(length=16) + '6d5d29b8058bdf6517f2487f8dc537ab' """ if self._hash.digest_size: return self._hash.hexdigest() - *_, bits = self._hash.name.split("_") # "shake_128" or "shake_256" + def shake_digestsize() -> int: + """return a default digest size for shake_128 and shake_256.""" + *_, bits = self._hash.name.split("_") + return int(bits) // 8 - return self._hash.hexdigest(length or int(bits) // 8) + return self._hash.hexdigest(length or shake_digestsize()) # type: ignore[call-arg] def copy(self) -> t.Self: """ @@ -121,10 +133,10 @@ def __repr__(self) -> str: def __getattr__(self, name: str) -> t.Any: return getattr(self._hash, name) - def __getstate__(self) -> dict[str, t.Any]: + def __getstate__(self) -> HasherState: return self._state - def __setstate__(self, state: dict[str, t.Any]) -> None: + def __setstate__(self, state: HasherState) -> None: self._state = state self._hash = hashlib.new(state["name"], state["data"], **state["kwargs"]) From 8fc4d71e9f9fc595ebf0ab771634491c38588e02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Sat, 18 Jul 2026 09:26:45 +0200 Subject: [PATCH 14/15] added pre-commit hook to re-format pyproject.toml --- .pre-commit-config.yaml | 4 ++++ pyproject.toml | 24 ++++++++++-------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 031ca38..e455916 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,3 +19,7 @@ repos: rev: v0.21.0 hooks: - id: yamlfmt + - repo: https://github.com/tox-dev/pyproject-fmt + rev: v2.25.3 + hooks: + - id: pyproject-fmt diff --git a/pyproject.toml b/pyproject.toml index a032c1b..8d91caf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,16 @@ [build-system] -requires = ["setuptools", "versioneer==0.29"] build-backend = "setuptools.build_meta" +requires = [ "setuptools", "versioneer==0.29" ] [tool.ruff] target-version = "py38" - -[tool.ruff.lint] -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade +lint.select = [ + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "W", # pycodestyle warnings ] - -[tool.ruff.lint.isort] -known-local-folder = ["dirhash"] +lint.isort.known-local-folder = [ "dirhash" ] From 99e84fb8608e76f86cafddf71401a19f5c526cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20D=C3=B6rrer?= Date: Sat, 18 Jul 2026 09:37:27 +0200 Subject: [PATCH 15/15] remove .coveragerc and update coverage configuration in pyproject.toml and tox.ini --- .coveragerc | 4 ---- pyproject.toml | 28 ++++++++++++++++++++++++++++ tox.ini | 2 +- 3 files changed, 29 insertions(+), 5 deletions(-) delete mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index cea0409..0000000 --- a/.coveragerc +++ /dev/null @@ -1,4 +0,0 @@ -[run] -branch = True -source = dirhash -omit = _version.py diff --git a/pyproject.toml b/pyproject.toml index 8d91caf..1f7ae5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,3 +14,31 @@ lint.select = [ "W", # pycodestyle warnings ] lint.isort.known-local-folder = [ "dirhash" ] + +[tool.pytest] +ini_options.minversion = "6.0" +ini_options.testpaths = [ + "src/dirhash/hasher.py", + "tests", +] +ini_options.addopts = [ + "--cov=dirhash", + "--cov-report=xml", + "--cov-report=term-missing", + "--doctest-modules", +] + +[tool.coverage] +run.branch = true +run.omit = [ + "_version.py", + "tests/*", +] +report.exclude_lines = [ + "@t.runtime_checkable", + "def __\\w+__", + "except ImportError", + 'if __name__ == "__main__":', + "if t.TYPE_CHECKING:", + "pragma: no cover", +] diff --git a/tox.ini b/tox.ini index 6088f7f..d5a91c5 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ deps = set_env = COVERAGE_FILE = {env_dir}/.coverage commands = - pytest --cov=dirhash --cov-report=xml --cov-report=term-missing --cov-config=.coveragerc {posargs:tests} + pytest {posargs:tests} [testenv:pre-commit] skip_install = true