Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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'
Expand Down
65 changes: 65 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ _native.*.pyd
.ruff_cache/
.mypy_cache/
.coverage
.coverage.*
htmlcov/
mutants/
tests/output_*.jpeg

# --- macOS ---
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<platform> 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
Expand Down Expand Up @@ -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.

Expand Down
41 changes: 39 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand Down
79 changes: 47 additions & 32 deletions src/libphash/_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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))

Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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()
Loading
Loading