From ec70fe1d2613e100c2fc31f3f9be1322d3bcbacc Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 13:25:42 +1000 Subject: [PATCH 01/14] test: ask for the JPEG decoder inside tests, not at collection Collection never calls into the package: a broken build, or a mutant of can_decode(), then fails the tests that ask instead of the whole session. mutmut verifies its setup by breaking every function and needs the tests, not their collection, to fail. --- tests/conftest.py | 12 +++++++++--- tests/test_config.py | 4 ++-- tests/test_decoding.py | 8 ++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 255459a..cb1ae76 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import functools import os from pathlib import Path @@ -13,13 +14,18 @@ DATA = c_reference.DATA + # libjpeg-turbo decodes JPEG unless the build was configured without it; stb_image then -# does, with its own IDCT, no arithmetic coding and no decode scaling. -LIBJPEG_TURBO = ph.can_decode("jpeg") +# does, with its own IDCT, no arithmetic coding and no decode scaling. The question is +# asked inside tests, not at import: collection never calls into the package, so a +# broken build or a mutant fails the tests that use it instead of the whole session. +@functools.cache +def libjpeg_turbo() -> bool: + return ph.can_decode("jpeg") def needs_libjpeg_turbo(why: str) -> None: - if not LIBJPEG_TURBO: + if not libjpeg_turbo(): pytest.skip(f"stb_image decodes JPEG in this build: {why}") diff --git a/tests/test_config.py b/tests/test_config.py index 719f7b0..15b306a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any import pytest -from conftest import LIBJPEG_TURBO +from conftest import libjpeg_turbo import libphash as ph from libphash import Config @@ -126,7 +126,7 @@ def test_max_pixels(photo: Path) -> None: def test_decode_scale(photo: Path) -> None: with ph.ImageContext(Config(decode_scale=2)) as ctx: ctx.load_file(photo) - side = 200 if LIBJPEG_TURBO else 400 + side = 200 if libjpeg_turbo() else 400 assert (ctx.width, ctx.height) == (side, side) diff --git a/tests/test_decoding.py b/tests/test_decoding.py index d3a2758..38e4b8d 100644 --- a/tests/test_decoding.py +++ b/tests/test_decoding.py @@ -11,7 +11,7 @@ from pathlib import Path import pytest -from conftest import DATA, LIBJPEG_TURBO, data +from conftest import DATA, data, libjpeg_turbo import libphash as ph @@ -38,7 +38,7 @@ def needs_decoder(name: str) -> None: def test_every_variant_decodes(name: str, grayscale: bool) -> None: needs_decoder(name) with ph.ImageContext(ph.Config(grayscale=grayscale)) as ctx: - if name == "jpeg/arithmetic.jpg" and not LIBJPEG_TURBO: + if name == "jpeg/arithmetic.jpg" and not libjpeg_turbo(): # stb_image has no arithmetic decoding. with pytest.raises(ph.CorruptDataError): ctx.load_file(data(name)) @@ -73,7 +73,7 @@ def test_broken_files_are_corrupt_data(name: str) -> None: @pytest.mark.parametrize("keep", [0.5, 0.9]) def test_truncated_jpeg_is_corrupt_data(photo_bytes: bytes, keep: float) -> None: # The detail is the decoder's own message. - message = "Premature end" if LIBJPEG_TURBO else "truncated" + message = "Premature end" if libjpeg_turbo() else "truncated" with pytest.raises(ph.CorruptDataError, match=message): ph.hash(photo_bytes[: int(len(photo_bytes) * keep)]) @@ -92,7 +92,7 @@ def test_decode_scale_reduces_jpeg_only(scale: ph.DecodeScale) -> None: config = ph.Config(decode_scale=scale) with ph.ImageContext(config) as ctx: ctx.load_file(data("photo.jpeg")) - side = 400 // scale if LIBJPEG_TURBO else 400 + side = 400 // scale if libjpeg_turbo() else 400 assert (ctx.width, ctx.height) == (side, side) first = ctx.hashes(*ph.Algorithm) ctx.load_file(data("photo.jpeg")) From da2947ff7854edc23a61f546dd6c21e4cbd0a756 Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 13:28:43 +1000 Subject: [PATCH 02/14] fix: Ctrl+C raises KeyboardInterrupt from a batch on the calling thread With threads=1 libphash runs the callbacks on the calling thread, and KeyboardInterrupt arrives as a callback is entered, before its own try. cffi printed "Exception ignored" and returned 0: the batch stopped, and hash_files() returned the cancelled results instead of raising. The callbacks now leave escaping exceptions to an onerror handler, which keeps them per thread for finish() to re-raise. The Ctrl+C test runs with one thread and with workers, with and without a progress callback. The unused interrupted and previous_wakeup attributes are removed. --- src/libphash/_batch.py | 44 ++++++++++++++++++++++++++++-------------- tests/test_batch.py | 22 +++++++++++++++------ 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/libphash/_batch.py b/src/libphash/_batch.py index c07a1d2..9ccb894 100644 --- a/src/libphash/_batch.py +++ b/src/libphash/_batch.py @@ -215,9 +215,7 @@ def __init__( self.done = 0 self.stop = False self.error: BaseException | None = None - self.interrupted = False self.wakeup: socket.socket | None = None - self.previous_wakeup = -1 self.options = ffi.new("ph_batch_options_t *") check(lib.ph_batch_options_init(self.options)) self.handle = ffi.new_handle(self) @@ -235,6 +233,7 @@ def __enter__(self) -> Self: lib.ph_free(template) raise self.options.config = template + _escaped.error = None self._watch_sigint() return self @@ -245,6 +244,9 @@ def __exit__(self, *exc_info: object) -> None: def finish(self, code: int) -> None: """Check the batch's return code, re-raising a callback's exception.""" + escaped, _escaped.error = _escaped.error, None + if escaped is not None: + raise escaped if self.error is not None: raise self.error if code not in (PH_SUCCESS, PH_ERR_CANCELLED): @@ -292,7 +294,6 @@ def should_continue(self) -> bool: except (BlockingIOError, InterruptedError): received = b"" if int(signal.SIGINT) in received: - self.interrupted = True self.stop = True return False return True @@ -319,19 +320,32 @@ def _check_threads(threads: object) -> int: return threads -@ffi.def_extern(name="_libphash_should_continue") +class _Escaped(threading.local): + error: BaseException | None = None + + +# An exception that leaves a callback never reaches C: cffi passes it to +# _keep_escaped and returns 0 to C, which stops the batch. Such an exception can +# arrive as the callback is entered, before any line of it runs: KeyboardInterrupt, +# when the calling thread runs the callbacks (threads=1). It is kept per thread and +# re-raised by finish() on the calling thread; on a worker thread it only stops the +# batch, since the calling thread cannot see it. +_escaped = _Escaped() + + +def _keep_escaped( + _type: type[BaseException] | None, value: BaseException | None, _tb: object +) -> None: + _escaped.error = value + + +@ffi.def_extern(name="_libphash_should_continue", onerror=_keep_escaped) def should_continue_callback(user_data: void_ptr) -> int: - try: - run = cast("_Run", ffi.from_handle(user_data)) - return 1 if run.should_continue() else 0 - except BaseException: # noqa: BLE001 -- a callback must not raise into C - return 0 + run = cast("_Run", ffi.from_handle(user_data)) + return 1 if run.should_continue() else 0 -@ffi.def_extern(name="_libphash_on_progress") +@ffi.def_extern(name="_libphash_on_progress", onerror=_keep_escaped) def on_progress_callback(_done: int, _total: int, user_data: void_ptr) -> None: - try: - run = cast("_Run", ffi.from_handle(user_data)) - run.on_progress() - except BaseException: # noqa: BLE001, S110 -- a callback must not raise into C - pass + run = cast("_Run", ffi.from_handle(user_data)) + run.on_progress() diff --git a/tests/test_batch.py b/tests/test_batch.py index 806250a..6e3428b 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -101,20 +101,30 @@ def boom(_done: int, _total: int) -> None: ph.hash_files(paths() * 10, threads=2, progress=boom) +# threads=1 runs the callbacks on the calling thread, where KeyboardInterrupt arrives +# inside a callback; with workers it arrives once the call returns, and the batch +# learns of it through the wakeup fd. @pytest.mark.skipif( sys.platform == "win32", reason="SIGINT delivery differs on Windows" ) -def test_ctrl_c_stops_the_batch() -> None: +@pytest.mark.parametrize("threads", [1, 2]) +@pytest.mark.parametrize("with_progress", [True, False]) +def test_ctrl_c_stops_the_batch(threads: int, *, with_progress: bool) -> None: big = [data("photo_large.jpeg")] * 2000 done: list[int] = [] - def interrupt_once_started(d: int, _t: int) -> None: + def count(d: int, _t: int) -> None: done.append(d) - if d == 5: - threading.Thread(target=os.kill, args=(os.getpid(), signal.SIGINT)).start() - with pytest.raises(KeyboardInterrupt): - ph.hash_files(big, threads=2, progress=interrupt_once_started) + timer = threading.Timer(0.2, os.kill, (os.getpid(), signal.SIGINT)) + timer.start() + try: + with pytest.raises(KeyboardInterrupt): + ph.hash_files( + big, threads=threads, progress=count if with_progress else None + ) + finally: + timer.cancel() assert len(done) < len(big) From 5302c5bd3ddd09e609fe540e393ca3ab16025357 Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 13:28:43 +1000 Subject: [PATCH 03/14] build: branch coverage of the package, combined across platforms --- .gitignore | 1 + pyproject.toml | 21 +++++++- uv.lock | 138 +++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 155 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 45a0f0c..ad2c064 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ _native.*.pyd .ruff_cache/ .mypy_cache/ .coverage +.coverage.* htmlcov/ tests/output_*.jpeg diff --git a/pyproject.toml b/pyproject.toml index 85b1a5d..273667f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = ["cffi>=1.15.0"] "Homepage" = "https://github.com/gudoshnikovn/python-libphash" [project.optional-dependencies] -dev = ["pytest", "hypothesis", "mypy", "basedpyright", "ruff", "typos", "setuptools", "types-setuptools", "types-cffi", "typing_extensions", "numpy", "Pillow"] +dev = ["pytest", "hypothesis", "coverage", "mypy", "basedpyright", "ruff", "typos", "setuptools", "types-setuptools", "types-cffi", "typing_extensions", "numpy", "Pillow"] [tool.setuptools] zip-safe = false @@ -72,6 +72,25 @@ before-all = "choco install nasm --no-progress -y" # escape; each one is doubled. environment = { PATH = 'C:\\Program Files\\NASM;$PATH' } +# Branch coverage of the package, measured against the editable build. File names are +# stored relative to the checkout, so the data files of every platform's job combine +# into one report; branches that run only on Windows or only on POSIX count once +# combined, and the threshold applies to the combined report. +[tool.coverage.run] +branch = true +source = ["src/libphash"] +relative_files = true +parallel = true + +[tool.coverage.paths] +source = ["src/libphash/", "src\\libphash\\"] + +[tool.coverage.report] +fail_under = 100 +show_missing = true +skip_covered = true +exclude_also = ["if TYPE_CHECKING:"] + [tool.mypy] strict = true python_version = "3.10" diff --git a/uv.lock b/uv.lock index 1cc9cf7..82a2fab 100644 --- a/uv.lock +++ b/uv.lock @@ -3,10 +3,10 @@ revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", @@ -212,6 +212,134 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/72/db17bf5f87568ab524413693385be2eeb03e652ab56c54ea05fa85515675/coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b", size = 223670, upload-time = "2026-09-27T12:25:34.675Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e1/285727a8a74d48de256e8605413ee4cf82301228253ea58e5ab4270138b0/coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87", size = 224191, upload-time = "2026-09-27T12:25:36.476Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/c737b73bac9bf5034f5ff45a4237b7a189d417a4e36751faf5d10c082cbe/coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3", size = 250921, upload-time = "2026-09-27T12:25:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/29/4e/e1d38d27817d91776ca543d01276a67eace3df675a673cac32b12c167d58/coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02", size = 252751, upload-time = "2026-09-27T12:25:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/0e/19/14a8e44cbb2ad36ae62aaa03c5eec4a06a7f7df220ea3776af26927acbe2/coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb", size = 254616, upload-time = "2026-09-27T12:25:40.506Z" }, + { url = "https://files.pythonhosted.org/packages/f9/72/f5bcad0d9a9b450080032344fbff7ec60c1b0e3d38019f7735dee2b69645/coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72", size = 256541, upload-time = "2026-09-27T12:25:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9a/8c735234e8abb52bf5d063f98c780fa942e77c4c0225f9fcb9b33effc346/coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08", size = 251589, upload-time = "2026-09-27T12:25:43.269Z" }, + { url = "https://files.pythonhosted.org/packages/35/6a/1bf6d32e55642d6972aa842640e0a8850e612d19ac7d05e2c32fa59dfd34/coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032", size = 252660, upload-time = "2026-09-27T12:25:45.051Z" }, + { url = "https://files.pythonhosted.org/packages/53/5b/05b1c0d0e9495cb056155a16066259adc1e935e7411a82a62779bf729975/coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205", size = 250660, upload-time = "2026-09-27T12:25:46.51Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/5bc3db57fc9c56b4ef055725c38c34faa818a27d17d65e251371f7f0e3a0/coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72", size = 254479, upload-time = "2026-09-27T12:25:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0f/4a5de66daef26eb919213f63b84f45d5065ab2df94cd767fc7c1174ac5e2/coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5", size = 250940, upload-time = "2026-09-27T12:25:49.227Z" }, + { url = "https://files.pythonhosted.org/packages/57/d3/84cd6a11e707e422194739e9734d947746efa5e5b3358ff2d601f823ee45/coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943", size = 251543, upload-time = "2026-09-27T12:25:50.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/bc/3d84c2e2a95f38e346ce3730d9f0e53114f4e8aace72e6e51e014afb9992/coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7", size = 225732, upload-time = "2026-09-27T12:25:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/07/86/31f1f3170571a345ab9d8361a7b2f8e0c5698173fbecd1f7149b6e2089ee/coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e", size = 226357, upload-time = "2026-09-27T12:25:53.272Z" }, + { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/9198b865679379fb165c689c64f6e11105ef380f6bd1c7673e83f73d9f5c/coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", size = 256627, upload-time = "2026-09-27T12:25:59.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/79/9521462cb6072fe394701bc8974b74afd576c9c9355156c7844e1a86a42b/coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", size = 258724, upload-time = "2026-09-27T12:26:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/8d7d5d633db9fe0f3182fedc731bf09f9bcf2366055735152504ad614677/coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", size = 260692, upload-time = "2026-09-27T12:26:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/76cb09c89ba46d74d37428bf93251fc14fb0bbe9e05cc2a5ef61773d318a/coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", size = 254800, upload-time = "2026-09-27T12:26:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/b6/2351c1979aaeb5b4a8091a75b90ca997ad60de36e181ddba267cf61dac97/coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", size = 256423, upload-time = "2026-09-27T12:26:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/ad9a4f8b5cb2d494fa9452b546fe742ed2f9d3847cc14c05e36279a3e649/coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", size = 254487, upload-time = "2026-09-27T12:26:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/a520470472f3e8b01169bf42162b1470c9ba992230432f62ca36269bf3a0/coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", size = 258259, upload-time = "2026-09-27T12:26:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/ff26d5938274745855fa61cfcba0245c88ccc10d98d2cbd96064f16cd5a7/coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", size = 254216, upload-time = "2026-09-27T12:26:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/5d832d3b06785d9f53267e4f2724a9f60c312eee6ebed9063a461d0d3b45/coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", size = 255125, upload-time = "2026-09-27T12:26:10.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/1d33edbc2fcf7d99e384e393e712aa5a2ebbbd8409825357815982207976/coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", size = 225898, upload-time = "2026-09-27T12:26:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7c/676df4882118756c4f8f560c954eddb93e166d84dda8c5f0b6a829689bde/coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", size = 226369, upload-time = "2026-09-27T12:26:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0e/a457f4a461b3c5610d845137fdd45fa465e011a64c25af440518ab1f4e41/coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", size = 226344, upload-time = "2026-09-27T12:26:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -706,10 +834,10 @@ version = "2.5.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", @@ -951,6 +1079,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "basedpyright" }, + { name = "coverage" }, { name = "hypothesis" }, { name = "mypy" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -970,6 +1099,7 @@ dev = [ requires-dist = [ { name = "basedpyright", marker = "extra == 'dev'" }, { name = "cffi", specifier = ">=1.15.0" }, + { name = "coverage", marker = "extra == 'dev'" }, { name = "hypothesis", marker = "extra == 'dev'" }, { name = "mypy", marker = "extra == 'dev'" }, { name = "numpy", marker = "extra == 'dev'" }, From 671e229f78a5fe08698e6166fe81259241a2cf90 Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 13:33:34 +1000 Subject: [PATCH 04/14] refactor: drop branches the C library and os.path never take ph_get_last_error_message() never returns NULL: it gives an empty string when the last load set no message. os.path.isdir() and exists() answer False for a path they cannot check, so the try around them caught nothing. on_progress is registered only with a progress function, so its check for None never ran. --- src/libphash/_batch.py | 6 +++--- src/libphash/_context.py | 3 +-- src/libphash/_errors.py | 18 ++++++++---------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/libphash/_batch.py b/src/libphash/_batch.py index 9ccb894..6fa42bb 100644 --- a/src/libphash/_batch.py +++ b/src/libphash/_batch.py @@ -299,15 +299,15 @@ def should_continue(self) -> bool: return True def on_progress(self) -> None: - if self.progress is None: - return + # The callback is registered only with a progress function. + progress = cast("ProgressCallback", self.progress) with self.lock: self.done += 1 done = self.done if self.stop: return try: - if self.progress(done, self.total) is False: + if progress(done, self.total) is False: self.stop = True except BaseException as e: # noqa: BLE001 -- re-raised from finish() self.error = e diff --git a/src/libphash/_context.py b/src/libphash/_context.py index dc18cc5..7845473 100644 --- a/src/libphash/_context.py +++ b/src/libphash/_context.py @@ -265,9 +265,8 @@ def _load_pixels(self, px: Pixels) -> None: @staticmethod def _last_message(ptr: ph_context_t_ptr) -> str: + # Never NULL: an empty string when the last load set no message. msg = lib.ph_get_last_error_message(ptr) - if not msg: - return "" return str(ffi.string(msg).decode("utf-8", "replace")) # --- Hashing --------------------------------------------------------------------- diff --git a/src/libphash/_errors.py b/src/libphash/_errors.py index b5ace6a..7713add 100644 --- a/src/libphash/_errors.py +++ b/src/libphash/_errors.py @@ -89,16 +89,14 @@ def _io_error( cls: type[ImageIOError] = ImageIOError if path is not None: # The C library reports every unusable path as PH_ERR_IO; the file system tells - # which OSError it is. - try: - if os.path.isdir(path): - err, cls = errno.EISDIR, IsADirectoryImageError - elif not os.path.exists(path): - err, cls = errno.ENOENT, FileNotFoundImageError - elif not os.access(path, os.R_OK): - err, cls = errno.EACCES, PermissionDeniedImageError - except (OSError, ValueError): - pass + # which OSError it is. isdir() and exists() answer False for a path they cannot + # check, so access() sees only paths that exist. + if os.path.isdir(path): + err, cls = errno.EISDIR, IsADirectoryImageError + elif not os.path.exists(path): + err, cls = errno.ENOENT, FileNotFoundImageError + elif not os.access(path, os.R_OK): + err, cls = errno.EACCES, PermissionDeniedImageError exc = cls(message, code=code, detail=detail) exc.errno = err exc.filename = os.fsdecode(path) if path is not None else None From 00bbf4bcfae2688fa507df6ca6f191d1d60961bb Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 13:33:34 +1000 Subject: [PATCH 05/14] test: every line and branch of the package The tests cover what was reachable but untested: algorithms given as their C value, an empty algorithm list, ImageContext.config, file objects whose read() returns a memoryview or a str, digest-only hashes(), comparisons with other types, memoryview pixel layouts, array interfaces without data, a batch without the wakeup fd or alongside an event loop's, and a progress callback that is not called after it stops the batch. C errors that the binding's own checks leave unreachable are reached through a stand-in lib that fails one function: the caller gets the matching exception, and a batch frees its config template. --- pyproject.toml | 4 +- tests/test_batch.py | 44 +++++++++++++++ tests/test_c_errors.py | 124 +++++++++++++++++++++++++++++++++++++++++ tests/test_context.py | 41 ++++++++++++++ tests/test_digest.py | 14 +++++ tests/test_hash.py | 10 ++++ tests/test_pixels.py | 42 +++++++++++++- 7 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 tests/test_c_errors.py diff --git a/pyproject.toml b/pyproject.toml index 273667f..b33eb4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,9 @@ environment = { PATH = 'C:\\Program Files\\NASM;$PATH' } # Branch coverage of the package, measured against the editable build. File names are # stored relative to the checkout, so the data files of every platform's job combine # into one report; branches that run only on Windows or only on POSIX count once -# combined, and the threshold applies to the combined report. +# combined, and the threshold applies to the combined report. The batch callbacks run +# on libphash's worker threads, which Python 3.14's sys.monitoring sees and the +# settrace of older versions does not: those lines count from the 3.14+ jobs. [tool.coverage.run] branch = true source = ["src/libphash"] diff --git a/tests/test_batch.py b/tests/test_batch.py index 6e3428b..58ac977 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -2,6 +2,7 @@ import os import signal +import socket import sys import threading from typing import TYPE_CHECKING @@ -150,3 +151,46 @@ def work(i: int) -> None: for i, result in results.items(): assert [r.hashes for r in result] == [r.hashes for r in expected] * 3 assert progress[i] == list(range(1, len(paths()) * 3 + 1)) + + +def test_no_algorithm() -> None: + with pytest.raises(ph.InvalidArgumentError, match="no algorithm given"): + ph.hash_files(paths(), []) + + +def test_progress_is_not_called_after_it_stops_the_batch() -> None: + # Items already running on other workers finish, but do not report. + calls: list[int] = [] + + def stop(d: int, _t: int) -> bool: + calls.append(d) + return False + + results = ph.hash_files(paths() * 10, threads=4, progress=stop) + assert calls == [1] + assert any(isinstance(r.error, ph.BatchCancelledError) for r in results) + + +def test_batch_without_the_wakeup_fd( + monkeypatch: pytest.MonkeyPatch, photo_bytes: bytes +) -> None: + def refuse(_fd: int, /, **_kwargs: object) -> int: + raise ValueError("set_wakeup_fd only works in main thread") + + monkeypatch.setattr(signal, "set_wakeup_fd", refuse) + [result] = ph.hash_buffers([photo_bytes], "ahash") + assert result.hashes == {ph.Algorithm.AHASH: ph.hash(photo_bytes, "ahash")} + + +def test_batch_leaves_an_owned_wakeup_fd_alone(photo_bytes: bytes) -> None: + # An event loop that set the wakeup fd keeps it through the batch. + reader, writer = socket.socketpair() + writer.setblocking(False) + previous = signal.set_wakeup_fd(writer.fileno()) + try: + [result] = ph.hash_buffers([photo_bytes], "ahash") + assert result.ok + finally: + assert signal.set_wakeup_fd(previous) == writer.fileno() + reader.close() + writer.close() diff --git a/tests/test_c_errors.py b/tests/test_c_errors.py new file mode 100644 index 0000000..eb89a4f --- /dev/null +++ b/tests/test_c_errors.py @@ -0,0 +1,124 @@ +"""Errors that the C library returns only for input the binding rejects first. + +The binding checks its arguments before each call, so with the real library these +paths never run; a stand-in ``lib`` that fails one function shows that, should the C +side ever reject what the binding passed, the caller gets the matching exception and +nothing leaks. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +import libphash as ph +import libphash._batch +import libphash._config +import libphash._context +import libphash._digest +from libphash._native import lib + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from libphash._native import ph_context_t_ptr + +ERROR = int(lib.PH_ERR_INVALID_ARGUMENT) + + +class StandInLib: + """The real ``lib`` with some functions replaced.""" + + def __init__(self, **replaced: Callable[..., object]) -> None: + self._replaced = replaced + + def __getattr__(self, name: str) -> object: + return self._replaced.get(name) or getattr(lib, name) + + +def fail(*_args: object) -> int: + return ERROR + + +def failing(*names: str) -> StandInLib: + return StandInLib(**dict.fromkeys(names, fail)) + + +def test_digest_info(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ph, "lib", failing("ph_digest_info")) + with pytest.raises(ph.InvalidArgumentError, match=r"digest_info\(radial\)"): + ph.digest_info("radial") + + +def test_compute_multi(monkeypatch: pytest.MonkeyPatch, photo: Path) -> None: + with ph.ImageContext() as ctx: + ctx.load(photo) + monkeypatch.setattr(libphash._context, "lib", failing("ph_compute_multi")) + with pytest.raises(ph.InvalidArgumentError, match="computing dhash, phash"): + ctx.hashes("phash", "dhash") + + +@pytest.mark.parametrize( + ("function", "call", "context"), + [ + ( + "ph_digest_to_hex", + lambda: ph.Digest(b"\1", ph.DigestKind.BITS).hex(), + "encoding digest", + ), + ("ph_hash_to_hex", lambda: ph.hash_to_hex(1), "encoding hash"), + ], +) +def test_hex_encoding( + monkeypatch: pytest.MonkeyPatch, + function: str, + call: Callable[[], object], + context: str, +) -> None: + monkeypatch.setattr(libphash._digest, "lib", failing(function)) + with pytest.raises(ph.InvalidArgumentError, match=context): + call() + + +def test_digest_metric_sentinel(monkeypatch: pytest.MonkeyPatch) -> None: + d = ph.Digest(b"\1", ph.DigestKind.BITS) + monkeypatch.setattr( + libphash._digest, + "lib", + StandInLib(ph_hamming_distance_digest=lambda *_args: -1), + ) + with pytest.raises(ph.InvalidArgumentError, match="rejected the digests"): + d.hamming_distance(d) + + +def test_batch_options_init( + monkeypatch: pytest.MonkeyPatch, photo_bytes: bytes +) -> None: + monkeypatch.setattr(libphash._batch, "lib", failing("ph_batch_options_init")) + with pytest.raises(ph.InvalidArgumentError): + ph.hash_buffers([photo_bytes]) + + +def test_batch_return_code(monkeypatch: pytest.MonkeyPatch, photo_bytes: bytes) -> None: + monkeypatch.setattr(libphash._batch, "lib", failing("ph_hash_buffers_ex")) + with pytest.raises(ph.InvalidArgumentError, match="batch"): + ph.hash_buffers([photo_bytes]) + + +def test_batch_config_frees_the_template( + monkeypatch: pytest.MonkeyPatch, photo_bytes: bytes +) -> None: + config = ph.Config() # validated before the stand-in fails its setters + freed: list[ph_context_t_ptr] = [] + + def free(ctx: ph_context_t_ptr) -> None: + freed.append(ctx) + lib.ph_free(ctx) + + monkeypatch.setattr(libphash._config, "lib", failing("ph_context_set_gamma")) + monkeypatch.setattr(libphash._batch, "lib", StandInLib(ph_free=free)) + with pytest.raises(ph.InvalidArgumentError): + ph.hash_buffers([photo_bytes], config=config) + assert len(freed) == 1 diff --git a/tests/test_context.py b/tests/test_context.py index dbd974c..a58977a 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,6 +3,7 @@ import pickle import threading from pathlib import Path +from typing import cast import pytest from conftest import data @@ -164,3 +165,43 @@ def work() -> None: for t in threads: t.join() assert errors == [] + + +def test_config_property() -> None: + config = ph.Config(phash_reduction_size=4) + assert ph.ImageContext(config).config is config + assert ph.ImageContext().config == ph.Config() + with pytest.raises(TypeError, match="expected a Config, got dict"): + ph.ImageContext(cast("ph.Config", {})) + + +def test_load_bytes_needs_a_buffer() -> None: + with pytest.raises(TypeError, match="bytes-like encoded image data, got str"): + ph.ImageContext().load_bytes(cast("bytes", "photo.jpeg")) + + +class _Reader: + def __init__(self, data: object) -> None: + self.data = data + + def read(self) -> bytes: + return cast("bytes", self.data) + + +def test_load_fileobj_takes_what_read_returns(photo_bytes: bytes) -> None: + expected = ph.hash(photo_bytes) + for content in (photo_bytes, bytearray(photo_bytes), memoryview(photo_bytes)): + with ph.ImageContext() as ctx: + ctx.load_fileobj(_Reader(content)) + assert ctx.phash() == expected + with pytest.raises(TypeError, match=r"_Reader.read\(\) returned str, not bytes"): + ph.ImageContext().load_fileobj(_Reader("text")) + + +def test_hashes_of_digests_only(photo: Path) -> None: + with ph.ImageContext() as ctx: + ctx.load(photo) + assert ctx.hashes("radial", "bmh") == { + ph.Algorithm.RADIAL: ctx.radial(), + ph.Algorithm.BMH: ctx.bmh(), + } diff --git a/tests/test_digest.py b/tests/test_digest.py index b4b822d..98d5ddc 100644 --- a/tests/test_digest.py +++ b/tests/test_digest.py @@ -1,6 +1,7 @@ import math import pickle from pathlib import Path +from typing import cast import pytest @@ -127,3 +128,16 @@ def test_digest_info_matches_computed_digests(photo: Path) -> None: else: assert (size, kind) == (len(value), value.kind) assert ph.digest_info("bmh", ph.Config(bmh_block_size=8)) == (8, DigestKind.BITS) + + +def test_comparing_with_other_types() -> None: + d = Digest(b"\1", DigestKind.BITS) + assert d != b"\1" + assert (d == 1) is False + with pytest.raises(TypeError, match="expected a Digest, got bytes"): + d.hamming_distance(cast("Digest", b"\1")) + + +def test_hash_from_hex_rejects_garbage() -> None: + with pytest.raises(ph.InvalidArgumentError, match="parsing hash 'xyz'"): + ph.hash_from_hex("xyz") diff --git a/tests/test_hash.py b/tests/test_hash.py index c547238..8a414ed 100644 --- a/tests/test_hash.py +++ b/tests/test_hash.py @@ -1,5 +1,6 @@ import io from pathlib import Path +from typing import cast import pytest from conftest import data @@ -124,3 +125,12 @@ def test_info() -> None: assert ph.can_decode(fmt) is True with pytest.raises(ValueError, match="fmt"): ph.can_decode("gif") # type: ignore[arg-type] # an untyped caller's mistake + + +def test_algorithm_as_its_c_value(photo: Path) -> None: + # Untyped callers may pass the C enum's value. + dhash = cast("ph.AlgorithmLike", int(ph.Algorithm.DHASH)) + assert ph.hash(photo, dhash) == ph.hash(photo, "dhash") + for value in (999, True, 1.0): + with pytest.raises(ph.InvalidArgumentError, match="expected an Algorithm"): + ph.hash(photo, cast("ph.AlgorithmLike", value)) diff --git a/tests/test_pixels.py b/tests/test_pixels.py index 73aba6a..889721e 100644 --- a/tests/test_pixels.py +++ b/tests/test_pixels.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, ClassVar, cast import pytest from conftest import data @@ -83,3 +83,43 @@ def test_wrong_dtype_and_shape() -> None: def test_pixels_ignore_auto_orient() -> None: arr = np.asarray(pil("photo.png").convert("RGB")) assert ph.hash(arr, config=ph.Config(auto_orient=False)) == ph.hash(arr) + + +def test_channel_order_is_checked() -> None: + rgb = np.asarray(pil("png/rgb8.png")) + with pytest.raises( + ValueError, match="channel_order must be 'rgb' or 'bgr', got 'RGB'" + ): + ph.ImageContext().load_pixels(rgb, channel_order=cast("ph.ChannelOrder", "RGB")) + + +def test_not_an_array() -> None: + with pytest.raises(TypeError, match="expected an array of pixels"): + ph.ImageContext().load_pixels(cast("ph.PixelArray", object())) + + +def test_memoryview_layouts() -> None: + rgb = np.asarray(pil("png/rgb8.png")) + with pytest.raises(TypeError, match="format 'H'"): + ph.hash(memoryview(np.zeros((8, 8), dtype=np.uint16))) + # A gapped view and BGR order are copied once. + gapped = memoryview(rgb[:, ::2]) + assert ph.hash(gapped) == ph.hash(np.ascontiguousarray(rgb[:, ::2])) + with ph.ImageContext() as ctx: + ctx.load_pixels(memoryview(np.ascontiguousarray(rgb[:, :, ::-1])), "bgr") + assert ctx.phash() == ph.hash(rgb) + + +class _Interface: + """An array interface whose data is not an address and has no buffer.""" + + __array_interface__: ClassVar[dict[str, object]] = { + "version": 3, + "typestr": "|u1", + "shape": (8, 8, 3), + } + + +def test_array_interface_without_data() -> None: + with pytest.raises(TypeError, match="does not expose its data"): + ph.hash(cast("ph.PixelArray", _Interface())) From f865165447746fdafecc64f5871b62feaf7af92d Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 13:34:40 +1000 Subject: [PATCH 06/14] ci: line and branch coverage over every platform, held at 100 % --- .github/workflows/ci.yml | 54 ++++++++++++++++++++++++++++++++++++++-- CLAUDE.md | 3 ++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11efcc4..697a525 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,11 +114,61 @@ jobs: # -rs lists every skipped test with its reason: a skip here is a fixture or an # optional dependency the job failed to provide. The parity tests build their C # reference against the job's libphash install with the runner's CMake; the - # variable makes them fail rather than skip when they cannot. + # variable makes them fail rather than skip when they cannot. Coverage is + # recorded here and checked by the coverage job over every platform's data. - name: pytest env: LIBPHASH_REQUIRE_C_REFERENCE: '1' - run: uv run --no-sync pytest -rs + run: uv run --no-sync coverage run -m pytest -rs + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-${{ matrix.os }}-${{ matrix.python }} + path: .coverage.* + include-hidden-files: true + if-no-files-found: error + + # Line and branch coverage of the package over every job of the test matrix: a + # branch that runs only on Windows, or only where sys.monitoring sees libphash's + # worker threads, counts once combined. Below 100 % the job fails; the report is in + # the job summary, the HTML one an artifact. + coverage: + needs: test + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + with: + submodules: 'false' + + - name: Install the locked dependencies + run: uv sync --locked --all-extras --no-install-project + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: coverage-* + merge-multiple: true + + - name: Combine + run: uv run --no-sync coverage combine + + - name: HTML report + run: uv run --no-sync coverage html --no-skip-covered --fail-under=0 + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-html + path: htmlcov/ + if-no-files-found: error + + - name: Report (fails below 100 %) + run: | + uv run --no-sync coverage report --format=markdown --fail-under=0 >> "$GITHUB_STEP_SUMMARY" + uv run --no-sync coverage report # The sdist and a wheel built from it, then the test suite against that wheel in a # fresh environment without numpy and Pillow, which are optional at runtime. diff --git a/CLAUDE.md b/CLAUDE.md index bf7a263..63ccbb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ The project is developed with [uv](https://docs.astral.sh/uv/) (`.python-version git submodule update --init --recursive # libphash and its vendored decoders uv sync --all-extras --inexact # builds the extension in editable mode uv run pytest # tests in tests/, fixtures from native/libphash/tests/data +uv run coverage run -m pytest && uv run coverage combine && uv run coverage report # 100 % in CI, combined over platforms uv run ruff check . && uv run ruff format --check . uv run mypy && uv run basedpyright # both strict, over src/, tests/, tools/ uv run python tools/stub.py --check # _native.pyi matches the built module @@ -60,7 +61,7 @@ Typing: the package passes mypy and basedpyright in strict mode, and every type Lint: ruff with a broad rule set and pep257 docstrings (`pyproject.toml`); every exception to a rule is a per-file ignore with a comment or a `# noqa: CODE -- reason`, never a bare `noqa`. - **`tests/`** — pytest suite (`test_cdef.py` fails when a header function is neither wrapped nor listed as deliberately unwrapped). `test_c_parity.py` builds `tests/c/reference.c` (through `c_reference.py`) against the libphash install of `tools/native.py` and requires the binding's hashes and error codes to equal C's for every fixture, setting and pixel layout; without the install or CMake it skips, and CI sets `LIBPHASH_REQUIRE_C_REFERENCE=1` so that it fails instead. `test_corpus.py` does the same for libphash's 200-photo site corpus and is collected only when `LIBPHASH_CORPUS` names the list of fetched photos. -- **`.github/workflows/`** — `ci.yml` (lint, types, tests on every platform, sdist) on every push and PR; `wheels.yml` only when a build file changes: every wheel by cibuildwheel (configuration in `pyproject.toml`), each wheel's hash table (`tools/hash_table.py`) compared across all platforms byte for byte, and a build with JPEG decoded by stb_image; `corpus.yml` (the photo corpus, network) weekly, by hand and when the submodule changes. `libphash-release-check.yml` opens a tracking issue weekly when `native/libphash` is behind libphash's newest release tag. +- **`.github/workflows/`** — `ci.yml` (lint, types, tests on every platform, their line and branch coverage combined and held at 100 %, sdist) on every push and PR; `wheels.yml` only when a build file changes: every wheel by cibuildwheel (configuration in `pyproject.toml`), each wheel's hash table (`tools/hash_table.py`) compared across all platforms byte for byte, and a build with JPEG decoded by stb_image; `corpus.yml` (the photo corpus, network) weekly, by hand and when the submodule changes. `libphash-release-check.yml` opens a tracking issue weekly when `native/libphash` is behind libphash's newest release tag. Code, comments, docstrings and tracked documentation are in English. From 26ccd894f1144de9c7f7ab3e145d79963f5a7bc6 Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 13:51:32 +1000 Subject: [PATCH 07/14] refactor: drop what the pixel and file paths never needed - Pixels loses its keepalive: ffi.from_buffer() keeps its object alive, and an array passed by address is held by the caller for the call. - A memoryview is taken of every buffer exporter, memoryview included; formats 'B' and 'c' are one byte, so the itemsize check is gone. - Palette and gray-with-alpha Pillow images become RGBA whether or not they carry transparency: libphash blends an opaque pixel to its own value exactly, so an opaque RGBA image hashes as RGB. - read_file() raises the ImageIOError itself, for ImageContext and for batches alike, and a batch chunk's progress is shifted by _shifted(). --- src/libphash/_batch.py | 29 ++++++++++++------------ src/libphash/_context.py | 15 ++----------- src/libphash/_sources.py | 48 ++++++++++++++++++++++++---------------- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/libphash/_batch.py b/src/libphash/_batch.py index 6fa42bb..bbfca52 100644 --- a/src/libphash/_batch.py +++ b/src/libphash/_batch.py @@ -15,10 +15,10 @@ from ._algorithms import Algorithm, AlgorithmLike, to_algorithm, uint64_flags from ._config import Config, apply_config, new_context -from ._errors import PH_ERR_CANCELLED, PH_ERR_IO, PH_SUCCESS, check, make_error +from ._errors import PH_ERR_CANCELLED, PH_SUCCESS, check, make_error from ._native import ffi, lib from ._sources import READ_FILES_IN_PYTHON, StrPath, encode_path, read_file -from .exceptions import InvalidArgumentError, PhashError +from .exceptions import ImageIOError, InvalidArgumentError, PhashError if TYPE_CHECKING: from typing_extensions import Self @@ -137,24 +137,16 @@ def _files_via_buffers( failed: dict[int, PhashError] = {} for i, p in enumerate(part): try: - content = read_file(p) - if not content: - raise OSError(0, "empty file") - data.append(content) - except OSError as e: # noqa: PERF203 -- each file fails on its own - failed[i] = make_error(PH_ERR_IO, detail=f"{e.strerror}: {p!s}", path=p) + data.append(read_file(p)) + except ImageIOError as e: # noqa: PERF203 -- each file fails on its own + failed[i] = e data.append(b"\0") # placeholder; its result is replaced below - offset = start - - def chunk_progress(done: int, _total: int, offset: int = offset) -> bool | None: - return progress(offset + done, total) if progress else None - part_results = hash_buffers( data, algos, config=config, threads=threads, - progress=chunk_progress if progress else None, + progress=_shifted(progress, start, total), ) for i, (p, r) in enumerate(zip(part, part_results, strict=True)): if i in failed: @@ -166,6 +158,15 @@ def chunk_progress(done: int, _total: int, offset: int = offset) -> bool | None: return results +def _shifted( + progress: ProgressCallback | None, offset: int, total: int +) -> ProgressCallback | None: + """Report a chunk's progress as progress through the whole batch.""" + if progress is None: + return None + return lambda done, _total: progress(offset + done, total) + + def _cancelled(source: _S) -> BatchResult[_S]: return BatchResult(source, None, make_error(PH_ERR_CANCELLED)) diff --git a/src/libphash/_context.py b/src/libphash/_context.py index 7845473..bba0bbc 100644 --- a/src/libphash/_context.py +++ b/src/libphash/_context.py @@ -7,7 +7,7 @@ from ._algorithms import Algorithm, AlgorithmLike, to_algorithm, uint64_flags from ._config import Config, apply_config, new_context from ._digest import Digest, digest_from_c -from ._errors import PH_ERR_IO, PH_SUCCESS, make_error +from ._errors import PH_SUCCESS, make_error from ._native import ffi, lib from ._sources import ( READ_FILES_IN_PYTHON, @@ -189,7 +189,7 @@ def load_file(self, path: StrPath) -> None: """Load an image file. On failure the context holds no image.""" encoded = encode_path(path) if READ_FILES_IN_PYTHON: - self._load_file_in_python(path) + self.load_bytes(read_file(path)) return with self._lock: ptr = self._live() @@ -197,17 +197,6 @@ def load_file(self, path: StrPath) -> None: if code != PH_SUCCESS: raise make_error(code, detail=self._last_message(ptr), path=path) - def _load_file_in_python(self, path: StrPath) -> None: - try: - data = read_file(path) - except OSError as e: - raise make_error( - PH_ERR_IO, detail=f"{e.strerror}: {path!s}", path=path - ) from e - if not data: - raise make_error(PH_ERR_IO, detail=f"empty file: {path!s}", path=path) - self.load_bytes(data) - def load_bytes(self, data: bytes | bytearray | memoryview) -> None: """Load an encoded image (JPEG, PNG, WebP, ...) from memory, without a copy. diff --git a/src/libphash/_sources.py b/src/libphash/_sources.py index d965ff6..0b9e466 100644 --- a/src/libphash/_sources.py +++ b/src/libphash/_sources.py @@ -10,6 +10,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal, Protocol, Union, cast, runtime_checkable +from ._errors import PH_ERR_IO, make_error from ._native import ffi if TYPE_CHECKING: @@ -49,8 +50,6 @@ class PILImage(Protocol): def mode(self) -> str: ... @property def size(self) -> tuple[int, int]: ... - @property - def info(self) -> Mapping[Any, Any]: ... def tobytes(self) -> bytes: ... def convert(self, mode: str) -> PILImage: ... @@ -92,7 +91,7 @@ def encode_path(path: StrPath) -> bytes: def is_pil_image(obj: object) -> bool: - module = type(obj).__module__ or "" + module = str(type(obj).__module__) return ( module.startswith("PIL.") and hasattr(obj, "mode") and hasattr(obj, "tobytes") ) @@ -102,7 +101,8 @@ def is_pil_image(obj: object) -> bool: class Pixels: """Arguments for ph_load_from_pixels(). - ``keepalive`` holds whatever must stay alive during the call. + The pointer is either ``ffi.from_buffer()``'s, which keeps its object alive, or an + array's own address, valid while the caller holds the array. """ pointer: char_ptr | uint8_t_ptr @@ -110,10 +110,9 @@ class Pixels: height: int channels: int stride: int - keepalive: object -def pixels_from_array(obj: PixelArray, channel_order: ChannelOrder = "rgb") -> Pixels: +def pixels_from_array(obj: PixelArray, channel_order: ChannelOrder) -> Pixels: """Read the pixels of an 8-bit array of shape (h, w) or (h, w, 1|3|4). A numpy array whose rows are spaced apart (a crop, ``arr[10:-10, 5:-5]``) is passed @@ -127,20 +126,20 @@ def pixels_from_array(obj: PixelArray, channel_order: ChannelOrder = "rgb") -> P return _from_array_interface(obj, iface, channel_order) try: # Any object with the buffer protocol and a shape, beyond what PixelArray names. - view = obj if isinstance(obj, memoryview) else memoryview(cast("Buffer", obj)) + view = memoryview(cast("Buffer", obj)) except TypeError: raise TypeError( "expected an array of pixels (numpy, memoryview with a shape), " f"got {type(obj).__name__}" ) from None - if view.format not in ("B", "c") or view.itemsize != 1: + if view.format not in ("B", "c"): raise TypeError( f"pixels must be 8-bit unsigned (format 'B'), got format {view.format!r}; " "convert the array to uint8 first" ) height, width, channels = _shape(tuple(view.shape or ())) if view.c_contiguous and channel_order == "rgb": - return Pixels(ffi.from_buffer(view), width, height, channels, 0, view) + return Pixels(ffi.from_buffer(view), width, height, channels, 0) data = view.tobytes() return _packed(data, width, height, channels, channel_order) @@ -172,7 +171,7 @@ def _from_array_interface( ) if zero_copy and isinstance(address, int): pointer = ffi.cast("const uint8_t *", address) - return Pixels(pointer, width, height, channels, row, obj) + return Pixels(pointer, width, height, channels, row) # Any other layout: one C-ordered copy through the buffer protocol. try: packed = memoryview(cast("Buffer", obj)).tobytes() @@ -195,7 +194,7 @@ def _packed( data[0::channels], ) data = bytes(swapped) - return Pixels(ffi.from_buffer(data), width, height, channels, 0, data) + return Pixels(ffi.from_buffer(data), width, height, channels, 0) def _shape(shape: tuple[int, ...]) -> tuple[int, int, int]: @@ -216,9 +215,10 @@ def pixels_from_pil(img: PILImage) -> Pixels: """Read the pixels of a Pillow image. ``L``, ``RGB`` and ``RGBA`` are read as they are. ``1`` becomes ``L``; palette and - gray-with-alpha images become ``RGBA`` (``RGB`` when they carry no transparency); - ``CMYK``, ``YCbCr``, ``HSV`` and ``LAB`` become ``RGB``. 16-bit, 32-bit integer and - float modes are refused: there is no single right way to bring them to 8 bits. + gray-with-alpha images become ``RGBA``, which hashes as ``RGB`` where every pixel is + opaque; ``CMYK``, ``YCbCr``, ``HSV`` and ``LAB`` become ``RGB``. 16-bit, 32-bit + integer and float modes are refused: there is no single right way to bring them to + 8 bits. Pillow does not apply the EXIF orientation to the pixels; pass the image through ``PIL.ImageOps.exif_transpose`` first if it matters. """ @@ -232,8 +232,7 @@ def pixels_from_pil(img: PILImage) -> Pixels: if mode == "1": target = "L" elif mode in ("P", "PA", "LA", "La", "RGBa"): - has_alpha = mode != "P" or "transparency" in img.info - target = "RGBA" if has_alpha else "RGB" + target = "RGBA" else: target = "RGB" img = img.convert(target) @@ -241,9 +240,20 @@ def pixels_from_pil(img: PILImage) -> Pixels: width, height = img.size data = img.tobytes() channels = _PIL_DIRECT[mode] - return Pixels(ffi.from_buffer(data), width, height, channels, 0, data) + return Pixels(ffi.from_buffer(data), width, height, channels, 0) def read_file(path: StrPath) -> bytes: - with open(path, "rb") as f: - return f.read() + """Read an image file for the C library to load from memory. + + A file that cannot be read, or is empty, raises the ImageIOError that the C + library's own open would. + """ + try: + with open(path, "rb") as f: + data = f.read() + except OSError as e: + raise make_error(PH_ERR_IO, detail=f"{e.strerror}: {path!s}", path=path) from e + if not data: + raise make_error(PH_ERR_IO, detail=f"empty file: {path!s}", path=path) + return data From a6fd79557553674498998d8c53d76c69f60466f9 Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 13:55:51 +1000 Subject: [PATCH 08/14] fix: I/O errors read the same whoever opens the file Where the binding reads files itself (Windows), a missing or empty file gave "No such file or directory: " and "empty file: ", and a bytes path printed as b'...'. read_file() now words them as libphash does, "Cannot open '': " and "Cannot read '': file is empty", with the path decoded. The error and batch tests run with libphash opening the files and with the binding reading them, and check the message, errno and filename of each failure. Batch tests check every result's source, the progress total of hash_buffers(), that threads=0 runs on workers, and that an exception from progress stops the batch; Ctrl+C tests clear a wakeup fd the test runner may have set. ImageIOError.__str__ drops its check for empty args, which a message always fills. --- src/libphash/_sources.py | 10 +++-- src/libphash/exceptions.py | 3 +- tests/conftest.py | 27 ++++++++++++++ tests/test_batch.py | 76 +++++++++++++++++++++++++++++++++++--- tests/test_errors.py | 33 ++++++++++++++--- tests/test_paths.py | 1 + 6 files changed, 134 insertions(+), 16 deletions(-) diff --git a/src/libphash/_sources.py b/src/libphash/_sources.py index 0b9e466..67467d8 100644 --- a/src/libphash/_sources.py +++ b/src/libphash/_sources.py @@ -246,14 +246,16 @@ def pixels_from_pil(img: PILImage) -> Pixels: def read_file(path: StrPath) -> bytes: """Read an image file for the C library to load from memory. - A file that cannot be read, or is empty, raises the ImageIOError that the C - library's own open would. + A file that cannot be opened, or is empty, raises the ImageIOError that the C + library's own open would, with the same message. """ try: with open(path, "rb") as f: data = f.read() except OSError as e: - raise make_error(PH_ERR_IO, detail=f"{e.strerror}: {path!s}", path=path) from e + detail = f"Cannot open '{os.fsdecode(path)}': {e.strerror}" + raise make_error(PH_ERR_IO, detail=detail, path=path) from e if not data: - raise make_error(PH_ERR_IO, detail=f"empty file: {path!s}", path=path) + detail = f"Cannot read '{os.fsdecode(path)}': file is empty" + raise make_error(PH_ERR_IO, detail=detail, path=path) return data diff --git a/src/libphash/exceptions.py b/src/libphash/exceptions.py index 2f631a9..a202da1 100644 --- a/src/libphash/exceptions.py +++ b/src/libphash/exceptions.py @@ -73,7 +73,8 @@ def __init__(self, message: str, *, code: int = 0, detail: str = "") -> None: PhashError.__init__(self, message, code=code, detail=detail) def __str__(self) -> str: - return str(self.args[0]) if self.args else "" + # OSError's own would print "[Errno 2] ..." once errno is set. + return str(self.args[0]) def __repr__(self) -> str: # The errno-specific subclasses are an implementation detail. diff --git a/tests/conftest.py b/tests/conftest.py index cb1ae76..2cc055b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,17 @@ import functools import os +import re +import sys from pathlib import Path +from typing import cast import c_reference import pytest from c_reference import Reference, start_reference import libphash as ph +import libphash._batch +import libphash._context # The photo corpus is fetched over the network by a workflow of its own, which names the # list of its files in LIBPHASH_CORPUS; other runs do not collect its test at all. @@ -49,3 +54,25 @@ def photo() -> Path: @pytest.fixture def photo_bytes(photo: Path) -> bytes: return photo.read_bytes() + + +@pytest.fixture(params=["C", "Python"]) +def files_read_by( + request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> str: + """Run the test with libphash opening the files, then with the binding reading them. + + The binding reads files itself on Windows (READ_FILES_IN_PYTHON); either way gives + the same hashes and the same errors. + """ + reader = cast("str", request.param) + if reader == "C" and sys.platform == "win32": + pytest.skip("on Windows the binding always reads the files") + for module in (libphash._context, libphash._batch): + monkeypatch.setattr(module, "READ_FILES_IN_PYTHON", reader == "Python") + return reader + + +def exactly(message: str) -> str: + """Return a ``pytest.raises(match=...)`` pattern for the whole message.""" + return f"^{re.escape(message)}$" diff --git a/tests/test_batch.py b/tests/test_batch.py index 58ac977..39b3c58 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -1,5 +1,6 @@ from __future__ import annotations +import errno import os import signal import socket @@ -13,9 +14,18 @@ import libphash as ph if TYPE_CHECKING: + from collections.abc import Iterator from pathlib import Path +@pytest.fixture +def no_wakeup_fd() -> Iterator[None]: + """Clear a wakeup fd the test runner may have set, so the batch sets its own.""" + previous = signal.set_wakeup_fd(-1) + yield + signal.set_wakeup_fd(previous) + + def paths() -> list[Path]: return [ data(n) @@ -30,6 +40,7 @@ def paths() -> list[Path]: ] +@pytest.mark.usefixtures("files_read_by") @pytest.mark.parametrize("threads", [0, 1, 3]) def test_batch_matches_single_hashes(threads: int) -> None: ps = paths() @@ -43,6 +54,7 @@ def test_batch_matches_single_hashes(threads: int) -> None: } +@pytest.mark.usefixtures("files_read_by") def test_batch_uses_the_config() -> None: cfg = ph.Config(phash_reduction_size=4) [r] = ph.hash_files([data("photo.jpeg")], config=cfg) @@ -50,24 +62,63 @@ def test_batch_uses_the_config() -> None: assert r.hashes[ph.Algorithm.PHASH] == ph.hash(data("photo.jpeg"), config=cfg) -def test_failed_items_carry_their_error(tmp_path: Path) -> None: +def test_failed_items_carry_their_error(tmp_path: Path, files_read_by: str) -> None: bad = tmp_path / "bad.jpg" bad.write_bytes(b"junk") - results = ph.hash_files([data("photo.jpeg"), tmp_path / "missing.jpg", bad]) + empty = tmp_path / "empty.jpg" + empty.write_bytes(b"") + missing = tmp_path / "missing.jpg" + sources = [data("photo.jpeg"), missing, bad, empty] + results = ph.hash_files(sources) + assert [r.source for r in results] == sources assert results[0].ok assert isinstance(results[1].error, FileNotFoundError) + assert (results[1].error.errno, results[1].error.filename) == ( + errno.ENOENT, + str(missing), + ) assert results[1].hashes is None assert "missing.jpg" in str(results[1].error) assert repr(results[1].error).startswith("ImageIOError(") assert isinstance(results[2].error, ph.UnsupportedFormatError) + assert type(results[3].error) is ph.ImageIOError + assert results[3].error.filename == str(empty) + if files_read_by == "Python": + # The binding's reader names the cause, as the C library does for one file. + assert results[1].error.detail == ( + f"Cannot open '{missing}': {os.strerror(errno.ENOENT)}" + ) + assert results[3].error.detail == f"Cannot read '{empty}': file is empty" def test_buffers(photo_bytes: bytes) -> None: - results = ph.hash_buffers([photo_bytes, b"junk", bytearray(photo_bytes)], "ahash") + seen: list[tuple[int, int]] = [] + results = ph.hash_buffers( + [photo_bytes, b"junk", bytearray(photo_bytes)], + "ahash", + progress=lambda d, t: seen.append((d, t)), + ) assert [r.source for r in results] == [0, 1, 2] assert results[0].hashes == {ph.Algorithm.AHASH: ph.hash(photo_bytes, "ahash")} assert isinstance(results[1].error, ph.DecodeError) assert results[2].hashes == results[0].hashes + assert sorted(seen) == [(1, 3), (2, 3), (3, 3)] + + +@pytest.mark.skipif((os.cpu_count() or 1) < 2, reason="one CPU: one worker") +def test_default_threads_are_workers(photo_bytes: bytes) -> None: + # threads=0 runs the items on libphash's pool; threads=1 on the calling thread. + def callers(**kwargs: int) -> set[int]: + idents: set[int] = set() + ph.hash_buffers( + [photo_bytes] * 8, + progress=lambda _d, _t: idents.add(threading.get_ident()), + **kwargs, + ) + return idents + + assert threading.get_ident() not in callers() + assert callers(threads=1) == {threading.get_ident()} def test_digests_cannot_be_batched() -> None: @@ -81,6 +132,7 @@ def test_empty_batch_and_bad_threads() -> None: ph.hash_files(paths(), threads=-1) +@pytest.mark.usefixtures("files_read_by") def test_progress_counts_to_total() -> None: seen: list[tuple[int, int]] = [] ph.hash_files(paths() * 5, threads=4, progress=lambda d, t: seen.append((d, t))) @@ -88,16 +140,26 @@ def test_progress_counts_to_total() -> None: assert {t for _, t in seen} == {30} +@pytest.mark.usefixtures("files_read_by") def test_progress_returning_false_cancels() -> None: - results = ph.hash_files(paths() * 10, threads=1, progress=lambda d, _t: d < 3) + sources = paths() * 10 + results = ph.hash_files(sources, threads=1, progress=lambda d, _t: d < 3) + assert [r.source for r in results] == sources assert sum(r.ok for r in results) == 3 assert all(isinstance(r.error, ph.BatchCancelledError) for r in results[3:]) -def test_exception_in_progress_propagates() -> None: - def boom(_done: int, _total: int) -> None: +@pytest.mark.usefixtures("files_read_by") +def test_exception_in_progress_propagates_and_stops() -> None: + calls: list[int] = [] + + def boom(done: int, _total: int) -> None: + calls.append(done) raise RuntimeError("from progress") + with pytest.raises(RuntimeError, match="from progress"): + ph.hash_files(paths() * 10, threads=1, progress=boom) + assert calls == [1] with pytest.raises(RuntimeError, match="from progress"): ph.hash_files(paths() * 10, threads=2, progress=boom) @@ -110,6 +172,7 @@ def boom(_done: int, _total: int) -> None: ) @pytest.mark.parametrize("threads", [1, 2]) @pytest.mark.parametrize("with_progress", [True, False]) +@pytest.mark.usefixtures("no_wakeup_fd") def test_ctrl_c_stops_the_batch(threads: int, *, with_progress: bool) -> None: big = [data("photo_large.jpeg")] * 2000 done: list[int] = [] @@ -158,6 +221,7 @@ def test_no_algorithm() -> None: ph.hash_files(paths(), []) +@pytest.mark.usefixtures("files_read_by") def test_progress_is_not_called_after_it_stops_the_batch() -> None: # Items already running on other workers finish, but do not report. calls: list[int] = [] diff --git a/tests/test_errors.py b/tests/test_errors.py index 7fe16c7..2aeaf5b 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,3 +1,4 @@ +import errno import os import pickle import sys @@ -12,6 +13,7 @@ from libphash._native import ffi, lib +@pytest.mark.usefixtures("files_read_by") def test_missing_file_is_file_not_found(tmp_path: Path) -> None: missing = tmp_path / "nope.jpg" with pytest.raises(FileNotFoundError) as exc: @@ -20,8 +22,9 @@ def test_missing_file_is_file_not_found(tmp_path: Path) -> None: assert isinstance(err, ph.ImageIOError) assert isinstance(err, ph.PhashError) assert err.code_name == "PH_ERR_IO" - assert err.filename == str(missing) - assert "nope.jpg" in str(err) + assert (err.errno, err.filename) == (errno.ENOENT, str(missing)) + assert str(err) == err.detail + assert str(err) == f"Cannot open '{missing}': {os.strerror(errno.ENOENT)}" def test_directory_is_a_directory(tmp_path: Path) -> None: @@ -45,11 +48,26 @@ def test_unreadable_file_is_permission_error( path.chmod(0o600) +@pytest.mark.usefixtures("files_read_by") def test_empty_file_is_an_io_error(tmp_path: Path) -> None: path = tmp_path / "empty.jpg" path.write_bytes(b"") - with pytest.raises(ph.ImageIOError): + with pytest.raises(ph.ImageIOError) as exc: ph.hash(path) + assert str(exc.value) == f"Cannot read '{path}': file is empty" + assert (exc.value.errno, exc.value.filename) == (None, str(path)) + + +def test_io_error_without_a_path() -> None: + err = make_error(int(lib.PH_ERR_IO)) + assert type(err) is ph.ImageIOError + assert (err.errno, err.filename) == (None, None) + + +def test_io_error_defaults() -> None: + err = ph.ImageIOError("cannot") + assert (str(err), err.code, err.code_name, err.detail) == ("cannot", 0, "", "") + assert repr(err) == "ImageIOError('cannot')" def test_decode_errors() -> None: @@ -104,9 +122,14 @@ def test_exceptions_pickle(make: Callable[[], object]) -> None: def test_io_exception_pickles_with_errno(tmp_path: Path) -> None: with pytest.raises(FileNotFoundError) as exc: ph.hash(tmp_path / "nope.jpg") - clone = pickle.loads(pickle.dumps(exc.value)) + err = exc.value + clone = pickle.loads(pickle.dumps(err)) assert isinstance(clone, FileNotFoundError) - assert clone.errno == exc.value.errno + assert isinstance(err, ph.ImageIOError) + assert isinstance(clone, ph.ImageIOError) + assert (str(clone), repr(clone)) == (str(err), repr(err)) + assert (clone.code, clone.detail) == (err.code, err.detail) + assert (clone.errno, clone.filename) == (err.errno, err.filename) def test_builtin_bases() -> None: diff --git a/tests/test_paths.py b/tests/test_paths.py index 8a51ed1..82ffa87 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -112,5 +112,6 @@ def test_python_read_path_cancels_across_chunks( path = tmp_path / "a.jpg" path.write_bytes(photo_bytes) results = ph.hash_files([path] * 300, threads=1, progress=lambda d, _t: d < 100) + assert len(results) == 300 assert sum(r.ok for r in results) == 100 assert all(isinstance(r.error, ph.BatchCancelledError) for r in results[100:]) From 9850811cb5a091bb55eaa6ee1ae61dbced816bb7 Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 13:55:57 +1000 Subject: [PATCH 09/14] fix: hash Pillow images in mode La Pillow converts premultiplied gray-with-alpha (La) only to LA, so img.convert("RGBA") raised ValueError and ph.hash() of such an image failed. La now goes through LA to RGBA. The pixel tests compare each converted Pillow mode with Pillow's own conversion, on an image whose alpha changes the hashes; check that arrays and memoryviews reach C at their own address with their row step; cover overlapping rows and reversed channels; and check the full text of each pixel error. --- src/libphash/_sources.py | 3 + tests/test_pixels.py | 144 +++++++++++++++++++++++++++++++++------ 2 files changed, 127 insertions(+), 20 deletions(-) diff --git a/src/libphash/_sources.py b/src/libphash/_sources.py index 67467d8..867a55e 100644 --- a/src/libphash/_sources.py +++ b/src/libphash/_sources.py @@ -232,6 +232,9 @@ def pixels_from_pil(img: PILImage) -> Pixels: if mode == "1": target = "L" elif mode in ("P", "PA", "LA", "La", "RGBa"): + if mode == "La": + # Pillow converts premultiplied gray-with-alpha only to LA. + img = img.convert("LA") target = "RGBA" else: target = "RGB" diff --git a/tests/test_pixels.py b/tests/test_pixels.py index 889721e..254c00c 100644 --- a/tests/test_pixels.py +++ b/tests/test_pixels.py @@ -1,9 +1,11 @@ -from typing import TYPE_CHECKING, ClassVar, cast +from typing import TYPE_CHECKING, cast import pytest -from conftest import data +from conftest import data, exactly import libphash as ph +from libphash._native import ffi +from libphash._sources import pixels_from_array np = pytest.importorskip("numpy") Image = pytest.importorskip("PIL.Image") @@ -30,17 +32,69 @@ def test_numpy_matches_pillow() -> None: assert ph.hash(np.ascontiguousarray(arr[:, :, :1])) == ph.hash(arr[:, :, 0].copy()) -@pytest.mark.parametrize("mode", ["1", "P", "LA", "CMYK", "YCbCr"]) -def test_pillow_modes_are_converted(mode: str) -> None: +COLOR = (ph.Algorithm.COLOR_HASH, ph.Algorithm.COLOR_MOMENTS) + + +def all_hashes(source: ph.Source) -> list[int | ph.Digest]: + """Every hash that a gray image has too.""" + return [ph.hash(source, a) for a in ph.Algorithm if a not in COLOR] + + +@pytest.mark.parametrize("mode", ["PA", "LA", "La", "RGBa"]) +def test_pillow_modes_with_alpha_become_rgba(mode: str) -> None: + # Pillow converts premultiplied La only from and to LA. + rgba8 = pil("png/rgba8.png") + img = rgba8.convert("LA").convert("La") if mode == "La" else rgba8.convert(mode) + straight = img.convert("LA") if mode == "La" else img + rgba = straight.convert("RGBA") + assert all_hashes(img) == all_hashes(rgba) + assert all_hashes(rgba) != all_hashes(straight.convert("RGB")) # the alpha counts + + +def test_pillow_palette_becomes_rgba() -> None: + img = pil("png/rgb8.png").convert("P") + assert all_hashes(img) == all_hashes(img.convert("RGB")) + img.info["transparency"] = img.getpixel((0, 0)) + rgba = img.convert("RGBA") + assert all_hashes(img) == all_hashes(rgba) + assert all_hashes(rgba) != all_hashes(img.convert("RGB")) + + +def test_pillow_bilevel_becomes_gray() -> None: + img = pil("png/rgb8.png").convert("1") + assert all_hashes(img) == all_hashes(img.convert("L")) + with pytest.raises(ph.RequiresColorError): + ph.hash(img, "color_hash") + + +@pytest.mark.parametrize("mode", ["CMYK", "YCbCr", "HSV", "LAB"]) +def test_pillow_other_modes_become_rgb(mode: str) -> None: img = pil("png/rgb8.png").convert(mode) - assert isinstance(ph.hash(img), int) + assert all_hashes(img) == all_hashes(img.convert("RGB")) def test_pillow_16_bit_is_refused() -> None: - with pytest.raises(TypeError, match="not 8-bit"): + message = ( + "Pillow mode 'I' is not 8-bit; convert it (img.convert('L') or 'RGB') first" + ) + with pytest.raises(TypeError, match=exactly(message)): ph.hash(pil("png/rgb8.png").convert("I")) +class _LooksLikePillow: + """Has Pillow's attributes but is not a Pillow image.""" + + mode = "RGB" + + def tobytes(self) -> bytes: + return b"\0" * 3 + + +def test_only_pillow_images_are_read_as_pillow() -> None: + with pytest.raises(TypeError, match="got _LooksLikePillow"): + ph.hash(cast("ph.Source", _LooksLikePillow())) + + def test_row_strided_crop_without_copy_matches_a_copy() -> None: arr = np.asarray(pil("photo.png").convert("RGB")) crop = arr[20:-30, 10:-40] @@ -50,10 +104,41 @@ def test_row_strided_crop_without_copy_matches_a_copy() -> None: def test_layouts_that_need_a_copy() -> None: arr = np.asarray(pil("photo.png").convert("RGB")) - for view in (arr[:, ::2], arr[::-1], arr[::2, ::3], np.asfortranarray(arr)): + # Rows that overlap: the C stride must cover a whole row. + overlapping = np.lib.stride_tricks.as_strided( + arr, shape=(10, 20, 3), strides=(30, 3, 1) + ) + views = ( + arr[:, ::2], + arr[::-1], + arr[::2, ::3], + arr[:, :, ::-1], + np.asfortranarray(arr), + overlapping, + ) + for view in views: assert ph.hash(view) == ph.hash(np.ascontiguousarray(view)) +def address(pointer: object) -> int: + return int(ffi.cast("uintptr_t", pointer)) + + +def test_arrays_without_a_copy() -> None: + # The pointer handed to C is the array's own memory, its row step the stride. + rgb = np.asarray(pil("photo.png").convert("RGB")) + gray = np.ascontiguousarray(rgb[:, :, 0]) + for arr in (rgb, rgb[20:-30, 10:-40], gray, gray[5:-5, 5:-5]): + px = pixels_from_array(arr, "rgb") + assert address(px.pointer) == arr.__array_interface__["data"][0] + assert (px.height, px.width) == arr.shape[:2] + assert px.stride == arr.strides[0] + view = memoryview(rgb) + px = pixels_from_array(view, "rgb") + assert address(px.pointer) == address(ffi.from_buffer(view)) + assert (px.height, px.width, px.channels, px.stride) == (*rgb.shape, 0) + + def test_bgr_order() -> None: rgb = np.asarray(pil("png/rgb8.png")) bgr = np.ascontiguousarray(rgb[:, :, ::-1]) @@ -71,6 +156,12 @@ def test_memoryview_with_shape() -> None: assert ph.hash(view) == ph.hash(rgb) +def test_array_interface_checks_the_type() -> None: + message = "pixels must be uint8, got typestr ''; convert the array to uint8 first" + with pytest.raises(TypeError, match=exactly(message)): + ph.hash(cast("ph.PixelArray", _Interface({"version": 3, "shape": (8, 8)}))) + + def test_wrong_dtype_and_shape() -> None: with pytest.raises(TypeError, match="uint8"): ph.hash(np.zeros((8, 8), dtype=np.uint16)) @@ -87,21 +178,35 @@ def test_pixels_ignore_auto_orient() -> None: def test_channel_order_is_checked() -> None: rgb = np.asarray(pil("png/rgb8.png")) - with pytest.raises( - ValueError, match="channel_order must be 'rgb' or 'bgr', got 'RGB'" - ): + message = "channel_order must be 'rgb' or 'bgr', got 'RGB'" + with pytest.raises(ValueError, match=exactly(message)): ph.ImageContext().load_pixels(rgb, channel_order=cast("ph.ChannelOrder", "RGB")) + message = "channel_order='bgr' needs 3 or 4 channels" + with pytest.raises(ValueError, match=exactly(message)): + ph.ImageContext().load_pixels(rgb[:, :, 0], channel_order="bgr") def test_not_an_array() -> None: - with pytest.raises(TypeError, match="expected an array of pixels"): + message = "expected an array of pixels (numpy, memoryview with a shape), got object" + with pytest.raises(TypeError, match=exactly(message)): ph.ImageContext().load_pixels(cast("ph.PixelArray", object())) + # A buffer without a shape is one row of bytes. + with pytest.raises(ValueError, match="shape"): + ph.ImageContext().load_pixels(cast("ph.PixelArray", b"\0" * 12)) def test_memoryview_layouts() -> None: rgb = np.asarray(pil("png/rgb8.png")) - with pytest.raises(TypeError, match="format 'H'"): - ph.hash(memoryview(np.zeros((8, 8), dtype=np.uint16))) + for dtype, fmt in ((np.uint16, "H"), (np.int8, "b")): + message = ( + f"pixels must be 8-bit unsigned (format 'B'), got format '{fmt}'; " + "convert the array to uint8 first" + ) + with pytest.raises(TypeError, match=exactly(message)): + ph.hash(memoryview(np.zeros((8, 8), dtype=dtype))) + # Format 'c' is a byte too. + chars = memoryview(rgb.tobytes()).cast("c", rgb.shape) + assert ph.hash(chars) == ph.hash(rgb) # A gapped view and BGR order are copied once. gapped = memoryview(rgb[:, ::2]) assert ph.hash(gapped) == ph.hash(np.ascontiguousarray(rgb[:, ::2])) @@ -113,13 +218,12 @@ def test_memoryview_layouts() -> None: class _Interface: """An array interface whose data is not an address and has no buffer.""" - __array_interface__: ClassVar[dict[str, object]] = { - "version": 3, - "typestr": "|u1", - "shape": (8, 8, 3), - } + def __init__(self, interface: dict[str, object]) -> None: + self.__array_interface__ = interface def test_array_interface_without_data() -> None: - with pytest.raises(TypeError, match="does not expose its data"): - ph.hash(cast("ph.PixelArray", _Interface())) + message = "the array does not expose its data through the buffer protocol" + interface = {"version": 3, "typestr": "|u1", "shape": (8, 8, 3)} + with pytest.raises(TypeError, match=exactly(message)): + ph.hash(cast("ph.PixelArray", _Interface(interface))) From ed4671676b0b9109071616f3f9200153c5b12e45 Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 13:58:43 +1000 Subject: [PATCH 10/14] refactor: a failed pixel load carries no detail, as libphash writes none ph_load_from_pixels() clears the context's message and never sets one, so reading it after a failed pixel load always gave an empty string. --- src/libphash/_context.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libphash/_context.py b/src/libphash/_context.py index bba0bbc..8521a7c 100644 --- a/src/libphash/_context.py +++ b/src/libphash/_context.py @@ -249,7 +249,8 @@ def _load_pixels(self, px: Pixels) -> None: ptr, px.pointer, px.width, px.height, px.channels, px.stride ) if code != PH_SUCCESS: - raise make_error(code, detail=self._last_message(ptr)) + # libphash writes no message for a pixel load. + raise make_error(code) del px @staticmethod From dba8f9359454e99cf7beb0c8b6a8045f49347158 Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 13:58:43 +1000 Subject: [PATCH 11/14] test: digests, contexts and C's answers, message by message Digest tests take the kind from whichever digest states it, name the right metric for each wrong kind, hash distinct digests apart, quote the text a hex parser refuses (non-ASCII included) and give the full text of the 64-bit range checks. The stand-in lib rejects each digest metric in turn. ImageContext's pickle and copy errors are checked in full, and a missing path that is not UTF-8 reads as U+FFFD in the message. --- tests/test_batch.py | 20 +++++------ tests/test_c_errors.py | 46 ++++++++++++++++++++----- tests/test_context.py | 10 ++++-- tests/test_digest.py | 78 ++++++++++++++++++++++++++++++++++++++++++ tests/test_paths.py | 11 ++++++ tests/test_pixels.py | 13 +++---- 6 files changed, 148 insertions(+), 30 deletions(-) diff --git a/tests/test_batch.py b/tests/test_batch.py index 39b3c58..807ab7e 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -108,17 +108,17 @@ def test_buffers(photo_bytes: bytes) -> None: @pytest.mark.skipif((os.cpu_count() or 1) < 2, reason="one CPU: one worker") def test_default_threads_are_workers(photo_bytes: bytes) -> None: # threads=0 runs the items on libphash's pool; threads=1 on the calling thread. - def callers(**kwargs: int) -> set[int]: - idents: set[int] = set() - ph.hash_buffers( - [photo_bytes] * 8, - progress=lambda _d, _t: idents.add(threading.get_ident()), - **kwargs, - ) - return idents + idents: set[int] = set() + + def note(_done: int, _total: int) -> None: + idents.add(threading.get_ident()) - assert threading.get_ident() not in callers() - assert callers(threads=1) == {threading.get_ident()} + ph.hash_buffers([photo_bytes] * 8, progress=note) + assert idents + assert threading.get_ident() not in idents + idents.clear() + ph.hash_buffers([photo_bytes] * 8, threads=1, progress=note) + assert idents == {threading.get_ident()} def test_digests_cannot_be_batched() -> None: diff --git a/tests/test_c_errors.py b/tests/test_c_errors.py index eb89a4f..686d029 100644 --- a/tests/test_c_errors.py +++ b/tests/test_c_errors.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING import pytest +from conftest import exactly import libphash as ph import libphash._batch @@ -42,6 +43,10 @@ def fail(*_args: object) -> int: return ERROR +def rejected(*_args: object) -> int: + return -1 + + def failing(*names: str) -> StandInLib: return StandInLib(**dict.fromkeys(names, fail)) @@ -82,15 +87,38 @@ def test_hex_encoding( call() -def test_digest_metric_sentinel(monkeypatch: pytest.MonkeyPatch) -> None: - d = ph.Digest(b"\1", ph.DigestKind.BITS) - monkeypatch.setattr( - libphash._digest, - "lib", - StandInLib(ph_hamming_distance_digest=lambda *_args: -1), - ) - with pytest.raises(ph.InvalidArgumentError, match="rejected the digests"): - d.hamming_distance(d) +@pytest.mark.parametrize( + ("function", "kind", "metric", "name"), + [ + ("ph_hamming_distance_digest", "BITS", "hamming_distance", "hamming_distance"), + ("ph_similarity_digest", "BITS", "similarity", "similarity"), + ("ph_l2_distance", "VECTOR16", "l2_distance", "l2_distance"), + ], +) +def test_digest_metric_sentinel( + monkeypatch: pytest.MonkeyPatch, function: str, kind: str, metric: str, name: str +) -> None: + d = ph.Digest(b"\1\1", ph.DigestKind[kind]) + monkeypatch.setattr(libphash._digest, "lib", StandInLib(**{function: rejected})) + message = f"{name}: the C library rejected the digests" + with pytest.raises(ph.InvalidArgumentError, match=exactly(message)): + getattr(d, metric)(d) + + +@pytest.mark.parametrize( + ("function", "kind", "metric"), + [ + ("ph_histogram_intersection", "HISTOGRAM", "histogram_intersection"), + ("ph_radial_similarity", "COEFFICIENTS", "radial_similarity"), + ], +) +def test_digest_metric_error( + monkeypatch: pytest.MonkeyPatch, function: str, kind: str, metric: str +) -> None: + d = ph.Digest(b"\1\2", ph.DigestKind[kind]) + monkeypatch.setattr(libphash._digest, "lib", failing(function)) + with pytest.raises(ph.InvalidArgumentError, match=f"^{metric}: "): + getattr(d, metric)(d) def test_batch_options_init( diff --git a/tests/test_context.py b/tests/test_context.py index a58977a..29b1dbb 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -6,7 +6,7 @@ from typing import cast import pytest -from conftest import data +from conftest import data, exactly import libphash as ph @@ -39,8 +39,12 @@ def test_unclosed_contexts_are_freed(photo: Path) -> None: def test_cannot_pickle_or_copy() -> None: ctx = ph.ImageContext() - for op in (pickle.dumps, copy.copy, copy.deepcopy): - with pytest.raises(TypeError, match="Config"): + message = "ImageContext cannot be pickled; pickle its Config instead" + with pytest.raises(TypeError, match=exactly(message)): + pickle.dumps(ctx) + message = "ImageContext cannot be copied; create one from its Config instead" + for op in (copy.copy, copy.deepcopy): + with pytest.raises(TypeError, match=exactly(message)): op(ctx) diff --git a/tests/test_digest.py b/tests/test_digest.py index 98d5ddc..bb100e7 100644 --- a/tests/test_digest.py +++ b/tests/test_digest.py @@ -1,9 +1,12 @@ import math import pickle +import re +from collections.abc import Callable from pathlib import Path from typing import cast import pytest +from conftest import exactly import libphash as ph from libphash import Digest, DigestKind @@ -141,3 +144,78 @@ def test_comparing_with_other_types() -> None: def test_hash_from_hex_rejects_garbage() -> None: with pytest.raises(ph.InvalidArgumentError, match="parsing hash 'xyz'"): ph.hash_from_hex("xyz") + + +BITS = Digest(b"\x0f", DigestKind.BITS) +UNSPECIFIED = Digest(b"\x0f", DigestKind.UNSPECIFIED) + + +def test_similarity_takes_the_kind_either_digest_states() -> None: + assert BITS.similarity(UNSPECIFIED) == 1.0 + assert UNSPECIFIED.similarity(BITS) == 1.0 + message = ( + "similarity: neither digest states its kind, so the measure is unknown; call " + "hamming_distance(), l2_distance(), histogram_intersection() or " + "radial_similarity() explicitly" + ) + with pytest.raises(ph.DigestKindError, match=exactly(message)): + UNSPECIFIED.similarity(UNSPECIFIED) + vector16 = Digest(b"\0\0", DigestKind.VECTOR16) + message = ( + "similarity: VECTOR16 digests have no normalized similarity; use l2_distance()" + ) + with pytest.raises(ph.DigestKindError, match=exactly(message)): + vector16.similarity(vector16) + + +@pytest.mark.parametrize( + ("kind", "metric", "right"), + [ + (DigestKind.BITS, "l2_distance", "hamming_distance() or similarity()"), + (DigestKind.VECTOR, "hamming_distance", "l2_distance()"), + (DigestKind.VECTOR16, "radial_similarity", "l2_distance()"), + ( + DigestKind.HISTOGRAM, + "hamming_distance", + "histogram_intersection() or similarity()", + ), + (DigestKind.COEFFICIENTS, "l2_distance", "radial_similarity() or similarity()"), + ], +) +def test_wrong_kind_names_the_right_metric( + kind: DigestKind, metric: str, right: str +) -> None: + d = Digest(b"\0\0", kind) + message = f"{metric} does not apply to {kind.name} digests; use {right}" + with pytest.raises(ph.DigestKindError, match=exactly(message)): + getattr(d, metric)(Digest(b"\0\0", DigestKind.UNSPECIFIED)) + + +def test_equal_digests_hash_alike_and_others_apart() -> None: + digests = [Digest(bytes([i]), DigestKind.BITS) for i in range(16)] + assert hash(Digest(b"\1", DigestKind.BITS)) == hash(digests[1]) + assert len({hash(d) for d in digests}) == len(digests) + + +@pytest.mark.parametrize( + ("parse", "what"), + [(Digest.from_hex, "digest"), (ph.hash_from_hex, "hash")], +) +def test_hex_parsers_quote_the_text(parse: Callable[[str], object], what: str) -> None: + # Non-ASCII text is refused by the C parser, not by the encoder. + for text in ("bits:é0", "x" * 50): + with pytest.raises( + ph.InvalidArgumentError, + match=f"^parsing {what} {re.escape(repr(text[:40]))}: ", + ): + parse(text) + + +def test_uint64_messages() -> None: + with pytest.raises(TypeError, match=exactly("a 64-bit hash is an int, got str")): + ph.hash_to_hex(cast("int", "1")) + with pytest.raises(TypeError, match=exactly("a 64-bit hash is an int, got bool")): + ph.hash_to_hex(True) + message = "a 64-bit hash is in [0, 2**64), got -1" + with pytest.raises(ph.InvalidArgumentError, match=exactly(message)): + ph.hash_to_hex(-1) diff --git a/tests/test_paths.py b/tests/test_paths.py index 82ffa87..c00b158 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -62,6 +62,17 @@ def test_symlinks(tmp_path: Path, photo: Path) -> None: ph.hash(dangling) +@pytest.mark.skipif(sys.platform == "win32", reason="libphash opens paths on POSIX") +def test_message_with_a_name_that_is_not_utf8(tmp_path: Path) -> None: + # libphash's message holds the path's bytes; what is not UTF-8 reads as U+FFFD. + raw = os.fsencode(tmp_path) + b"/\xff\xfe-missing.jpg" + with pytest.raises(FileNotFoundError) as exc: + ph.ImageContext().load_file(raw) + assert str(exc.value).endswith( + "/\ufffd\ufffd-missing.jpg': No such file or directory" + ) + + def test_long_path_keeps_the_name(tmp_path: Path) -> None: deep = tmp_path / ("d" * 200) / ("e" * 200) with pytest.raises(ph.ImageIOError) as exc: diff --git a/tests/test_pixels.py b/tests/test_pixels.py index 254c00c..645132e 100644 --- a/tests/test_pixels.py +++ b/tests/test_pixels.py @@ -120,22 +120,19 @@ def test_layouts_that_need_a_copy() -> None: assert ph.hash(view) == ph.hash(np.ascontiguousarray(view)) -def address(pointer: object) -> int: - return int(ffi.cast("uintptr_t", pointer)) - - def test_arrays_without_a_copy() -> None: # The pointer handed to C is the array's own memory, its row step the stride. rgb = np.asarray(pil("photo.png").convert("RGB")) gray = np.ascontiguousarray(rgb[:, :, 0]) for arr in (rgb, rgb[20:-30, 10:-40], gray, gray[5:-5, 5:-5]): px = pixels_from_array(arr, "rgb") - assert address(px.pointer) == arr.__array_interface__["data"][0] + own = ffi.cast("const uint8_t *", arr.__array_interface__["data"][0]) + assert px.pointer == own assert (px.height, px.width) == arr.shape[:2] assert px.stride == arr.strides[0] view = memoryview(rgb) px = pixels_from_array(view, "rgb") - assert address(px.pointer) == address(ffi.from_buffer(view)) + assert px.pointer == ffi.from_buffer(view) assert (px.height, px.width, px.channels, px.stride) == (*rgb.shape, 0) @@ -206,7 +203,7 @@ def test_memoryview_layouts() -> None: ph.hash(memoryview(np.zeros((8, 8), dtype=dtype))) # Format 'c' is a byte too. chars = memoryview(rgb.tobytes()).cast("c", rgb.shape) - assert ph.hash(chars) == ph.hash(rgb) + assert ph.hash(cast("ph.PixelArray", chars)) == ph.hash(rgb) # A gapped view and BGR order are copied once. gapped = memoryview(rgb[:, ::2]) assert ph.hash(gapped) == ph.hash(np.ascontiguousarray(rgb[:, ::2])) @@ -224,6 +221,6 @@ def __init__(self, interface: dict[str, object]) -> None: def test_array_interface_without_data() -> None: message = "the array does not expose its data through the buffer protocol" - interface = {"version": 3, "typestr": "|u1", "shape": (8, 8, 3)} + interface: dict[str, object] = {"version": 3, "typestr": "|u1", "shape": (8, 8, 3)} with pytest.raises(TypeError, match=exactly(message)): ph.hash(cast("ph.PixelArray", _Interface(interface))) From 7b653430e34f80d77a230279b78e39f2d4d64a1c Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 14:08:34 +1000 Subject: [PATCH 12/14] test: cancelled items keep their source, and hex errors their context --- tests/test_c_errors.py | 2 +- tests/test_paths.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_c_errors.py b/tests/test_c_errors.py index 686d029..9ecdeaf 100644 --- a/tests/test_c_errors.py +++ b/tests/test_c_errors.py @@ -83,7 +83,7 @@ def test_hex_encoding( context: str, ) -> None: monkeypatch.setattr(libphash._digest, "lib", failing(function)) - with pytest.raises(ph.InvalidArgumentError, match=context): + with pytest.raises(ph.InvalidArgumentError, match=f"^{context}: "): call() diff --git a/tests/test_paths.py b/tests/test_paths.py index c00b158..dd577af 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -123,6 +123,6 @@ def test_python_read_path_cancels_across_chunks( path = tmp_path / "a.jpg" path.write_bytes(photo_bytes) results = ph.hash_files([path] * 300, threads=1, progress=lambda d, _t: d < 100) - assert len(results) == 300 + assert [r.source for r in results] == [path] * 300 assert sum(r.ok for r in results) == 100 assert all(isinstance(r.error, ph.BatchCancelledError) for r in results[100:]) From ec8d006051d17391a4a8938891e820a3b9c4d1e0 Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 14:08:34 +1000 Subject: [PATCH 13/14] ci: mutation testing, every survivor caught or explained mutmut mutates src/libphash/ and runs the tests against each mutant. tools/mutants.py passes only when every mutant is caught (killed, timed out or crashed) or listed in tests/equivalent_mutants.json with the reason no test can tell it apart: typing.cast() arguments, strict= on zips of equal length, codec name case, buffers larger than C fills, the chunk size of the Python file reader. It fails on a listed mutant that no longer survives. Entries name a mutant by its function and changed lines, not mutmut's number, which shifts as the code changes. mutation.yml runs it weekly, by hand, and when the package or the gate changes. pytest collects only tests/, since mutmut keeps a copy of them in mutants/. --- .github/workflows/mutation.yml | 65 ++++++ .gitignore | 1 + CLAUDE.md | 3 +- pyproject.toml | 11 +- tests/equivalent_mutants.json | 181 ++++++++++++++++ tools/mutants.py | 140 ++++++++++++ uv.lock | 375 ++++++++++++++++++++++++++++++++- 7 files changed, 770 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/mutation.yml create mode 100644 tests/equivalent_mutants.json create mode 100644 tools/mutants.py diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..a607779 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,65 @@ +name: Mutation + +# Mutation testing of the package with mutmut: each mutant of src/libphash/ must be +# caught by the tests or be listed in tests/equivalent_mutants.json with its reason +# (tools/mutants.py). A run takes about a quarter of an hour, so it is not part of +# ci.yml: it runs weekly, by hand, and when the package, the list, the gate or this +# workflow changes. +on: + push: + branches: [ main, 'release/**' ] + paths: &mutation-paths + - src/** + - tests/equivalent_mutants.json + - tools/mutants.py + - .github/workflows/mutation.yml + pull_request: + branches: [ main, 'release/**' ] + paths: *mutation-paths + schedule: + - cron: '41 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + mutmut: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + - name: Build and install (editable) + run: uv sync --locked --all-extras + + # mutmut runs the tests from a copy of the tree in mutants/, where the C reference + # of the parity tests would look for a libphash install of its own; the variable + # points it at the job's. + - name: libphash install + run: | + dir=$(uv run --no-sync python -c "import sys; sys.path.insert(0, 'tools'); import native; print(native.build_dir())") + echo "LIBPHASH_BUILD_DIR=$dir" >> "$GITHUB_ENV" + + - name: mutmut run + env: + LIBPHASH_REQUIRE_C_REFERENCE: '1' + run: uv run --no-sync mutmut run --max-children "$(nproc)" + + - name: Every mutant caught or listed as equivalent + run: uv run --no-sync python tools/mutants.py | tee mutation-report.md + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ !cancelled() }} + with: + name: mutation-report + path: mutation-report.md + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index ad2c064..d5597b0 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ _native.*.pyd .coverage .coverage.* htmlcov/ +mutants/ tests/output_*.jpeg # --- macOS --- diff --git a/CLAUDE.md b/CLAUDE.md index 63ccbb3..3744113 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ git submodule update --init --recursive # libphash and its vendored decoders uv sync --all-extras --inexact # builds the extension in editable mode uv run pytest # tests in tests/, fixtures from native/libphash/tests/data uv run coverage run -m pytest && uv run coverage combine && uv run coverage report # 100 % in CI, combined over platforms +LIBPHASH_BUILD_DIR=$PWD/build/libphash- uv run mutmut run && uv run python tools/mutants.py # not on Windows uv run ruff check . && uv run ruff format --check . uv run mypy && uv run basedpyright # both strict, over src/, tests/, tools/ uv run python tools/stub.py --check # _native.pyi matches the built module @@ -61,7 +62,7 @@ Typing: the package passes mypy and basedpyright in strict mode, and every type Lint: ruff with a broad rule set and pep257 docstrings (`pyproject.toml`); every exception to a rule is a per-file ignore with a comment or a `# noqa: CODE -- reason`, never a bare `noqa`. - **`tests/`** — pytest suite (`test_cdef.py` fails when a header function is neither wrapped nor listed as deliberately unwrapped). `test_c_parity.py` builds `tests/c/reference.c` (through `c_reference.py`) against the libphash install of `tools/native.py` and requires the binding's hashes and error codes to equal C's for every fixture, setting and pixel layout; without the install or CMake it skips, and CI sets `LIBPHASH_REQUIRE_C_REFERENCE=1` so that it fails instead. `test_corpus.py` does the same for libphash's 200-photo site corpus and is collected only when `LIBPHASH_CORPUS` names the list of fetched photos. -- **`.github/workflows/`** — `ci.yml` (lint, types, tests on every platform, their line and branch coverage combined and held at 100 %, sdist) on every push and PR; `wheels.yml` only when a build file changes: every wheel by cibuildwheel (configuration in `pyproject.toml`), each wheel's hash table (`tools/hash_table.py`) compared across all platforms byte for byte, and a build with JPEG decoded by stb_image; `corpus.yml` (the photo corpus, network) weekly, by hand and when the submodule changes. `libphash-release-check.yml` opens a tracking issue weekly when `native/libphash` is behind libphash's newest release tag. +- **`.github/workflows/`** — `ci.yml` (lint, types, tests on every platform, their line and branch coverage combined and held at 100 %, sdist) on every push and PR; `wheels.yml` only when a build file changes: every wheel by cibuildwheel (configuration in `pyproject.toml`), each wheel's hash table (`tools/hash_table.py`) compared across all platforms byte for byte, and a build with JPEG decoded by stb_image; `corpus.yml` (the photo corpus, network) weekly, by hand and when the submodule changes; `mutation.yml` (mutmut over `src/libphash/`) weekly, by hand and when the package changes: every mutant is caught or listed in `tests/equivalent_mutants.json` with its reason, and `tools/mutants.py` fails on any other survivor and on a listed mutant that no longer survives. `libphash-release-check.yml` opens a tracking issue weekly when `native/libphash` is behind libphash's newest release tag. Code, comments, docstrings and tracked documentation are in English. diff --git a/pyproject.toml b/pyproject.toml index b33eb4e..3a2fca8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = ["cffi>=1.15.0"] "Homepage" = "https://github.com/gudoshnikovn/python-libphash" [project.optional-dependencies] -dev = ["pytest", "hypothesis", "coverage", "mypy", "basedpyright", "ruff", "typos", "setuptools", "types-setuptools", "types-cffi", "typing_extensions", "numpy", "Pillow"] +dev = ["pytest", "hypothesis", "coverage", "mypy", "basedpyright", "ruff", "typos", "setuptools", "types-setuptools", "types-cffi", "typing_extensions", "numpy", "Pillow", "mutmut; sys_platform != 'win32'"] [tool.setuptools] zip-safe = false @@ -72,6 +72,10 @@ before-all = "choco install nasm --no-progress -y" # escape; each one is doubled. environment = { PATH = 'C:\\Program Files\\NASM;$PATH' } +[tool.pytest.ini_options] +# mutmut keeps a copy of the tests in mutants/. +testpaths = ["tests"] + # Branch coverage of the package, measured against the editable build. File names are # stored relative to the checkout, so the data files of every platform's job combine # into one report; branches that run only on Windows or only on POSIX count once @@ -93,6 +97,11 @@ show_missing = true skip_covered = true exclude_also = ["if TYPE_CHECKING:"] +[tool.mutmut] +source_paths = ["src/libphash/"] +pytest_add_cli_args_test_selection = ["tests/"] +also_copy = ["native/libphash/include", "native/libphash/tests/data", "tools"] + [tool.mypy] strict = true python_version = "3.10" diff --git a/tests/equivalent_mutants.json b/tests/equivalent_mutants.json new file mode 100644 index 0000000..875b904 --- /dev/null +++ b/tests/equivalent_mutants.json @@ -0,0 +1,181 @@ +{ + "about": [ + "Mutants that no test can tell from the original code (see tools/mutants.py).", + "Each entry is a function, the reason, and the mutants: the lines each one", + "removes (-) and adds (+), as `mutmut show` prints them, stripped." + ], + "equivalent": [ + { + "function": "libphash._batch._Run.on_progress", + "reason": "typing.cast() returns its second argument unchanged; the first is read only by type checkers.", + "mutants": [ + ["-progress = cast(\"ProgressCallback\", self.progress)", "+progress = cast(None, self.progress)"], + ["-progress = cast(\"ProgressCallback\", self.progress)", "+progress = cast(\"XXProgressCallbackXX\", self.progress)"], + ["-progress = cast(\"ProgressCallback\", self.progress)", "+progress = cast(\"progresscallback\", self.progress)"], + ["-progress = cast(\"ProgressCallback\", self.progress)", "+progress = cast(\"PROGRESSCALLBACK\", self.progress)"] + ] + }, + { + "function": "libphash._batch._files_via_buffers", + "reason": "None is false, as False is.", + "mutants": [ + ["-stopped = False", "+stopped = None"] + ] + }, + { + "function": "libphash._batch._files_via_buffers", + "reason": "The chunk size bounds only how many files are held in memory at once; the results do not depend on it.", + "mutants": [ + ["-workers = threads if threads > 0 else (os.cpu_count() or 1)", "+workers = threads if (threads > 0) and False else (os.cpu_count() or 1)"], + ["-workers = threads if threads > 0 else (os.cpu_count() or 1)", "+workers = threads if (threads > 0) or True else (os.cpu_count() or 1)"], + ["-workers = threads if threads > 0 else (os.cpu_count() or 1)", "+workers = threads if threads >= 0 else (os.cpu_count() or 1)"], + ["-workers = threads if threads > 0 else (os.cpu_count() or 1)", "+workers = threads if threads > 1 else (os.cpu_count() or 1)"], + ["-workers = threads if threads > 0 else (os.cpu_count() or 1)", "+workers = threads if threads > 0 else (os.cpu_count() and 1)"], + ["-workers = threads if threads > 0 else (os.cpu_count() or 1)", "+workers = threads if threads > 0 else (os.cpu_count() or 2)"], + ["-chunk = max(64, 4 * workers)", "+chunk = max(65, 4 * workers)"], + ["-chunk = max(64, 4 * workers)", "+chunk = max(64, 4 / workers)"], + ["-chunk = max(64, 4 * workers)", "+chunk = max(64, 5 * workers)"] + ] + }, + { + "function": "libphash._batch._files_via_buffers", + "reason": "The placeholder's result is replaced by the file's read error.", + "mutants": [ + ["-data.append(b\"\\0\") # placeholder; its result is replaced below", "+data.append(b\"XX\\0XX\") # placeholder; its result is replaced below"] + ] + }, + { + "function": "libphash._batch._files_via_buffers", + "reason": "The zipped sequences have one length by construction; strict=True only guards that.", + "mutants": [ + ["-for i, (p, r) in enumerate(zip(part, part_results, strict=True)):", "+for i, (p, r) in enumerate(zip(part, part_results, strict=None)):"], + ["-for i, (p, r) in enumerate(zip(part, part_results, strict=True)):", "+for i, (p, r) in enumerate(zip(part, part_results, )):"], + ["-for i, (p, r) in enumerate(zip(part, part_results, strict=True)):", "+for i, (p, r) in enumerate(zip(part, part_results, strict=False)):"] + ] + }, + { + "function": "libphash._batch.hash_buffers", + "reason": "The zipped sequences have one length by construction; strict=True only guards that.", + "mutants": [ + ["-for item, view in zip(items, views, strict=True):", "+for item, view in zip(items, views, strict=None):"], + ["-for item, view in zip(items, views, strict=True):", "+for item, view in zip(items, views, ):"], + ["-for item, view in zip(items, views, strict=True):", "+for item, view in zip(items, views, strict=False):"] + ] + }, + { + "function": "libphash._batch.hash_buffers", + "reason": "ffi.from_buffer() of a bytes-like object gives char[], which cffi passes for const uint8_t * as well.", + "mutants": [ + ["-views = [ffi.from_buffer(\"const uint8_t[]\", b) for b in buffers]", "+views = [ffi.from_buffer(b) for b in buffers]"] + ] + }, + { + "function": "libphash._context.ImageContext._dimensions", + "reason": "A buffer one element larger than the C function fills, with the size passed to match.", + "mutants": [ + ["-out = ffi.new(\"int[]\", 3)", "+out = ffi.new(\"int[]\", 4)"] + ] + }, + { + "function": "libphash._context.ImageContext._last_message", + "reason": "Codec names are case-insensitive.", + "mutants": [ + ["-return str(ffi.string(msg).decode(\"utf-8\", \"replace\"))", "+return str(ffi.string(msg).decode(\"UTF-8\", \"replace\"))"] + ] + }, + { + "function": "libphash._context.ImageContext.hashes", + "reason": "The zipped sequences have one length by construction; strict=True only guards that.", + "mutants": [ + ["-result.update(zip(uint64, (int(v) for v in out), strict=True))", "+result.update(zip(uint64, (int(v) for v in out), strict=None))"], + ["-result.update(zip(uint64, (int(v) for v in out), strict=True))", "+result.update(zip(uint64, (int(v) for v in out), ))"], + ["-result.update(zip(uint64, (int(v) for v in out), strict=True))", "+result.update(zip(uint64, (int(v) for v in out), strict=False))"] + ] + }, + { + "function": "libphash._context.ImageContext.load_bytes", + "reason": "ffi.from_buffer() of a bytes-like object gives char[], which cffi passes for const uint8_t * as well.", + "mutants": [ + ["-buf = ffi.from_buffer(\"const uint8_t[]\", data)", "+buf = ffi.from_buffer(data)"] + ] + }, + { + "function": "libphash._context._read_all", + "reason": "typing.cast() returns its second argument unchanged; the first is read only by type checkers.", + "mutants": [ + ["-data = cast(\"object\", fileobj.read()) # checked: an untyped read() may return str", "+data = cast(None, fileobj.read()) # checked: an untyped read() may return str"], + ["-data = cast(\"object\", fileobj.read()) # checked: an untyped read() may return str", "+data = cast(\"XXobjectXX\", fileobj.read()) # checked: an untyped read() may return str"], + ["-data = cast(\"object\", fileobj.read()) # checked: an untyped read() may return str", "+data = cast(\"OBJECT\", fileobj.read()) # checked: an untyped read() may return str"], + ["-return cast(\"memoryview\", data)", "+return cast(None, data)"], + ["-return cast(\"memoryview\", data)", "+return cast(\"XXmemoryviewXX\", data)"], + ["-return cast(\"memoryview\", data)", "+return cast(\"MEMORYVIEW\", data)"] + ] + }, + { + "function": "libphash._digest.Digest.from_hex", + "reason": "Codec names are case-insensitive.", + "mutants": [ + ["-code = lib.ph_digest_from_hex(text.encode(\"ascii\", \"replace\"), out)", "+code = lib.ph_digest_from_hex(text.encode(\"ASCII\", \"replace\"), out)"] + ] + }, + { + "function": "libphash._digest.Digest.from_uint64", + "reason": "int.to_bytes() is big-endian by default from Python 3.11, which mutmut runs; the 3.10 test jobs need the argument.", + "mutants": [ + ["-return cls(_check_uint64(value).to_bytes(8, \"big\"), DigestKind.BITS)", "+return cls(_check_uint64(value).to_bytes(8, ), DigestKind.BITS)"] + ] + }, + { + "function": "libphash._digest.Digest.hex", + "reason": "Codec names are case-insensitive.", + "mutants": [ + ["-return str(ffi.string(out).decode(\"ascii\"))", "+return str(ffi.string(out).decode(\"ASCII\"))"] + ] + }, + { + "function": "libphash._digest.hash_from_hex", + "reason": "Codec names are case-insensitive.", + "mutants": [ + ["-code = lib.ph_hash_from_hex(text.encode(\"ascii\", \"replace\"), out)", "+code = lib.ph_hash_from_hex(text.encode(\"ASCII\", \"replace\"), out)"] + ] + }, + { + "function": "libphash._digest.hash_to_hex", + "reason": "A buffer one element larger than the C function fills, with the size passed to match.", + "mutants": [ + ["-out = ffi.new(\"char[]\", 17)", "+out = ffi.new(\"char[]\", 18)"], + ["-code = lib.ph_hash_to_hex(_check_uint64(value), out, 17)", "+code = lib.ph_hash_to_hex(_check_uint64(value), out, 18)"] + ] + }, + { + "function": "libphash._digest.hash_to_hex", + "reason": "Codec names are case-insensitive.", + "mutants": [ + ["-return str(ffi.string(out).decode(\"ascii\"))", "+return str(ffi.string(out).decode(\"ASCII\"))"] + ] + }, + { + "function": "libphash._sources._from_array_interface", + "reason": "typing.cast() returns its second argument unchanged; the first is read only by type checkers.", + "mutants": [ + ["-address = cast(\"tuple[object, ...]\", data)[0] if isinstance(data, tuple) else None", "+address = cast(None, data)[0] if isinstance(data, tuple) else None"], + ["-address = cast(\"tuple[object, ...]\", data)[0] if isinstance(data, tuple) else None", "+address = cast(\"XXtuple[object, ...]XX\", data)[0] if isinstance(data, tuple) else None"], + ["-address = cast(\"tuple[object, ...]\", data)[0] if isinstance(data, tuple) else None", "+address = cast(\"TUPLE[OBJECT, ...]\", data)[0] if isinstance(data, tuple) else None"], + ["-packed = memoryview(cast(\"Buffer\", obj)).tobytes()", "+packed = memoryview(cast(None, obj)).tobytes()"], + ["-packed = memoryview(cast(\"Buffer\", obj)).tobytes()", "+packed = memoryview(cast(\"XXBufferXX\", obj)).tobytes()"], + ["-packed = memoryview(cast(\"Buffer\", obj)).tobytes()", "+packed = memoryview(cast(\"buffer\", obj)).tobytes()"], + ["-packed = memoryview(cast(\"Buffer\", obj)).tobytes()", "+packed = memoryview(cast(\"BUFFER\", obj)).tobytes()"] + ] + }, + { + "function": "libphash._sources.pixels_from_array", + "reason": "typing.cast() returns its second argument unchanged; the first is read only by type checkers.", + "mutants": [ + ["-view = memoryview(cast(\"Buffer\", obj))", "+view = memoryview(cast(None, obj))"], + ["-view = memoryview(cast(\"Buffer\", obj))", "+view = memoryview(cast(\"XXBufferXX\", obj))"], + ["-view = memoryview(cast(\"Buffer\", obj))", "+view = memoryview(cast(\"buffer\", obj))"], + ["-view = memoryview(cast(\"Buffer\", obj))", "+view = memoryview(cast(\"BUFFER\", obj))"] + ] + } + ] +} diff --git a/tools/mutants.py b/tools/mutants.py new file mode 100644 index 0000000..058877f --- /dev/null +++ b/tools/mutants.py @@ -0,0 +1,140 @@ +"""Check mutmut's results against the list of equivalent mutants. + +Run after ``mutmut run``, from the project root:: + + python tools/mutants.py + +Every mutant must be caught (killed, timed out or crashed) or be listed in +``tests/equivalent_mutants.json`` with the reason no test can tell it from the original. +The script fails on a mutant that is neither, and on a listed mutant that no longer +survives, so that the list stays true. A mutant is named by its function and the lines +it changes, not by mutmut's number, which shifts when the function changes. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import TypedDict + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +EQUIVALENTS = PROJECT_ROOT / "tests" / "equivalent_mutants.json" +CAUGHT = ("killed", "timeout", "segfault") + + +class _Entry(TypedDict): + function: str + reason: str + mutants: list[list[str]] + + +@dataclass(frozen=True) +class Mutant: + """One of mutmut's mutants: its id, its status and what it changes.""" + + name: str + status: str + + @property + def function(self) -> str: + """The mutated function as ``module.Class.method`` or ``module.function``.""" + mangled = re.sub(r"__mutmut_\d+$", "", self.name) + module, _, local = mangled.rpartition(".") + if local.startswith("xǁ"): + _, cls, method = local.split("ǁ") + return f"{module}.{cls}.{method}" + return f"{module}.{local.removeprefix('x_')}" + + def diff(self) -> tuple[str, ...]: + """Return the removed and added lines, each stripped after its sign.""" + shown = _mutmut("show", self.name) + return tuple( + line[0] + line[1:].strip() + for line in shown.splitlines() + if line[:1] in "-+" and not line.startswith(("---", "+++")) + ) + + +def _mutmut(*args: str) -> str: + return subprocess.run( # noqa: S603 -- mutmut from the environment, fixed arguments + [sys.executable, "-m", "mutmut", *args], + check=True, + capture_output=True, + text=True, + cwd=PROJECT_ROOT, + ).stdout + + +def results() -> list[Mutant]: + """Every mutant of the last ``mutmut run`` with its status.""" + mutants: list[Mutant] = [] + for line in _mutmut("results", "--all", "true").splitlines(): + name, sep, status = line.strip().rpartition(": ") + if sep: + mutants.append(Mutant(name, status)) + return mutants + + +def equivalents() -> set[tuple[str, tuple[str, ...]]]: + """Return the listed equivalent mutants as (function, diff) pairs.""" + with EQUIVALENTS.open(encoding="utf-8") as f: + listed: list[_Entry] = json.load(f)["equivalent"] + return { + (entry["function"], tuple(diff)) + for entry in listed + for diff in entry["mutants"] + } + + +def main() -> int: + """Report the mutants; return 1 if any is unexplained or the list is stale.""" + mutants = results() + if not mutants: + sys.stderr.write("no mutmut results: run `mutmut run` first\n") + return 1 + listed = equivalents() + unexplained: list[tuple[Mutant, tuple[str, ...]]] = [] + equivalent: set[tuple[str, tuple[str, ...]]] = set() + for mutant in mutants: + if mutant.status in CAUGHT: + continue + key = (mutant.function, mutant.diff()) + if mutant.status == "survived" and key in listed: + equivalent.add(key) + else: + unexplained.append((mutant, key[1])) + stale = sorted(listed - equivalent) + + counts = Counter(m.status for m in mutants) + caught = sum(counts[s] for s in CAUGHT) + lines = [ + "## Mutation testing", + "", + f"{len(mutants)} mutants: {caught} caught " + f"({100 * caught / len(mutants):.1f} %), {len(equivalent)} equivalent, " + f"{len(unexplained)} unexplained.", + "", + ", ".join(f"{status}: {n}" for status, n in sorted(counts.items())), + ] + for mutant, diff in unexplained: + lines += ["", f"### {mutant.name} ({mutant.status})", "", "```diff"] + lines += [*diff, "```"] + for function, diff in stale: + lines += ["", f"### listed but not surviving: {function}", "", "```diff"] + lines += [*diff, "```"] + report = "\n".join(lines) + sys.stdout.write(report + "\n") + if summary := os.environ.get("GITHUB_STEP_SUMMARY"): + with open(summary, "a", encoding="utf-8") as f: + f.write(report + "\n") + return 1 if unexplained or stale else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index 82a2fab..2ab8fe9 100644 --- a/uv.lock +++ b/uv.lock @@ -9,8 +9,10 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -203,6 +205,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -457,6 +468,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "libcst" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/c0/098e5c91ff1537f00c85a6438b6cb1863d17144680cc91f47c87f104a200/libcst-1.9.0.tar.gz", hash = "sha256:087b58a9afe076bb08e2d726478e1f16cb928d67ffa9092817e033c335de522a", size = 914739, upload-time = "2026-07-29T21:28:43.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/b3/23144dcd9f9cd6f6150058653ecd0357c6b09361656ebd286165a5d76d34/libcst-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5705f664266742b8d9323db714f58cf06a6cde739eb09fd9ca31cd233f9806e5", size = 2055037, upload-time = "2026-07-29T19:24:27.708Z" }, + { url = "https://files.pythonhosted.org/packages/5d/87/164a9bac39a817b505d27cf4490e6f66b2a1b8adb6b4e022b1ef4613bcfe/libcst-1.9.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7dcddfb4905c5ef90a6fdc58dccbc049faf0056b68161042fc1e0526b20cba05", size = 2210166, upload-time = "2026-07-29T19:24:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/b5aeaefbe471c4225c145c15c3bac96d67b0641a5e3ec9b871c62858c88c/libcst-1.9.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:cc84aee3c293a012db722aa2d6ea87be524646852684a5a4e54c1fb98ea9072f", size = 2260568, upload-time = "2026-07-29T19:24:31.021Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0f/351dcd23629732e35e716e13ad536a193124cda075ac5e59dc866503447b/libcst-1.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:329ffda6b0ef708ff22876f982ec886a509c8f73ac1ccb0190693d35cb09aa0f", size = 2276024, upload-time = "2026-07-29T19:24:32.393Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/0da2b60137a6290ba7dda902b32da64d46c9a5107aa002da578f8b1ff8c8/libcst-1.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5302fc698ef863e1a2fb540e95793b3d5a96abc89aa6423ee9c389d7d7ec5eab", size = 2384413, upload-time = "2026-07-29T19:24:33.983Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/b45e6950878d9143f95db609432031ac688b311aa14450a8df239c648c45/libcst-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cdbafbc992b38ea005ef37e20243c4177879020c82d414789fbb7fa22d6d3a9", size = 2054911, upload-time = "2026-07-29T19:24:38.542Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d8/1c1ae22e05fbc763dc5a4e4c3cab356166913ef19d2c2da42e6f1be5c98c/libcst-1.9.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3f735e3acb3afc75d50803fde1598b467bf05d12d6854025df4398aebd75a14c", size = 2209147, upload-time = "2026-07-29T19:24:40.039Z" }, + { url = "https://files.pythonhosted.org/packages/84/e1/0d5eaeac4dadd34fcd9ea154d0a6e5ce10c9fec0e02c73f807a16475b8b0/libcst-1.9.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:951f13c193e590fc88c9582a5341a130fe53ecece51043cff4bd54e25a5e022d", size = 2260355, upload-time = "2026-07-29T19:24:41.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/45ce33c6bca5c1961a90f2999138741daa0dc8cf6fe8b122c0ca2502632d/libcst-1.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc6aa3e7f21315dbe9868c0d13ffaac309514e8c816320c5f91230fa5b21df92", size = 2275659, upload-time = "2026-07-29T19:24:42.828Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b1/936d2f17d93fb160579ff56ca671d26154209bbc07ea8645eb11db1f9633/libcst-1.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f101e0bf8638eba80694ecc3c3abb5309eb488f5271427536cb379c5c1e0ba9e", size = 2383467, upload-time = "2026-07-29T19:24:44.397Z" }, + { url = "https://files.pythonhosted.org/packages/b0/bb/d22c37c33dfe18084634f5ef89f8f0749ffe7b6e0ad312722aafd86bbbdb/libcst-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd1a3500c41784075c4946a995d5ad89f68fa0d226b63ff3c4d78f6ea6dd23e5", size = 2043159, upload-time = "2026-07-29T19:24:49.013Z" }, + { url = "https://files.pythonhosted.org/packages/10/b8/2dedef84d72e7271119217503b69ed6dc5d0b2077685e163caae669d9c70/libcst-1.9.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:611cebd3bbc2014576f4dcc7b845b3c594c96ddc287a3db9b78f22eff156a7d3", size = 2203245, upload-time = "2026-07-29T19:24:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/e02ac2dad647423f947bb11f8322bfdba8ccfdd380e6c7b695add2d1acd4/libcst-1.9.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8d731abe1307720ea1a52d447555e8443a6d130e0e520243c0634a58f6edbc9d", size = 2255388, upload-time = "2026-07-29T19:24:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/6089a51518cfd2ff40950eb26bcc36951d7b6d4f4213568aa0290265aff7/libcst-1.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5351d92ca6ac1cc32e097700e1161ad1ceaa4d9b2cca5abadb1e94576b325", size = 2268982, upload-time = "2026-07-29T19:24:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/ed/78/26881ec466fb70cbc129dca26ccb5a52a0061face2c5822e4b61f00f9699/libcst-1.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03165a264653bb77f6a11b412ae09c08bdb0c25864f3b8d42b816ac64b9d4b9e", size = 2378174, upload-time = "2026-07-29T19:24:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b1/befc0544283bb3923a928accf79ed685e5a725524bdb3491826670affc07/libcst-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8b9df30f524317b097dc53065b25dda33d6a4cc3c7c8bf4fc83ca7559c58cc0", size = 2043754, upload-time = "2026-07-29T19:24:59.885Z" }, + { url = "https://files.pythonhosted.org/packages/45/50/fef7c172a8457c95894edf5fb04805024899cdfea41fa01b0636587b79e1/libcst-1.9.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e465a7bc9c2b9533eb9e06d2391f8819f811b5112919d4064c9fb8565aaafa08", size = 2203548, upload-time = "2026-07-29T19:25:01.323Z" }, + { url = "https://files.pythonhosted.org/packages/18/ff/764cd2be1fd99d774fc44039c319dc0ed1d9d9afeaa02759a05121cccd4b/libcst-1.9.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8504b422c95676a8c27b517e1ac01413ece91bf356865c587ca9bdcd5708a2f7", size = 2255274, upload-time = "2026-07-29T19:25:02.764Z" }, + { url = "https://files.pythonhosted.org/packages/34/a7/474748a27a02fa83e3556b260d5f5236ca48164fa7beffecf3b2cad24dca/libcst-1.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bcb9f9d4fcfe2ec7a40d2c26e03a538d5d9dc189c38551eb3f94ab661afee7c0", size = 2269126, upload-time = "2026-07-29T19:25:04.158Z" }, + { url = "https://files.pythonhosted.org/packages/8a/b7/655e45363b8cf87b91e41e060b21c89e5316c7ef36eb14a1a498b27bb71e/libcst-1.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8d671c39a431c309476099b8ec811e412503ec0f4465f6fc907cb51c70e8e6b", size = 2378602, upload-time = "2026-07-29T19:25:06.424Z" }, + { url = "https://files.pythonhosted.org/packages/65/2a/4943c71d90975bc59034a057dea346b365c276f308c1d31f5cf2bd85492d/libcst-1.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eccf4c57d273cdd3fe1c67b72cf9bb1bbd4547aa011824e96ccf5b7136057aa4", size = 2044529, upload-time = "2026-07-29T19:25:11.04Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1168b98c2a0f338be3a44753647baab051feaf6167cc887bf4447f8fd920/libcst-1.9.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32395244edfe6538e0ea2bf82051d60103d3f54861274805c4fb3745efb70a85", size = 2203519, upload-time = "2026-07-29T19:25:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/2eaa697a80f899bcf2245680bbfcc1e478eb3b3328c21d4b2880bdf00dac/libcst-1.9.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:444e84c76cd035cd2fe136838c1a524d34b08216521f6f5093df8a6f6cfa5799", size = 2255879, upload-time = "2026-07-29T19:25:14.137Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/793b9fd96dd52d202f5f2b88d7e54f05ebed767d1bb3b32395a1817bf6ab/libcst-1.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb5d0946f2b4c6711b5d69fe4f833b364f9e7a1a2b08f88b98619dc18975099a", size = 2269807, upload-time = "2026-07-29T19:25:15.647Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f4/1bc7aaea03971c45e8a885fed9fc1c73176dbbd00b0296a3cde9529bafd6/libcst-1.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45808c03528b3ad40b14095348a918e08d98c47b4a125d631cb78e817c0a5b16", size = 2378171, upload-time = "2026-07-29T19:25:17.289Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7f/c3f3a0e7a1a2adaa76e815e82a7814d6bfe7d49432276bba652248c68d0b/libcst-1.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f6248cb07444ab9a6733a855737a9febed8b9adca51347019348b09a3ac7dfe9", size = 2036102, upload-time = "2026-07-29T19:25:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/4e/af/2f5543255b2c7d749b966adeccc6bfb1652cf8b425afb913d0f3f025a865/libcst-1.9.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:496c24e0d3240bc7da45dae543aff3f5b6509978c39262d6b84ed2fb999dded2", size = 2194806, upload-time = "2026-07-29T19:25:24.017Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/03f4cddce426d005e208b39ea7b2d456e667cdee0f1891360f0fc8430f20/libcst-1.9.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ea490fa8540503db5f321f0268becab46eb50f710e8cec8041e241fd66f6874f", size = 2246762, upload-time = "2026-07-29T19:25:25.365Z" }, + { url = "https://files.pythonhosted.org/packages/d8/31/9d5fe1e43dc3dbcc74f70f3e0e73fcdd8d84effc1059d8b45974f0d0d2eb/libcst-1.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50ab94bb2524b419056d4003032b8c67102ac800f8d85b3c4260a01746d94dbb", size = 2259633, upload-time = "2026-07-29T19:25:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2b/6752b28d88c3a19b3bc0b9e1838443b6760d5c862b4f4b37955402659e24/libcst-1.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a2faaf92500d0226358125630f5aab4758e8aad3f2d70a10892ec3c700781a54", size = 2368043, upload-time = "2026-07-29T19:25:28.344Z" }, + { url = "https://files.pythonhosted.org/packages/39/e4/ad790b043a38b10cea13166c8dd716654ad8b227c20f12eebf6326191cd9/libcst-1.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a5068bf6114f6f4d79af7a6c80a28d1deb50441150ea1db13b5458ab34bec159", size = 2043987, upload-time = "2026-08-11T05:54:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/15/6b/d3cd8275cc54ffc9817ade91442b3e6e9edd1a4518bd4e6dda13e3152dbe/libcst-1.9.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:7acfd18adcdd32dcf41ce676cf121002afcbe9ad2c72dc0c18d78465beedc228", size = 2204003, upload-time = "2026-08-11T05:54:04.031Z" }, + { url = "https://files.pythonhosted.org/packages/da/62/87eddccb11d5d221d50ac4fff4b6e9b8d48c547028d01f7d0fbc5c8a1d75/libcst-1.9.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:86361e2b426bd1b403e52375703c8b764e226e571adcb30442510710681edbf0", size = 2256053, upload-time = "2026-08-11T05:54:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/a44126aeb9fca8b4e342e0cc803ea00ca8f6cd5712619a7897b3eba13797/libcst-1.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8c14abe844bec8b021111b3985474e49f5af799c020e8e40dedcac87c97a40c5", size = 2270576, upload-time = "2026-08-11T05:54:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/bb/51/3af3dd44b117d66486e0ed61e43a16b7fc8cc5c372e1dd4ca0e70b888d46/libcst-1.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:8930971d2299b7006bb5d10c10038f44efb6da89d5bca2823595e31a9cdb96af", size = 2378573, upload-time = "2026-08-11T05:54:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/d7/00/c3751b12eb81822ea0b6131d374a98bc6c024c4b9ec5f6ea8f53dc23e967/libcst-1.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02ee2dbdb5c218116f16a021350fc267a14be205b50d81c33f5d73857ab6fbf7", size = 2036178, upload-time = "2026-08-11T05:54:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/48ff486eb5adc593f4818569e8896b74b672414ab4722a74b17e4c4cf31e/libcst-1.9.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:3e5b684191a0462b2d40a73261ea7f4da6a49e7a030b7e0fceca46bebb51dfe5", size = 2195496, upload-time = "2026-08-11T05:54:17.625Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/b13a4669864d41a4801fed7b86ede1049dc9a1e1dde250a7ee1f9c3b1285/libcst-1.9.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:2b150c4f298fe54eb0fe73abf71f57db74d070796140a8c206c9615b6861f01e", size = 2245769, upload-time = "2026-08-11T05:54:19.343Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c7/cf2d46744825e84afbd7228fdeeea8d23cf6c4b2074253c27efb7705c653/libcst-1.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:50b913e37187f00a6fb88962009364e1cf1b1284aa1762304f34b52b972f2218", size = 2260576, upload-time = "2026-08-11T05:54:21.444Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/5433d3d62250b2e4325938db101766bcab2a006819684713bcccb6259a88/libcst-1.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:9f92c75283030fd58fb7d6e560168a00381e0e3368ec8a026a3ec8a828a05f93", size = 2368709, upload-time = "2026-08-11T05:54:23.276Z" }, +] + [[package]] name = "librt" version = "0.16.0" @@ -587,6 +650,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mutmut" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "coverage" }, + { name = "libcst" }, + { name = "pytest" }, + { name = "setproctitle" }, + { name = "textual" }, + { name = "toml", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/30/b334a22184026d92a7549c8a76ad1d397ea65a6f8534ef486556ab861c70/mutmut-3.8.0.tar.gz", hash = "sha256:bc670276518f893ab356f1d28826c49b7e606e7cf89c300e3888c0b52fc1d2ed", size = 83631, upload-time = "2026-09-12T16:53:58.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/2a/a9904a45804c1de50c0c3baad1512b78d5b66d2befd841104b8a62bf0f35/mutmut-3.8.0-py3-none-any.whl", hash = "sha256:4c0abcef6b8d1116f2adfaad19df12e1c621e4a402d4367ef15bf8cba497b91d", size = 81469, upload-time = "2026-09-12T16:53:56.906Z" }, +] + [[package]] name = "mypy" version = "2.4.0" @@ -840,8 +968,10 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } wheels = [ @@ -1024,6 +1154,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] +[[package]] +name = "platformdirs" +version = "4.12.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/90/a1/d5f9002a70298c64a789779077d8dd90c10aa1f47fe40c86802df874f2a6/platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250", size = 60010, upload-time = "2026-10-07T23:30:32.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/ba/223e00b885e960edd5d4b4d178c88acce019bd5b83786093681b8c393492/platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7", size = 32596, upload-time = "2026-10-07T23:30:30.825Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/80/a8/66d45abadff219e36e2a824181b8f6a67e7ed4572934d6252c71c29d5731/platformdirs-4.13.0.tar.gz", hash = "sha256:1aa0b0d3f224c1f07c295121e312a5a24a180d6ae5a8425ea1784b3e3863e9c0", size = 61094, upload-time = "2026-10-11T02:05:24.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl", hash = "sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1", size = 32724, upload-time = "2026-10-11T02:05:22.776Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1081,6 +1244,7 @@ dev = [ { name = "basedpyright" }, { name = "coverage" }, { name = "hypothesis" }, + { name = "mutmut", marker = "sys_platform != 'win32'" }, { name = "mypy" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, @@ -1101,6 +1265,7 @@ requires-dist = [ { name = "cffi", specifier = ">=1.15.0" }, { name = "coverage", marker = "extra == 'dev'" }, { name = "hypothesis", marker = "extra == 'dev'" }, + { name = "mutmut", marker = "sys_platform != 'win32' and extra == 'dev'" }, { name = "mypy", marker = "extra == 'dev'" }, { name = "numpy", marker = "extra == 'dev'" }, { name = "pillow", marker = "extra == 'dev'" }, @@ -1114,6 +1279,91 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, +] + +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" version = "0.14.10" @@ -1140,6 +1390,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] +[[package]] +name = "setproctitle" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/b0/6b8a516c5a9e9630bd5293db78314ac012f690305fe93beadea388626efb/setproctitle-1.3.8.tar.gz", hash = "sha256:cafe209d064a6efb88cb45a03e97981ff8832802b2b5d009dde0197a3b7b41c8", size = 27214, upload-time = "2026-10-01T21:09:27.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/4e/60631727c0302cbbc9afbc2072ebf7f346ff30edf59a5e765e1474ab1cb2/setproctitle-1.3.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:78cbc62de67dad0a1dbadb1b2bed1bb4b283d73b460ba5fdce16727d27396dd6", size = 18128, upload-time = "2026-10-01T20:58:13.369Z" }, + { url = "https://files.pythonhosted.org/packages/28/6d/5a362f73748f02b7c05ce8ea25c330a80b358213ddb5c9729cf5d4a09006/setproctitle-1.3.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6579e2a6fd0bf3128c06141a87a49423e145a3ad1631fa9cd43886668ce4ea8c", size = 13133, upload-time = "2026-10-01T20:58:15.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/252257eccb5d0476b63f0fd60c3bd632f6a7f0fb5b61ee531e8582bec8d6/setproctitle-1.3.8-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbe67d7ac416087996e39494325d94ef9f4ac1ed33823f90de83c73925665cd2", size = 31928, upload-time = "2026-10-01T20:58:17.45Z" }, + { url = "https://files.pythonhosted.org/packages/75/d4/565f624a49e2e49b4907f3b5131cdda20027d45312ce2b538a0e3985b549/setproctitle-1.3.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:199c2c674af749003596d3a5ccf35112d28a7cfe95a363eae5fd042c33e0253e", size = 32788, upload-time = "2026-10-01T20:58:19.763Z" }, + { url = "https://files.pythonhosted.org/packages/15/16/9f619b2dea800c3e43a4b706ce08060ba1683e493135249e254d781e4951/setproctitle-1.3.8-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53c92b933a47fdc9bcf5033834ebd640bdba8ddc8437e1ab2310682806c31c0e", size = 34639, upload-time = "2026-10-01T20:58:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fc/96807f69eacd8c675a536c9bd63874a6ed34e5ddd85415074af322e6eb9c/setproctitle-1.3.8-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5e6208cd0ce0562887925850d22bffd85aa6d5709f6890d3cdac8668e5aefb9", size = 31137, upload-time = "2026-10-01T20:58:25.945Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bf/79402fbaccea0e8b2fa52753f31c0c81e58b92f4cf6823d7371d95b7e309/setproctitle-1.3.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b15200dc328020f498b196eb59a3b3e35cb08f17bfe05b2e63e5ed47110d254", size = 32127, upload-time = "2026-10-01T20:58:28.253Z" }, + { url = "https://files.pythonhosted.org/packages/ff/60/9abaaa5ac714c5605dc75555c297d355890f21a1a58c9c60b8120f17b86e/setproctitle-1.3.8-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3b256ac72a3855b434506a85cf5ca6e25441657d5dc56eb73df6e279193cb35b", size = 33378, upload-time = "2026-10-01T20:58:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/2b9e148c0156018266bf6384d94dfaf6367f36a329cc27f383d4bf62c31a/setproctitle-1.3.8-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7c8a2beab229c818ae36ca3f7d769db95dd748c76b857851e0a883d947419ba5", size = 30383, upload-time = "2026-10-01T20:58:32.479Z" }, + { url = "https://files.pythonhosted.org/packages/3e/55/388d6942629b7fe58c704bea325934766e5ef1b0e59acfb2633093d0fb0f/setproctitle-1.3.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5b6372be8ff8afc56e0d50036ff618a29bfef72f52946f316e3a65fe25266800", size = 31558, upload-time = "2026-10-01T20:58:34.659Z" }, + { url = "https://files.pythonhosted.org/packages/4a/78/d065a73bef541ab5f6f094c705c016563a063a4ba9e839030a845f2087d7/setproctitle-1.3.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cc711e639a3e978978ab3bbb01f8f106715f5c23bbd99ced5af2404528bbaa6c", size = 18131, upload-time = "2026-10-01T20:58:40.67Z" }, + { url = "https://files.pythonhosted.org/packages/6a/95/2981f400b09b0675225f45b105f1c99e3dfa603b12e6767fd09fd5e58209/setproctitle-1.3.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:50f9bce172b6f9ead1c8fe813d85b1dde57ab503a445f982026ac3a16c719e1e", size = 13140, upload-time = "2026-10-01T20:58:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/7a19bba7f4a354fc3499bf83614b27452232235b1d44191f46ff396051de/setproctitle-1.3.8-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1a6f6a3b2920f4362e8d924f9e5b084f0b2692a2c140e348abd2e3a07a1def73", size = 32316, upload-time = "2026-10-01T20:58:44.544Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/cc23c7184c59e3511b024aac78181d71a4d68f957cf7a07b5cff13404db0/setproctitle-1.3.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:861380b2244983d12b6fe00e4a49f16f9946932245a252dd16c1f5ef7d1be632", size = 33226, upload-time = "2026-10-01T20:58:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/42e67ea6e5e36cedd0dc3df0846e0b4879ccf68fb36eb27f80b31c292ded/setproctitle-1.3.8-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e03a3bdd844e20a611518f7532e951c63d25bb27ccae7d7eb02100542540c14f", size = 35035, upload-time = "2026-10-01T20:58:49.092Z" }, + { url = "https://files.pythonhosted.org/packages/56/48/a156953dfab59df378ac92acca0e6873edf653d94e1a6a08d4a2bcf8add7/setproctitle-1.3.8-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ecb5101325e0254694e55d46ff936926296626bf81c902b60328ead152170bfd", size = 31481, upload-time = "2026-10-01T20:58:51.19Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6f/bed22b21f6a4543bab99d10d5374850470ec3080616a29fa36e07ccf42b2/setproctitle-1.3.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7bd9f7a369a2914102c375d98998c97d669306372732c850dd8319d5a92f515", size = 32468, upload-time = "2026-10-01T20:58:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/17/7c/0a64d381b652dcbe07538434e8340d1a07dbdc9b6646f87e398fc0e39f3d/setproctitle-1.3.8-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3cb92b3239e8094c2454a2160e45417490d4fce69cd09ac846a0c3e43360fb4", size = 33772, upload-time = "2026-10-01T20:58:55.849Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/4c79334f634538e0197cc2f1817ec35a10f8993fbf93566353b0d7e750a3/setproctitle-1.3.8-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:eadfb2ec0948a791846f7cc411c18ff5bd8359e8102cf78d39b8a28bd98b7c6a", size = 30770, upload-time = "2026-10-01T20:58:57.851Z" }, + { url = "https://files.pythonhosted.org/packages/46/2a/c7626d22877c2a413e9b8114c68f00f42a228efc517652a6d37ca29f4768/setproctitle-1.3.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23a193a32a9cca10c4062e2efa3377e804d0209903c734940cc6626d6dfd018a", size = 31894, upload-time = "2026-10-01T20:59:00.076Z" }, + { url = "https://files.pythonhosted.org/packages/b1/36/e7a4b93d7457ed12a1a2b49b042c25848a8847b15f7808159f2ccce91fdd/setproctitle-1.3.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f9a49d8103df362272c625641a4f7b104ae584db21d0f1eec3b7a5d0d7750ed8", size = 18121, upload-time = "2026-10-01T20:59:05.288Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6a10d95bba71fd5f441dda01ae374ebcaeaa637a09f72b95e8cc59443958/setproctitle-1.3.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:823d7d8790a4f8484c7fa65468fcd9d009f13ffe5f0bc89ec0e0d2c165ac0335", size = 13126, upload-time = "2026-10-01T20:59:06.865Z" }, + { url = "https://files.pythonhosted.org/packages/cb/21/4df9fbc0c72b9bca39b5d53eff6813dc1738a1562983e0c614054e9dbb8d/setproctitle-1.3.8-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4a765e0c80a2453480be27c4fc22ba1c0771867040c5f91e4221940d78a4abfb", size = 32929, upload-time = "2026-10-01T20:59:09.199Z" }, + { url = "https://files.pythonhosted.org/packages/5f/07/1476dafdbf5d124a05ae4856272908c0796fa65844376a32bb066f04afda/setproctitle-1.3.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61345393548bc72844d837d62fcf63444b0f5dac418544beb6d4f628a1c5df3", size = 33765, upload-time = "2026-10-01T20:59:15.018Z" }, + { url = "https://files.pythonhosted.org/packages/81/c6/db7b3b859ba0fad54d6a352c3f99a29691f11be133649f61a30a3798f2b0/setproctitle-1.3.8-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba59516fcab4b621b80966a32f1195770696c113a948d00845f35b762e335db7", size = 35614, upload-time = "2026-10-01T20:59:17.439Z" }, + { url = "https://files.pythonhosted.org/packages/c2/76/1acfd4b72d5e1cb56ce44b028325d12fedd3410bfac02e15d3de03fa049a/setproctitle-1.3.8-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f523d34ad72b811be61e5027a046ad4670b54fc3c6384056f06b59f77d671af0", size = 32215, upload-time = "2026-10-01T20:59:19.993Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/ce856b9aea7bcf1be539b1811e9bd937ca3cd1edf99516a66b71132cfcc3/setproctitle-1.3.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c667cc9c4cd949814b4ba8be70632a6454637647f3feea2724e02d3d3e35c66d", size = 33169, upload-time = "2026-10-01T20:59:22.201Z" }, + { url = "https://files.pythonhosted.org/packages/88/40/d96d641620e0cf4d8ec09571e5be293aa45f66cde574fc9c069b0ecbeb9f/setproctitle-1.3.8-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d2f756ede847e05e7759af4f2d87dedf3ed34004092216be85acd8301b1d385", size = 34429, upload-time = "2026-10-01T20:59:24.228Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3c/ef0eef79863fad43ba6aca4fd96ab3b16358e8e37fd6d794bc8f7b88bf6a/setproctitle-1.3.8-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:191888bdf7c223c8b00eaa935947de0904165dee5e84d2055aecbb473d9c3f51", size = 31401, upload-time = "2026-10-01T20:59:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/bb/97/7c667a2eae40a693b26186b34384ce9035f4563b7053e2e254f7738da75a/setproctitle-1.3.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c7b9b6ba3d0f6e504be5978854966168fb95b17ce126eab9a6132fe15f3e540", size = 32724, upload-time = "2026-10-01T20:59:28.481Z" }, + { url = "https://files.pythonhosted.org/packages/58/46/125791eb19c85340c6d43de8c821b143aecbfa0882343f244f5744474229/setproctitle-1.3.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a4a782f9ab26e662a890c1a7d865c452c83ed554de44aa83da35fa5bdcc79921", size = 18100, upload-time = "2026-10-01T20:59:33.41Z" }, + { url = "https://files.pythonhosted.org/packages/47/51/73b1e62c3e019e683e58d31f9f8af57e6750f36ce079004d29b7790e22ce/setproctitle-1.3.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63adfa4be8f9dbac4be6b359e2a8eb15a733545ef613d02a12ae27a4d40e975", size = 13116, upload-time = "2026-10-01T20:59:35.159Z" }, + { url = "https://files.pythonhosted.org/packages/78/ae/095c6ad159e3a7b95bc6d9a3eae18abfea1ebc79ed3377617406a1668150/setproctitle-1.3.8-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d4289e209e805d7d276a534f062092974fbe6814889d4c132c247205ebb49a65", size = 33275, upload-time = "2026-10-01T20:59:37.235Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/5196258efd941cb874b3f4da91460601b867fdc434eba48a70455736d4be/setproctitle-1.3.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bb543b4b58ad839f7f0d335a7f4b40cef6989952e7239de3532de0b7771607e", size = 34124, upload-time = "2026-10-01T20:59:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/e9754e0916e6dda232a8c337bda69d2fea33acee0c5d3bdf580b8155a50d/setproctitle-1.3.8-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b705f01a531dba8fbbab90e30d432af4cc70a79c0a33b238e2c061816140cad0", size = 36006, upload-time = "2026-10-01T20:59:41.501Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a9/c63d2d745f18423937429a8611743e7b3e17259173150fbcdaa59daa4a51/setproctitle-1.3.8-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e286e4c4e90063072a6e088a8d6526a7ffceb47429db88ba50ba58eeebcc758", size = 32268, upload-time = "2026-10-01T20:59:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/a72818902f32ea9b848db5ccc828eb9cd9bc8661975610e1d46ea4aacbe2/setproctitle-1.3.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7b77c8671974288cf192e481ed63e928f1086762e97e8845d09d65ba121f8aaa", size = 33179, upload-time = "2026-10-01T20:59:45.809Z" }, + { url = "https://files.pythonhosted.org/packages/bb/55/fa59463ae6b957048e0e842d4b5513eced1b71554d8af0f5d91cd23cde23/setproctitle-1.3.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0e85b55eac9abfa3166ab00dac21e718ac2624a1df7d0b75559a2d3e3781cb33", size = 34472, upload-time = "2026-10-01T20:59:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/b7/77/1f0cf55055788bf7eb9a146040a64d7ec42cd56153894856effc70e943ec/setproctitle-1.3.8-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f215ec9237d5f8e96d0205be6e2141292c20b133343c2153b6d4934f523ef422", size = 31428, upload-time = "2026-10-01T20:59:49.753Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/d1d8a6cd620d72a04f5d3335125e5b2fc5667bd06a76bb5bbc50b101e804/setproctitle-1.3.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0e70a5d294e4c8cf7dc94e015fca6d4913779a424937e66b429fba82d8eb5e2", size = 32776, upload-time = "2026-10-01T20:59:51.763Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1b/15043f8886aecfb31759c4a7c2cda9f85971242a10723049fc0fea34629f/setproctitle-1.3.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f5f1d7e0d57cd75febd70c6fc26c2ea06024ab2006011abb3094b9fea54801ee", size = 18104, upload-time = "2026-10-01T20:59:57.014Z" }, + { url = "https://files.pythonhosted.org/packages/0d/be/c2a6880394bef30aafe274d00ca0c7dac34fff1fb867f6c51abf876a640f/setproctitle-1.3.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b8954fc72fdd49588c8b8d615eabc27dfd8eb6d0cffe987fc5df02934816fb19", size = 13124, upload-time = "2026-10-01T20:59:58.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/96/6b3818e7f63a30f13bbe97b1ea0a51509a20b5b2d48c95e526c3054343d5/setproctitle-1.3.8-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d6a0245679b74e0fda974edb672cd1f2351b0d762279b6776ca3a065c9322a4", size = 33180, upload-time = "2026-10-01T21:04:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/4f/43/fd3220b4bb2169d8723b3561ae7f8e1ee6f2e13e2da38b3a7a150004d2aa/setproctitle-1.3.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:394aca04e9f11c469f4f29d25a40a79eb4b8c9a72f438ba6390b776590f89757", size = 34071, upload-time = "2026-10-01T21:07:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/58/ee9eeebeb74125723b60eada6e6b2def747815228193327ce470da088f2b/setproctitle-1.3.8-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f92f6ded50c0ae7b0fed612be77c5345ed4a4578f99b98c7f8258d0e599eff16", size = 35923, upload-time = "2026-10-01T21:07:19.054Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fe/b339602e96d73b29fd123c8b9c886e8f9a5afa12a18218eece56bd99c668/setproctitle-1.3.8-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c09f15930910dd895ac113b16c0b3ac2051dcbd44b0f017f94cb51349ba11583", size = 32116, upload-time = "2026-10-01T21:07:25.121Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5e/8d46d34648d8dfdc3f3e941c149ae316b6c6f417391018e279a1bb733c42/setproctitle-1.3.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4756a96435917494ec2f27f0da46bc05eecaa1a7e072d57220ecb2d46ea8a3d3", size = 32980, upload-time = "2026-10-01T21:07:27.826Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6a/10720d27d5f4a47c9af51652b49dd22313be20b9d542e72f80b4b363702b/setproctitle-1.3.8-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:65279b2df2951b64878da5382363e730d242486179c440c8653fe39aa3cc9e62", size = 34307, upload-time = "2026-10-01T21:07:30.62Z" }, + { url = "https://files.pythonhosted.org/packages/6e/65/95dd7b0245fdb7d2f6633613c559ecd9f0b2afeb6e3d962c218b6a1722d4/setproctitle-1.3.8-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d2ced815b940c88097e07182c57cbfe1a0156839d1edc9be2bc0e4c74ab4eed", size = 31126, upload-time = "2026-10-01T21:07:33.59Z" }, + { url = "https://files.pythonhosted.org/packages/fd/96/4d62c4ba70cf8fa305f1dcbe35e93449819bb27b451fcb473556acf92650/setproctitle-1.3.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:37a91697c3b96bac58b6936b3bf0366af96a2a6cef963fdd9aaf0593a4bd1ff2", size = 32468, upload-time = "2026-10-01T21:07:36.312Z" }, + { url = "https://files.pythonhosted.org/packages/38/d8/019116e7defcd5d2c9264da4a82d369de6806a9161adf73e5f73e63fb715/setproctitle-1.3.8-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:84320e61f3906cc550b900ab1ba02ec53edd2d2e1e917f0873cba16592801c89", size = 18105, upload-time = "2026-10-01T21:07:38.655Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/bfa9d6b5574903c17e02caba0e25815bb1c7e7b895d594475676baaa35b4/setproctitle-1.3.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8086259943fc75eb07e11c1d08600709b9ce4fb2ec9bbd2490556c47d3ead135", size = 13125, upload-time = "2026-10-01T21:07:40.774Z" }, + { url = "https://files.pythonhosted.org/packages/d3/29/c7febeedee66b929c335d7b8ce83b1c4baccc65c94a4112fd8c1ea32b57d/setproctitle-1.3.8-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8879d303d4e3559efdba033ff198c6869010f7958412ec2047674881859fdd7f", size = 34818, upload-time = "2026-10-01T21:07:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/36/b9/90fea18bcb74f19d411df7e9e2d981c4d3e99817870acb0b5cac6540629f/setproctitle-1.3.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7de4da27fa4481e8a7a4ef58c908ffd2b996847c3d7fbf27b5c53ce0984c408", size = 35622, upload-time = "2026-10-01T21:07:51.03Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/5899eab6e0b4a127e98a268a157b6bdba4f8ad18d0ed90fdf3e081c20a3b/setproctitle-1.3.8-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:741b1ae7e1cd68745b8b677daba73685dab2d1b8bb12500e6537f3482a91150f", size = 37762, upload-time = "2026-10-01T21:07:53.758Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cd/190748a1f68afe6154b62517d2288b7d4c2092b0efa6e61d044c0f99d17b/setproctitle-1.3.8-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c85ff5b000eef8a280333ca158483a76f86bd374816add5c016a9f4294471289", size = 33548, upload-time = "2026-10-01T21:07:56.148Z" }, + { url = "https://files.pythonhosted.org/packages/26/db/889062b0970f04bc76d8eed03165dc17de8df282399ad931cb10a23b33a3/setproctitle-1.3.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:656ffe282ec0ca18308eebb100898c79b4f5698a93228d50d2f2a4a09474d7e0", size = 34298, upload-time = "2026-10-01T21:07:58.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/bc/adedb2c18eb7bb152b6bc59187f46fc328e7bb729bf28a7e4e43521c4c7a/setproctitle-1.3.8-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ae87e7b3e6414b3dd887adb72d2c4ea9f210c5f48efafd295ade4a58c760d29d", size = 35897, upload-time = "2026-10-01T21:08:01.561Z" }, + { url = "https://files.pythonhosted.org/packages/38/8e/f1769dcd1eb668b01ad251eb6780b030752d18dabc1b82789b255189772e/setproctitle-1.3.8-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:09cb645c6ae440286391e256203fc03522b3b0f55fda8219f698abb5c447cce7", size = 32435, upload-time = "2026-10-01T21:08:04.112Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/68be1eed21bba3ce27c6907ddcf0559cb99e4de3aa3ee000a38994371b73/setproctitle-1.3.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:84003eeaf2d7460390bbff1dbd7bee5b1213e7ae47a489f0fb7aad6acd2b045c", size = 34024, upload-time = "2026-10-01T21:08:06.514Z" }, + { url = "https://files.pythonhosted.org/packages/92/e4/e9866831fd0cff677c67488059fab3786a843ff0bd86f4b3c44bea8dd763/setproctitle-1.3.8-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:63348d86ae60b071a898e4241b41da1346d8c36f1a60bf3b698ae5eb35b79865", size = 18107, upload-time = "2026-10-01T21:08:16.029Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/5c11a8c8cf3b91e54fdf4c93d1c79d99a491e04d77d34c80a90552fe6b46/setproctitle-1.3.8-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:59239b6e5007b1bec79ef444a390b3bcd59cbcb2686c9910da5a42f1e99f738c", size = 13132, upload-time = "2026-10-01T21:08:17.756Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/47dea0a33ec67a2813e40bd92deea74c3732fd598bd71b636a92897eaf17/setproctitle-1.3.8-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:38310d9cf94959e39d22fdc0154c66c8bdbadfa790c524b04cb36da6333c7507", size = 33509, upload-time = "2026-10-01T21:08:20.254Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7c/5d6945012077e6c393d59432df42d281d213f508bcf6d83a1287ab2eb9ae/setproctitle-1.3.8-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a279b136ed97407209d2dc9c30d1e65405d1a4a06ab162cedd9c4159b51f8f20", size = 34405, upload-time = "2026-10-01T21:08:22.796Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/543a4470bfd01529dc35f0014dc68c70665db8b790e3647b41d155203b87/setproctitle-1.3.8-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26997a85aa70bb088e86f0b7899fb0c909be824d22fc025e87b0005afbe59261", size = 36167, upload-time = "2026-10-01T21:08:25.35Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/fc55041b162833c4bbbbfe78ed417bcab7b22f9fa72aff1722ff6af46fae/setproctitle-1.3.8-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35fc5e10fcba6df25d676ef908b44a36c17e7f167738ed016561185e24f4d274", size = 32623, upload-time = "2026-10-01T21:08:27.778Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e2/8c73c990c338b9ca5bf4fc6167ac864a80a53a10e71fd2755ba146f1fce6/setproctitle-1.3.8-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f5df0503093ec8c3c7e1da53920e79c62ab653b51b136c4736caa252552a8732", size = 33510, upload-time = "2026-10-01T21:08:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/cd/61/8e10270d3a7326dc4e8a1408c631e6ae912625b91f38adaf04794a19e07b/setproctitle-1.3.8-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a582308ba43afa7233d28243d9d1212e11c81d3c187a43929ec0cb70c4781c5", size = 34528, upload-time = "2026-10-01T21:08:37.161Z" }, + { url = "https://files.pythonhosted.org/packages/77/d4/00773ca8c876123348e65edfd9eec47464bbfa1a31067c6e1cd382356a0f/setproctitle-1.3.8-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ce48dbf74e8ec618d74cbaffe1159a4c32a4428de598f0ccdb0782bae618a72a", size = 31681, upload-time = "2026-10-01T21:08:39.874Z" }, + { url = "https://files.pythonhosted.org/packages/d5/42/19c6b634cca492575f9d99868bc35134528fb390d683d6a0396f61df0b8d/setproctitle-1.3.8-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dd3a8fcee7d8a23e44781144830dee97b53ca4e0380bdd2a0e05b13913ce5725", size = 32999, upload-time = "2026-10-01T21:08:42.36Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/da93e1ae012f2515473a45fb33f5ba2ddbbfcce1381c5e41005b31ab3230/setproctitle-1.3.8-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e9f643bd97374aeb7e4274e5c361277bf1428e8e55246cee6823fa3525dc2c04", size = 18119, upload-time = "2026-10-01T21:08:44.296Z" }, + { url = "https://files.pythonhosted.org/packages/af/35/41b28fa8eb8f1bee9c5516b99262a5df413937bf186e9d50ec3ef812fc61/setproctitle-1.3.8-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:fbd9ceb8c091c125e5892757e4d16af06674dc96edd7b5bf1e627b1ae7650c66", size = 13140, upload-time = "2026-10-01T21:08:46.797Z" }, + { url = "https://files.pythonhosted.org/packages/0c/19/089c878e6b8a3a391ceb03e1653ad6f33f27a5f7e204cf4a54b6a053b83d/setproctitle-1.3.8-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:322b4643f98c824b17dbf2ddb07ea7a37def8cc9e18adee320befce4bdffa271", size = 35122, upload-time = "2026-10-01T21:08:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/88/a7/f419b87fcb6202ef0a94f7cd6bdecb23c2d01f2f07b38f92d3f0c5dfb518/setproctitle-1.3.8-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36ee7300584a53fd7ac5f3853323411bde1cf6c081921bd3754a73b4071245", size = 35944, upload-time = "2026-10-01T21:08:52.612Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/fb181112f3c09a8c8962bd110f538c8a9d287a0c6a8bfd5b4dc39d54e0df/setproctitle-1.3.8-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c05aff52e6eb9c4d2ea86cf0d0a60b1487ed570263a1b13bb30d9f96aa8ba97", size = 37981, upload-time = "2026-10-01T21:08:55.136Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/934846cf63b5ae2e9a88dcac6ddc3ec813e6b9e0919994684f7674155b8a/setproctitle-1.3.8-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df807e7f036c19235320b6b5137291891d660f10ccb0b50dc46bc8385be18379", size = 34229, upload-time = "2026-10-01T21:08:57.703Z" }, + { url = "https://files.pythonhosted.org/packages/c9/04/c11f73d8aa0ebe5d63866130ad14ca3789a4dfb9fc617c3a612af9c177f9/setproctitle-1.3.8-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bef8c5f8820c70e8761171409052056ffd247023b001a6254287a0694c3d8b1c", size = 34743, upload-time = "2026-10-01T21:09:04.088Z" }, + { url = "https://files.pythonhosted.org/packages/38/50/8c780488db26e8f51af91465bda6ee224522248dc5220b282791dacbbe5e/setproctitle-1.3.8-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7855290194637a3c5a2e37c423efbf5813fde97a6b63ab99feade5e05d73dc6c", size = 36094, upload-time = "2026-10-01T21:09:06.67Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e6/13d541653607b3b7232dc9f8baae05b24112ac9a3756c3653903c624c4a9/setproctitle-1.3.8-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:8c0a7753f5ddaf0ecd1fc8df3e2106611d4290f0508e643e723cb5000b7d6c2f", size = 33148, upload-time = "2026-10-01T21:09:09.064Z" }, + { url = "https://files.pythonhosted.org/packages/42/94/f3e16c3a7949fa28a777aa30830bf89098026e13669144b0ec1b147f17f4/setproctitle-1.3.8-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:97b35b15f81363a314baca76674c19027dce19eed1590abd51c2d285830efd50", size = 34488, upload-time = "2026-10-01T21:09:11.756Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e1/61838733db1ee33c4d550e72e98891824b28f644a2fb9612f107e5bf819d/setproctitle-1.3.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:26da5ad9883d825b9d732fd1dcb99268a4ee545b1edd0aaa5c56f16009cccaf8", size = 12627, upload-time = "2026-10-01T21:09:21.435Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cf/08599efcb67f437acc0b1b6510aa08f44873cbd416b526139c4eb38b2775/setproctitle-1.3.8-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15aaec002066f6dd7d920c46d3bffc3e529cb4a9fac961f2dd878aad64b0e29e", size = 14324, upload-time = "2026-10-01T21:09:23.204Z" }, +] + [[package]] name = "setuptools" version = "82.0.1" @@ -1158,6 +1498,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs", version = "4.12.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "platformdirs", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tomli" version = "2.3.0" From 133cfb1433b3993bd495ed288fc4caaee3063746 Mon Sep 17 00:00:00 2001 From: gudoshnikovn Date: Sun, 11 Oct 2026 14:10:40 +1000 Subject: [PATCH 14/14] test: warnings fail, markers and config are strict, tests time out pytest turns every warning into an error, refuses unknown markers and settings, and stops a test after five minutes (pytest-timeout, now installed wherever the tests run). The development environment runs the tests in a random order with pytest-randomly, which prints its seed. --- .github/workflows/ci.yml | 2 +- pyproject.toml | 11 +++++++++-- uv.lock | 38 +++++++++++++++++++++++++++++++++----- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 697a525..55a3e64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,7 +188,7 @@ jobs: - name: Install the wheel into a fresh environment run: | uv venv "$RUNNER_TEMP/venv" - VIRTUAL_ENV="$RUNNER_TEMP/venv" uv pip install dist/*.whl pytest hypothesis + VIRTUAL_ENV="$RUNNER_TEMP/venv" uv pip install dist/*.whl pytest pytest-timeout hypothesis - name: pytest run: '"$RUNNER_TEMP/venv/bin/python" -m pytest -rs' diff --git a/pyproject.toml b/pyproject.toml index 3a2fca8..6a910f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = ["cffi>=1.15.0"] "Homepage" = "https://github.com/gudoshnikovn/python-libphash" [project.optional-dependencies] -dev = ["pytest", "hypothesis", "coverage", "mypy", "basedpyright", "ruff", "typos", "setuptools", "types-setuptools", "types-cffi", "typing_extensions", "numpy", "Pillow", "mutmut; sys_platform != 'win32'"] +dev = ["pytest", "pytest-randomly", "pytest-timeout", "hypothesis", "coverage", "mypy", "basedpyright", "ruff", "typos", "setuptools", "types-setuptools", "types-cffi", "typing_extensions", "numpy", "Pillow", "mutmut; sys_platform != 'win32'"] [tool.setuptools] zip-safe = false @@ -49,7 +49,7 @@ build-verbosity = 1 skip = ["*-musllinux_*"] # numpy and Pillow are optional at runtime; with them the array and Pillow tests run # against the wheel too. The sdist job of CI runs the suite without them. -test-requires = ["pytest", "hypothesis", "numpy", "Pillow"] +test-requires = ["pytest", "pytest-timeout", "hypothesis", "numpy", "Pillow"] test-command = "pytest {project}/tests -rs" # CMake and Ninja come from build-system.requires. nasm gives libjpeg-turbo its x86 @@ -75,6 +75,13 @@ environment = { PATH = 'C:\\Program Files\\NASM;$PATH' } [tool.pytest.ini_options] # mutmut keeps a copy of the tests in mutants/. testpaths = ["tests"] +addopts = ["--strict-markers", "--strict-config"] +# A warning is a failure: deprecations surface in the run that causes them. +filterwarnings = ["error"] +# A hung test fails on its own instead of holding the job until its timeout. +# pytest-randomly, where installed, runs the tests in a random order and prints the +# seed, so that a test depending on another one's leftovers shows up. +timeout = 300 # Branch coverage of the package, measured against the editable build. File names are # stored relative to the checkout, so the data files of every platform's job combine diff --git a/uv.lock b/uv.lock index 2ab8fe9..390f8f1 100644 --- a/uv.lock +++ b/uv.lock @@ -8,10 +8,10 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", @@ -967,10 +967,10 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } @@ -1176,8 +1176,8 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -1232,6 +1232,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-randomly" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/3b/6a40e1b9d925651e601e056a97f60d8a1daeddeac03d5609be60cb4362ce/pytest_randomly-5.0.0.tar.gz", hash = "sha256:e9c575a5873ef168ddbe340ed9e97ce9edb4492ccc821e4b2ac6bb1f0ed515d2", size = 8542, upload-time = "2026-09-01T22:34:20.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/b4/47e939285caad9a623d021512912ac08dc92a467ad075d179f43729d2934/pytest_randomly-5.0.0-py3-none-any.whl", hash = "sha256:8a0d4703115c0c25b38b6e129fc16b1947b9643ff26a41bc1d185d7e5a7689c1", size = 8920, upload-time = "2026-09-01T22:34:19.227Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-libphash" source = { editable = "." } @@ -1251,6 +1275,8 @@ dev = [ { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pillow" }, { name = "pytest" }, + { name = "pytest-randomly" }, + { name = "pytest-timeout" }, { name = "ruff" }, { name = "setuptools" }, { name = "types-cffi" }, @@ -1270,6 +1296,8 @@ requires-dist = [ { name = "numpy", marker = "extra == 'dev'" }, { name = "pillow", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-randomly", marker = "extra == 'dev'" }, + { name = "pytest-timeout", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "setuptools", marker = "extra == 'dev'" }, { name = "types-cffi", marker = "extra == 'dev'" },