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
59 changes: 58 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ jobs:
- name: Install the wheel into a fresh environment
run: |
uv venv "$RUNNER_TEMP/venv"
VIRTUAL_ENV="$RUNNER_TEMP/venv" uv pip install dist/*.whl pytest pytest-timeout hypothesis
VIRTUAL_ENV="$RUNNER_TEMP/venv" uv pip install dist/*.whl pytest pytest-timeout hypothesis psutil

- name: pytest
run: '"$RUNNER_TEMP/venv/bin/python" -m pytest -rs'
Expand All @@ -198,3 +198,60 @@ jobs:
name: sdist
path: dist/*.tar.gz
if-no-files-found: error

# The test suite with libphash, its decoders and the extension built under Clang's
# sanitizers by tools/sanitizers.py; the interpreter is not instrumented, so a report
# is about code of the extension. AddressSanitizer runs with UndefinedBehaviorSanitizer
# and LeakSanitizer, then fuzzes each target of tools/fuzz_targets.py for a minute
# from its corpus (fuzz.yml runs them longer); ThreadSanitizer watches libphash's
# worker threads and the callbacks they make into Python.
sanitizers:
strategy:
fail-fast: false
matrix:
sanitizer: [ address, thread ]
runs-on: ubuntu-24.04
timeout-minutes: 30
name: sanitizers (${{ matrix.sanitizer }})
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: ./.github/actions/setup

- name: Install Clang and its sanitizer runtimes
run: |
sudo apt-get update
sudo apt-get install -y clang libclang-rt-18-dev

- name: Build under the sanitizer
run: uv run --no-project python tools/sanitizers.py build ${{ matrix.sanitizer }}

- name: pytest
run: >
.venv/bin/python tools/sanitizers.py run ${{ matrix.sanitizer }} sanitizer-logs --
.venv/bin/python -m pytest -rs

- name: Fuzz each target for a minute
if: matrix.sanitizer == 'address'
run: |
mkdir -p fuzz-crashes
for target in load pixels digest; do
.venv/bin/python tools/sanitizers.py run fuzz sanitizer-logs -- \
.venv/bin/python tools/fuzz.py "$target" -max_total_time=60 -max_len=65536 \
-artifact_prefix=fuzz-crashes/
done

- name: Sanitizer reports
if: ${{ !cancelled() }}
run: .venv/bin/python tools/sanitizers.py check sanitizer-logs

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: sanitizers-${{ matrix.sanitizer }}
path: |
sanitizer-logs/
fuzz-crashes/
if-no-files-found: ignore
72 changes: 72 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Fuzz

# Each fuzz target of tools/fuzz_targets.py for a quarter of an hour, in the
# AddressSanitizer build of tools/sanitizers.py; the sanitizer job of ci.yml gives them a
# minute each. Every run starts from the corpus in tests/fuzz/: an input that fails a
# target is uploaded, and goes into the corpus with the fix. It runs weekly, by hand,
# and when a target, the corpus or this workflow changes.
on:
push:
branches: [ main, 'release/**' ]
paths: &fuzz-paths
- tools/fuzz.py
- tools/fuzz_targets.py
- tests/fuzz/**
- .github/workflows/fuzz.yml
pull_request:
branches: [ main, 'release/**' ]
paths: *fuzz-paths
schedule:
- cron: '53 4 * * 1'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
fuzz:
strategy:
fail-fast: false
matrix:
target: [ load, pixels, digest ]
runs-on: ubuntu-24.04
timeout-minutes: 45
name: fuzz (${{ matrix.target }})
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: ./.github/actions/setup

- name: Install Clang and its sanitizer runtimes
run: |
sudo apt-get update
sudo apt-get install -y clang libclang-rt-18-dev

- name: Build under AddressSanitizer
run: uv run --no-project python tools/sanitizers.py build address

- name: Fuzz for 15 minutes
run: |
mkdir -p fuzz-crashes
.venv/bin/python tools/sanitizers.py run fuzz sanitizer-logs -- \
.venv/bin/python tools/fuzz.py ${{ matrix.target }} -max_total_time=900 \
-max_len=65536 -artifact_prefix=fuzz-crashes/

- name: Sanitizer reports
if: ${{ !cancelled() }}
run: .venv/bin/python tools/sanitizers.py check sanitizer-logs

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: fuzz-${{ matrix.target }}
path: |
sanitizer-logs/
fuzz-crashes/
if-no-files-found: ignore
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ uv run mypy && uv run basedpyright # both strict, over s
uv run python tools/stub.py --check # _native.pyi matches the built module
uv run typos
uv build # sdist + wheel into dist/
# Linux with Clang and libclang-rt-dev; the build replaces the editable install in .venv
uv run --no-project python tools/sanitizers.py build address # or: thread
.venv/bin/python tools/sanitizers.py run address logs -- .venv/bin/python -m pytest
.venv/bin/python tools/sanitizers.py run fuzz logs -- .venv/bin/python tools/fuzz.py pixels -max_total_time=60
.venv/bin/python tools/sanitizers.py check logs
```

`uv sync` without `--inexact` removes packages that are not in the lock file from `.venv`.
Expand All @@ -39,6 +44,8 @@ How the extension is built:
- **`tools/cdef.py`** — generates the CFFI `cdef` from `native/libphash/include/libphash.h`: every function and constant of the header, nothing written by hand.
- **`tools/ffi.py`** — the CFFI builder (`cffi_modules` in `setup.py`).
- **`tools/stub.py`** — generates `src/libphash/_native.pyi` from the built module through cffi's introspection: a class per pointer and array type (`uint64_t_ptr`, `ph_digest_t_array`), struct fields, every `lib` function with the header's parameter names, `ffi.new`/`cast`/`from_buffer` overloaded on literal cdecls. Parameter types follow cffi's rules (a `T *` takes exactly `T *`/`T[]` or `ffi.NULL`; character pointers also take `bytes`). The classes exist only for type checkers: import them under `TYPE_CHECKING`. A test fails when the committed stub differs from the generated one.
- **`tools/sanitizers.py`** — the sanitizer builds (Clang; `address` is ASan + UBSan + libFuzzer's coverage hooks, `thread` is TSan) in `build/sanitizer-<name>/`, runs with the runtime preloaded into the uninstrumented interpreter, and `check`, which fails on any report and on a leak whose block the extension itself allocated (the interpreter's own unfreed blocks are ignored; `tests/conftest.py` asks LeakSanitizer for the check when the tests are done).
- **`tools/fuzz_targets.py`** — fuzz targets of what the binding adds over libphash (which fuzzes its decoders in C): `load` (encoded bytes), `pixels` (array layouts against their C-ordered copy), `digest` (text form, round trips, comparison ranges). **`tools/fuzz.py`** runs one under atheris (Linux x86-64, `fuzz` dependency group); `typings/atheris.pyi` types the part of atheris the tools use. The corpus is `tests/fuzz/<target>/`, replayed on every platform by `tests/test_fuzz_corpus.py`.
- **`setup.py`** — `build_ext` runs the CMake build before compiling the extension, so an sdist is made without a compiler.
- **`MANIFEST.in`** — the sdist carries only what libphash's CMake build reads (`CMakeLists.txt`, `cmake/`, `include/`, `src/`, `vendor/`, licenses), enough to build offline.

Expand All @@ -61,8 +68,8 @@ The public API is what `src/libphash/__init__.py` exports; every other module is
Typing: the package passes mypy and basedpyright in strict mode, and every type a caller needs to annotate their own code (`Source`, `StrPath`, `PixelArray`, `AlgorithmLike`, the `Config` literals, the protocols) is exported from `libphash`. Public signatures use protocols, not `Any`. A runtime check of an argument for untyped callers takes it as `object`, so neither checker calls the check unnecessary.
Lint: ruff with a broad rule set and pep257 docstrings (`pyproject.toml`); every exception to a rule is a per-file ignore with a comment or a `# noqa: CODE -- reason`, never a bare `noqa`.
- **`tests/`** — pytest suite (`test_cdef.py` fails when a header function is neither wrapped nor listed as deliberately unwrapped).
`test_c_parity.py` builds `tests/c/reference.c` (through `c_reference.py`) against the libphash install of `tools/native.py` and requires the binding's hashes and error codes to equal C's for every fixture, setting and pixel layout; without the install or CMake it skips, and CI sets `LIBPHASH_REQUIRE_C_REFERENCE=1` so that it fails instead. `test_corpus.py` does the same for libphash's 200-photo site corpus and is collected only when `LIBPHASH_CORPUS` names the list of fetched photos.
- **`.github/workflows/`** — `ci.yml` (lint, types, tests on every platform, their line and branch coverage combined and held at 100 %, sdist) on every push and PR; `wheels.yml` only when a build file changes: every wheel by cibuildwheel (configuration in `pyproject.toml`), each wheel's hash table (`tools/hash_table.py`) compared across all platforms byte for byte, and a build with JPEG decoded by stb_image; `corpus.yml` (the photo corpus, network) weekly, by hand and when the submodule changes; `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.
`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. `test_memory.py` repeats every path (success, errors, GC, batch, callbacks) and bounds the growth of Python objects (tracemalloc) and resident memory (psutil), and holds the peak of a batch to libphash's estimate (4 bytes per pixel and the file, per worker); under a sanitizer it skips, and LeakSanitizer checks the C side.
- **`.github/workflows/`** — `ci.yml` (lint, types, tests on every platform, their line and branch coverage combined and held at 100 %, sdist, the test suite under ASan/UBSan/LSan and under TSan, a minute of each fuzz target) on every push and PR; `fuzz.yml` (a quarter of an hour per fuzz target) weekly, by hand and when a target or the corpus changes; `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
30 changes: 25 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ dependencies = ["cffi>=1.15.0"]
"Homepage" = "https://github.com/gudoshnikovn/python-libphash"

[project.optional-dependencies]
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'"]
dev = ["pytest", "pytest-randomly", "pytest-timeout", "hypothesis", "psutil", "coverage", "mypy", "basedpyright", "ruff", "typos", "setuptools", "types-setuptools", "types-cffi", "types-psutil", "typing_extensions", "numpy", "Pillow", "mutmut; sys_platform != 'win32'"]

# Not part of "dev": atheris has wheels for Linux x86-64 and CPython 3.12+ only, and the
# sanitizer job of CI, which runs the fuzzers, installs it with --group fuzz.
[dependency-groups]
fuzz = [
"atheris>=3.1; sys_platform == 'linux' and platform_machine == 'x86_64' and python_version >= '3.12'",
]

[tool.setuptools]
zip-safe = false
Expand All @@ -49,7 +56,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", "pytest-timeout", "hypothesis", "numpy", "Pillow"]
test-requires = ["pytest", "pytest-timeout", "hypothesis", "psutil", "numpy", "Pillow"]
test-command = "pytest {project}/tests -rs"

# CMake and Ninja come from build-system.requires. nasm gives libjpeg-turbo its x86
Expand Down Expand Up @@ -106,14 +113,21 @@ 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"]
# The memory tests repeat every operation hundreds of times and catch no mutant the other
# tests miss; run for each mutant, they would multiply the run's time.
pytest_add_cli_args_test_selection = ["tests/", "--ignore=tests/test_memory.py"]
also_copy = [
"native/libphash/include",
"native/libphash/tests/data",
"tools",
".github/workflows",
]

[tool.mypy]
strict = true
python_version = "3.10"
files = ["src", "tests", "tools", "setup.py"]
mypy_path = ["src", "tools"]
mypy_path = ["src", "tools", "typings"]

[tool.basedpyright]
typeCheckingMode = "strict"
Expand Down Expand Up @@ -156,6 +170,12 @@ convention = "pep257"
"tests/c_reference.py" = ["S603", "PERF203"]
# The build runs CMake from PATH or the build environment, with arguments it builds.
"tools/native.py" = ["S603"]
# The sanitizer builds run uv and clang from PATH, and a run starts the command it is
# given.
"tools/sanitizers.py" = ["S603", "S607"]
# A fuzz target fails by raising, so its checks are asserts; an error is an outcome to
# record for each algorithm, not a reason to stop.
"tools/fuzz_targets.py" = ["S101", "PERF203"]
# The stub's classes and parameters carry the names of the C types and of the header.
"src/libphash/_native.pyi" = ["N801", "A002"]

Expand Down
25 changes: 25 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ctypes
import functools
import os
import re
Expand All @@ -20,6 +21,30 @@
DATA = c_reference.DATA


def _sanitizer_runtime() -> ctypes.CDLL | None:
"""Return the process's own symbols if a sanitizer runtime is loaded into it."""
if sys.platform == "win32":
return None
symbols = ctypes.CDLL(None)
return symbols if hasattr(symbols, "__sanitizer_print_stack_trace") else None


#: The sanitizer job runs the tests with AddressSanitizer or ThreadSanitizer preloaded.
SANITIZER = _sanitizer_runtime()


def pytest_unconfigure() -> None:
"""Under LeakSanitizer, report the blocks nothing points to once the tests are done.

The sanitizer job turns the check at exit off: by then the interpreter has dropped
its own references to memory it never frees, which would bury the report. Here the
interpreter still holds them, and tools/sanitizer_logs.py fails the job on a block
the extension allocated.
"""
if SANITIZER is not None and hasattr(SANITIZER, "__lsan_do_recoverable_leak_check"):
SANITIZER.__lsan_do_recoverable_leak_check()


# 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. The question is
# asked inside tests, not at import: collection never calls into the package, so a
Expand Down
1 change: 1 addition & 0 deletions tests/fuzz/digest/bits-pair
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
4`¾1 iþÚ îè¹™\|)™ý¯å“%<ÖT¯Mú×' ®³þé#/Šò!žä‘ű ìµV;üo“B~ËÈþ)
Binary file added tests/fuzz/digest/coefficients-pair
Binary file not shown.
Binary file added tests/fuzz/digest/flat-coefficients
Binary file not shown.
Binary file added tests/fuzz/digest/from-hex-bad-digit
Binary file not shown.
Binary file added tests/fuzz/digest/from-hex-bits
Binary file not shown.
1 change: 1 addition & 0 deletions tests/fuzz/digest/hash-from-hex
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
00ff00ff12345678
Binary file added tests/fuzz/digest/histogram-pair
Binary file not shown.
Binary file added tests/fuzz/digest/unspecified-and-bits
Binary file not shown.
Binary file added tests/fuzz/digest/vector16-pair
Binary file not shown.
Binary file added tests/fuzz/load/truncated-tga
Binary file not shown.
Binary file added tests/fuzz/pixels/channel-gaps-bgr
Binary file not shown.
Binary file added tests/fuzz/pixels/column-step-bgra
Binary file not shown.
Binary file added tests/fuzz/pixels/empty
Binary file not shown.
Binary file added tests/fuzz/pixels/reversed-rows-gray
Binary file not shown.
Binary file added tests/fuzz/pixels/row-gaps-rgb
Binary file not shown.
Binary file added tests/fuzz/pixels/swapped-axes-one-channel
Binary file not shown.
48 changes: 48 additions & 0 deletions tests/test_fuzz_corpus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""The fuzz corpus, replayed: every input of tests/fuzz/ holds its target's property.

tools/fuzz.py runs the targets under atheris in the sanitizer job; here their corpus,
inputs found by fuzzing included, runs on every platform without atheris.
"""

import re
import sys
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import fuzz_targets # noqa: E402 -- tools/ joins sys.path just above


def corpus(target: str) -> list[Path]:
dirs = fuzz_targets.seed_dirs(target)
return sorted(p for d in dirs if d.is_dir() for p in d.iterdir() if p.is_file())


CASES = [
pytest.param(target, path, id=f"{target}/{path.name}")
for target in fuzz_targets.TARGETS
for path in corpus(target)
]


@pytest.mark.parametrize(("target", "path"), CASES)
def test_corpus_input(target: str, path: Path) -> None:
if target == "pixels":
pytest.importorskip("numpy")
fuzz_targets.TARGETS[target](path.read_bytes())


def test_every_target_has_a_corpus() -> None:
assert all(corpus(target) for target in fuzz_targets.TARGETS)


def test_ci_fuzzes_every_target() -> None:
workflows = ROOT / ".github" / "workflows"
loop = re.search(r"for target in (.+); do", (workflows / "ci.yml").read_text())
matrix = re.search(r"target: \[ (.+) \]", (workflows / "fuzz.yml").read_text())
assert loop is not None
assert matrix is not None
assert loop.group(1).split() == list(fuzz_targets.TARGETS)
assert [t.strip() for t in matrix.group(1).split(",")] == list(fuzz_targets.TARGETS)
Loading
Loading