diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3c741e8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + - name: Sync dev environment + run: uv sync --frozen --extra dev + - name: Ruff lint + run: uv run ruff check + - name: Ruff format check + run: uv run ruff format --check + + test: + name: test ${{ matrix.os }} py${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Cross-OS x Python-floor/recent. The default-install surface only + # needs the core deps plus dev tooling (no GPU extra): the model-running + # paths are availability-gated and skip when torch/diffusers are absent, + # so this matrix exercises metadata, identify, visible, and the cv2 + # eraser on every OS without pulling the heavy ML stack. + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + - name: Sync dev environment + run: uv sync --frozen --extra dev + - name: Run tests + run: uv run pytest -q diff --git a/CLAUDE.md b/CLAUDE.md index 9c46200..35d22c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r ## Test and lint +- **CI** (`.github/workflows/test.yml`): runs on push to `main` + every PR. A `lint` job (ubuntu: `ruff check` + `ruff format --check`) plus a `test` matrix (ubuntu/macos/windows x py3.10/3.12) that does `uv sync --frozen --extra dev` then `pytest`. The matrix installs only core + dev (no `gpu` extra), so the GPU/model-running tests skip there and it exercises the metadata/identify/visible/cv2-eraser surface on all three OSes. Keep `uv.lock` valid (don't break `--frozen`) when editing `pyproject.toml`. `publish.yml` stays release-only. - `bash maintain.sh` — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright, pytest -n auto - **Strict pyright is clean across `src/` (0 errors).** The cv2/torch/diffusers boundary files (`gemini_engine`, `region_eraser`, `doubao_engine`, `face_protector`, `humanizer`, `invisible_engine`, `noai/watermark_remover`, and the whole `noai/ctrlregen/` subpackage) carry a documented per-file `# pyright:` relax pragma (or, for `ctrlregen`, a `tool.pyright.executionEnvironments` entry) that turns off only the unknown-type / untyped-third-party rules — those libs ship no usable types, so strict typing there fights the ecosystem. Pure-logic files stay fully strict; `typings/piexif/__init__.pyi` is a local stub so `metadata.py`/`extractor.py` resolve piexif. Public ndarray-returning signatures on the relaxed engines are still annotated `NDArray[Any]` so strict consumers (`cli.py`) stay clean. When touching a relaxed file, prefer fixing real issues over widening the pragma; keep the pragma scoped to genuinely-untyped boundaries. (`uv-secure` is clean since idna was bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx.) - **Full-project `uv run pyright` (no path) OOMs/crashes node on this ML-heavy repo** (emits a `libnode` stack frame, no summary) — a known environment limit, not a code error. Gate with `uv run --extra dev --extra gpu pyright src/` (completes, authoritative) or scope to changed files; also run `uv run ruff check` and `uv run pytest` directly. diff --git a/README.md b/README.md index a3ea16b..cf549c6 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and vi > > No Python, no GPU, no setup. Visible-watermark and metadata removal are **free**. Invisible-watermark removal (SynthID / SDXL regeneration) normally needs a local GPU and ~2 GB of models. On **[raiw.cc](https://raiw.cc)** it runs on cloud GPUs in one click for a small per-image fee. +[![Tests](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml/badge.svg)](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml) [![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-db61a2?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/wiltodelta) If this tool saves you time, consider [sponsoring its development](https://github.com/sponsors/wiltodelta). diff --git a/pyproject.toml b/pyproject.toml index 72eddc9..e0324dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,16 @@ description = "Remove visible and invisible AI watermarks from images (Gemini / readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} +classifiers = [ + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Graphics", + "Topic :: Scientific/Engineering :: Image Processing", +] dependencies = [ "pillow>=10.0.0", "piexif>=1.1.3", diff --git a/tests/test_cli.py b/tests/test_cli.py index b47428c..3a53c62 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -249,6 +249,7 @@ class TestInvisibleCommand: mock_cls, mock_engine = _mock_invisible_engine() output = tmp_path / "clean.png" with ( + patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True), patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True), patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls), ): @@ -263,6 +264,7 @@ class TestInvisibleCommand: def test_invisible_default_output(self, runner, sample_png): mock_cls, _mock_engine = _mock_invisible_engine() with ( + patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True), patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True), patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls), ): diff --git a/tests/test_invisible_engine.py b/tests/test_invisible_engine.py index 8b5e244..1ea39af 100644 --- a/tests/test_invisible_engine.py +++ b/tests/test_invisible_engine.py @@ -12,9 +12,15 @@ class TestIsAvailable: result = is_available() assert isinstance(result, bool) - def test_available_when_torch_installed(self): - """torch + diffusers should be installed in dev env.""" - assert is_available() is True + def test_available_reflects_dependencies(self): + """is_available() is True iff torch + diffusers (the gpu extra) import. + + Must not assume the full stack: the core+dev CI env has no diffusers. + """ + import importlib.util + + expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers")) + assert is_available() is expected class TestInvisibleEngineInit: diff --git a/tests/test_platform.py b/tests/test_platform.py index 54c9871..00dfce6 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -151,13 +151,21 @@ class TestAvailability: """Tests for dependency availability checks.""" def test_watermark_removal_available(self): - # In dev env with torch+diffusers installed - assert is_watermark_removal_available() is True + # Reflects the actual environment: True iff torch + diffusers (the gpu + # extra) are importable. The core+dev CI env has no diffusers, so this + # must not assume the full stack is present. + import importlib.util + + expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers")) + assert is_watermark_removal_available() is expected def test_invisible_is_available(self): + import importlib.util + from remove_ai_watermarks.invisible_engine import is_available - assert is_available() is True + expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers")) + assert is_available() is expected # ── Platform-specific path handling ─────────────────────────────────