diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11efcc4..55a3e64 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. @@ -138,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/.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 45a0f0c..d5597b0 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,9 @@ _native.*.pyd .ruff_cache/ .mypy_cache/ .coverage +.coverage.* htmlcov/ +mutants/ tests/output_*.jpeg # --- macOS --- diff --git a/CLAUDE.md b/CLAUDE.md index bf7a263..3744113 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,8 @@ 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 +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 @@ -60,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, 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 85b1a5d..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", "mypy", "basedpyright", "ruff", "typos", "setuptools", "types-setuptools", "types-cffi", "typing_extensions", "numpy", "Pillow"] +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 @@ -72,6 +72,43 @@ 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"] +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 +# into one report; branches that run only on Windows or only on POSIX count once +# 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"] +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.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/src/libphash/_batch.py b/src/libphash/_batch.py index c07a1d2..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)) @@ -215,9 +216,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 +234,7 @@ def __enter__(self) -> Self: lib.ph_free(template) raise self.options.config = template + _escaped.error = None self._watch_sigint() return self @@ -245,6 +245,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,21 +295,20 @@ 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 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 @@ -319,19 +321,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/src/libphash/_context.py b/src/libphash/_context.py index dc18cc5..8521a7c 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. @@ -260,14 +249,14 @@ 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 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 diff --git a/src/libphash/_sources.py b/src/libphash/_sources.py index d965ff6..867a55e 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,10 @@ 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" + if mode == "La": + # Pillow converts premultiplied gray-with-alpha only to LA. + img = img.convert("LA") + target = "RGBA" else: target = "RGB" img = img.convert(target) @@ -241,9 +243,22 @@ 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 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: + detail = f"Cannot open '{os.fsdecode(path)}': {e.strerror}" + raise make_error(PH_ERR_IO, detail=detail, path=path) from e + if not data: + 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 255459a..2cc055b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +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. @@ -13,13 +19,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}") @@ -43,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/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/tests/test_batch.py b/tests/test_batch.py index 806250a..807ab7e 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -1,7 +1,9 @@ from __future__ import annotations +import errno import os import signal +import socket import sys import threading from typing import TYPE_CHECKING @@ -12,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) @@ -29,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() @@ -42,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) @@ -49,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. + idents: set[int] = set() + + def note(_done: int, _total: int) -> None: + idents.add(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: @@ -80,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))) @@ -87,34 +140,55 @@ 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) +# 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]) +@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] = [] - 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) @@ -140,3 +214,47 @@ 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(), []) + + +@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] = [] + + 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..9ecdeaf --- /dev/null +++ b/tests/test_c_errors.py @@ -0,0 +1,152 @@ +"""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 +from conftest import exactly + +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 rejected(*_args: object) -> int: + return -1 + + +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=f"^{context}: "): + call() + + +@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( + 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_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_context.py b/tests/test_context.py index dbd974c..29b1dbb 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,9 +3,10 @@ import pickle import threading from pathlib import Path +from typing import cast import pytest -from conftest import data +from conftest import data, exactly import libphash as ph @@ -38,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) @@ -164,3 +169,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_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")) diff --git a/tests/test_digest.py b/tests/test_digest.py index b4b822d..bb100e7 100644 --- a/tests/test_digest.py +++ b/tests/test_digest.py @@ -1,8 +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 @@ -127,3 +131,91 @@ 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") + + +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_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_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_paths.py b/tests/test_paths.py index 8a51ed1..dd577af 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: @@ -112,5 +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 [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:]) diff --git a/tests/test_pixels.py b/tests/test_pixels.py index 73aba6a..645132e 100644 --- a/tests/test_pixels.py +++ b/tests/test_pixels.py @@ -1,9 +1,11 @@ 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,38 @@ 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 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") + 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 px.pointer == 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 +153,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)) @@ -83,3 +171,56 @@ 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")) + 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: + 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")) + 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(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])) + 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.""" + + def __init__(self, interface: dict[str, object]) -> None: + self.__array_interface__ = interface + + +def test_array_interface_without_data() -> None: + message = "the array does not expose its data through the buffer protocol" + 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))) 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 1cc9cf7..390f8f1 100644 --- a/uv.lock +++ b/uv.lock @@ -3,14 +3,16 @@ 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.13.*' 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 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.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'", "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" @@ -212,6 +223,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" @@ -329,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" @@ -459,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" @@ -706,14 +962,16 @@ 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.13.*' 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 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.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" } wheels = [ @@ -896,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.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'", +] +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" @@ -941,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 = "." } @@ -951,13 +1266,17 @@ dependencies = [ [package.optional-dependencies] 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.*'" }, { 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" }, @@ -970,11 +1289,15 @@ 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 = "mutmut", marker = "sys_platform != 'win32' and extra == 'dev'" }, { name = "mypy", marker = "extra == 'dev'" }, { 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'" }, @@ -984,6 +1307,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" @@ -1010,6 +1418,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" @@ -1028,6 +1526,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"