Skip to content

test: coverage and mutation gates; fix Ctrl+C, La, I/O messages - #9

Merged
gudoshnikovn merged 14 commits into
release/v2.0.1.0from
feature/coverage
Oct 11, 2026
Merged

gudoshnikovn merged 14 commits into
release/v2.0.1.0from
feature/coverage

Conversation

@gudoshnikovn

Copy link
Copy Markdown
Owner
  • Line and branch coverage over every platform, held at 100 % by a coverage job that combines the matrix's data (HTML report as an artifact, summary in the job summary).
  • mutation.yml: mutmut over src/libphash/; tools/mutants.py passes when every mutant is caught or listed in tests/equivalent_mutants.json with its reason.
  • pytest: warnings are errors, strict markers and config, per-test timeout, random order in development.
  • Fixes: Ctrl+C raises KeyboardInterrupt from a batch with threads=1; Pillow images in mode La hash; I/O errors read the same when the binding reads the file (Windows) as when libphash does.
  • Branches that never ran are removed.

🤖 Generated with Claude Code

Collection never calls into the package: a broken build, or a mutant of
can_decode(), then fails the tests that ask instead of the whole
session. mutmut verifies its setup by breaking every function and needs
the tests, not their collection, to fail.
With threads=1 libphash runs the callbacks on the calling thread, and
KeyboardInterrupt arrives as a callback is entered, before its own try.
cffi printed "Exception ignored" and returned 0: the batch stopped, and
hash_files() returned the cancelled results instead of raising.

The callbacks now leave escaping exceptions to an onerror handler, which
keeps them per thread for finish() to re-raise. The Ctrl+C test runs
with one thread and with workers, with and without a progress callback.
The unused interrupted and previous_wakeup attributes are removed.
ph_get_last_error_message() never returns NULL: it gives an empty
string when the last load set no message. os.path.isdir() and exists()
answer False for a path they cannot check, so the try around them
caught nothing. on_progress is registered only with a progress
function, so its check for None never ran.
The tests cover what was reachable but untested: algorithms given as
their C value, an empty algorithm list, ImageContext.config, file
objects whose read() returns a memoryview or a str, digest-only
hashes(), comparisons with other types, memoryview pixel layouts, array
interfaces without data, a batch without the wakeup fd or alongside an
event loop's, and a progress callback that is not called after it
stops the batch.

C errors that the binding's own checks leave unreachable are reached
through a stand-in lib that fails one function: the caller gets the
matching exception, and a batch frees its config template.
- Pixels loses its keepalive: ffi.from_buffer() keeps its object alive,
  and an array passed by address is held by the caller for the call.
- A memoryview is taken of every buffer exporter, memoryview included;
  formats 'B' and 'c' are one byte, so the itemsize check is gone.
- Palette and gray-with-alpha Pillow images become RGBA whether or not
  they carry transparency: libphash blends an opaque pixel to its own
  value exactly, so an opaque RGBA image hashes as RGB.
- read_file() raises the ImageIOError itself, for ImageContext and for
  batches alike, and a batch chunk's progress is shifted by _shifted().
Where the binding reads files itself (Windows), a missing or empty file
gave "No such file or directory: <path>" and "empty file: <path>", and a
bytes path printed as b'...'. read_file() now words them as libphash
does, "Cannot open '<path>': <reason>" and "Cannot read '<path>': file
is empty", with the path decoded.

The error and batch tests run with libphash opening the files and with
the binding reading them, and check the message, errno and filename of
each failure. Batch tests check every result's source, the progress
total of hash_buffers(), that threads=0 runs on workers, and that an
exception from progress stops the batch; Ctrl+C tests clear a wakeup fd
the test runner may have set. ImageIOError.__str__ drops its check for
empty args, which a message always fills.
Pillow converts premultiplied gray-with-alpha (La) only to LA, so
img.convert("RGBA") raised ValueError and ph.hash() of such an image
failed. La now goes through LA to RGBA.

The pixel tests compare each converted Pillow mode with Pillow's own
conversion, on an image whose alpha changes the hashes; check that
arrays and memoryviews reach C at their own address with their row
step; cover overlapping rows and reversed channels; and check the full
text of each pixel error.
ph_load_from_pixels() clears the context's message and never sets one, so reading it after a failed pixel load always gave an empty string.
Digest tests take the kind from whichever digest states it, name the
right metric for each wrong kind, hash distinct digests apart, quote the
text a hex parser refuses (non-ASCII included) and give the full text of
the 64-bit range checks. The stand-in lib rejects each digest metric in
turn. ImageContext's pickle and copy errors are checked in full, and a
missing path that is not UTF-8 reads as U+FFFD in the message.
mutmut mutates src/libphash/ and runs the tests against each mutant.
tools/mutants.py passes only when every mutant is caught (killed, timed
out or crashed) or listed in tests/equivalent_mutants.json with the
reason no test can tell it apart: typing.cast() arguments, strict= on
zips of equal length, codec name case, buffers larger than C fills, the
chunk size of the Python file reader. It fails on a listed mutant that
no longer survives. Entries name a mutant by its function and changed
lines, not mutmut's number, which shifts as the code changes.

mutation.yml runs it weekly, by hand, and when the package or the gate
changes. pytest collects only tests/, since mutmut keeps a copy of them
in mutants/.
pytest turns every warning into an error, refuses unknown markers and
settings, and stops a test after five minutes (pytest-timeout, now
installed wherever the tests run). The development environment runs
the tests in a random order with pytest-randomly, which prints its seed.
@gudoshnikovn
gudoshnikovn merged commit 133cfb1 into release/v2.0.1.0 Oct 11, 2026
23 checks passed
@gudoshnikovn
gudoshnikovn deleted the feature/coverage branch October 11, 2026 04:48
@gudoshnikovn gudoshnikovn self-assigned this Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant