Release 0.22.0 with composable feature extras

This commit is contained in:
Victor Kuznetsov
2026-07-31 10:39:13 -07:00
parent 9c9e81c756
commit 08dc078d91
32 changed files with 567 additions and 172 deletions
+5 -5
View File
@@ -542,7 +542,7 @@ class TestAllCommand:
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output)])
assert result.exit_code != 0, result.output
assert "NOT removed" in result.output
assert "remove-ai-watermarks[gpu]" in result.output
assert "remove-ai-watermarks[diffusion]" in result.output
assert output.exists() # visible + metadata still produced a file
def test_all_reports_metadata_that_survived_stripping(self, runner, sample_png, tmp_path):
@@ -933,21 +933,21 @@ class TestBatchCommand:
class TestGpuHintMarkup:
"""The GPU-extra install hint must reach the user with the ``[gpu]`` token
"""The diffusion install hint must reach the user with the ``[diffusion]`` token
intact (plain output prints it verbatim, with no markup parsing)."""
def test_invisible_install_hint_keeps_gpu_extra(self, runner, sample_png):
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
result = runner.invoke(main, ["invisible", str(sample_png)])
assert result.exit_code != 0
assert "remove-ai-watermarks[gpu]" in result.output
assert "remove-ai-watermarks[diffusion]" in result.output
def test_all_install_hint_keeps_gpu_extra(self, runner, sample_png):
# The `all` pipeline skips the invisible step with a warning that carries
# the same hint; it must keep the [gpu] extra too.
# the same hint; it must keep the [diffusion] extra too.
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
result = runner.invoke(main, ["all", str(sample_png)])
assert "remove-ai-watermarks[gpu]" in result.output
assert "remove-ai-watermarks[diffusion]" in result.output
class TestEraseCommand:
+10 -1
View File
@@ -782,6 +782,15 @@ class TestIdentifyVisibleTextMarks:
identify(tmp_clean_png, check_visible=True, check_invisible=False)
assert mock_imread.call_count == 1
def test_missing_pixel_extra_preserves_metadata_verdict(self, tmp_png_with_ai_metadata: Path):
import remove_ai_watermarks.image_io as image_io
with patch.object(image_io, "imread", side_effect=ModuleNotFoundError("No module named 'cv2'")):
report = identify(tmp_png_with_ai_metadata, check_visible=True, check_invisible=False)
assert report.is_ai_generated is True
assert report.confidence == "high"
# ── Caveats and serialization ───────────────────────────────────────
@@ -989,7 +998,7 @@ class TestIdentifyC2paDevice:
from remove_ai_watermarks.invisible_watermark import is_available as _wm_available # noqa: E402
@pytest.mark.skipif(not _wm_available(), reason="invisible-watermark not installed")
@pytest.mark.skipif(not _wm_available(), reason="detect extra not installed")
class TestIdentifyInvisibleWatermark:
def _sdxl_watermarked(self, tmp_path: Path) -> Path:
import cv2
+3 -3
View File
@@ -18,9 +18,9 @@ class TestIsAvailable:
assert isinstance(result, bool)
def test_available_reflects_dependencies(self):
"""is_available() is True iff torch + diffusers (the gpu extra) import.
"""is_available() is True iff torch + diffusers (the diffusion extra) import.
Must not assume the full stack: the core+dev CI env has no diffusers.
Must not assume the full stack: the default+dev CI env has no diffusers.
"""
import importlib.util
@@ -212,7 +212,7 @@ class TestCannyControlImage:
def test_edge_map_is_3channel_rgb(self):
if not is_available():
pytest.skip("gpu extra (torch/diffusers) not installed")
pytest.skip("diffusion extra (torch/diffusers) not installed")
import numpy as np
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
+18 -5
View File
@@ -1,8 +1,7 @@
"""Tests for open invisible-watermark (imwatermark) detection.
"""Tests for open DWT-DCT watermark detection.
Each known scheme is round-tripped: embed its exact upstream pattern with the
encoder, then assert the detector names it. Skipped entirely if the optional
``invisible-watermark`` package is not installed.
The upstream encoder supplies known watermarks, while the in-tree decoder must
both identify them and match the upstream decoder bit for bit.
"""
from __future__ import annotations
@@ -25,7 +24,7 @@ from remove_ai_watermarks.invisible_watermark import (
is_available,
)
pytestmark = pytest.mark.skipif(not is_available(), reason="invisible-watermark not installed")
pytestmark = pytest.mark.skipif(not is_available(), reason="detect extra not installed")
def _base_image() -> np.ndarray:
@@ -61,6 +60,20 @@ class TestHelpers:
class TestDetect:
def test_in_tree_decoder_matches_upstream(self, tmp_path: Path):
from imwatermark import WatermarkDecoder
from remove_ai_watermarks.dwt_dct import decode_dwt_dct
from remove_ai_watermarks.image_io import imread
path = _write_bits_watermark(tmp_path, _BITS_48["Stable Diffusion XL"])
image = imread(path)
assert image is not None
upstream = np.asarray(WatermarkDecoder("bits", 48).decode(image, "dwtDct"), dtype=bool)
ours = np.asarray(decode_dwt_dct(image, wm_len=48), dtype=bool)
assert np.array_equal(ours, upstream)
def test_detects_sdxl(self, tmp_path: Path):
path = _write_bits_watermark(tmp_path, _BITS_48["Stable Diffusion XL"])
assert detect_invisible_watermark(path) == "Stable Diffusion XL"
+2 -2
View File
@@ -52,8 +52,8 @@ class TestConstants:
assert ".jpg" in SUPPORTED_FORMATS
def test_supported_formats_include_heic_avif(self):
# HEIC/AVIF are first-class on the pixel path now (read+write via pillow-heif),
# so batch discovers them and the CLI does not warn.
# HEIC/AVIF are first-class when the visible pixel extra is installed
# (read+write via pillow-heif), so batch discovers them without a warning.
assert {".heic", ".heif", ".avif"} <= SUPPORTED_FORMATS
def test_supported_formats_exclude_jpeg_xl(self):
+67
View File
@@ -0,0 +1,67 @@
"""Published dependency boundaries."""
from __future__ import annotations
from importlib.metadata import metadata, requires
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
def _requirement_names(extra: str | None = None) -> set[str]:
selected_extra = extra or ""
parsed = (Requirement(value) for value in requires("remove-ai-watermarks") or [])
return {
canonicalize_name(requirement.name)
for requirement in parsed
if requirement.marker is None or requirement.marker.evaluate({"extra": selected_extra})
}
def test_default_install_is_metadata_focused():
default = _requirement_names()
assert {
"c2pa-python",
"click",
"piexif",
"pillow",
"python-dotenv",
} <= default
assert {
"invisible-watermark",
"numpy",
"opencv-python-headless",
"pillow-heif",
"torch",
"trustmark",
}.isdisjoint(default)
def test_pixels_extra_owns_shared_numeric_dependencies():
assert {
"numpy",
"opencv-python-headless",
} <= _requirement_names("pixels")
def test_file_format_and_detector_dependencies_are_independent():
assert "pillow-heif" in _requirement_names("heif")
assert "pywavelets" in _requirement_names("detect")
def test_extras_use_capability_names_without_legacy_aliases():
extras = set(metadata("remove-ai-watermarks").get_all("Provides-Extra") or [])
assert {"pixels", "heif", "visible", "detect", "diffusion"} <= extras
assert {"gpu", "remove", "detect-pywavelets"}.isdisjoint(extras)
def test_production_all_does_not_include_development_tools():
assert {
"pyright",
"pytest",
"pytest-cov",
"pytest-xdist",
"ruff",
}.isdisjoint(_requirement_names("all"))
+2 -2
View File
@@ -238,7 +238,7 @@ class TestQwenKwargs:
"""_build_qwen_kwargs is pure (no torch); guards the Qwen-Image call shape.
watermark_remover imports torch under a try/except, so the module (and this pure
helper) imports fine in the core+dev CI env where torch is absent.
helper) imports fine in the default+dev CI env where torch is absent.
"""
def test_uses_true_cfg_not_guidance_scale(self):
@@ -431,7 +431,7 @@ class TestAvailability:
def test_watermark_removal_available(self):
# Reflects the actual environment: True iff torch + diffusers (the gpu
# extra) are importable. The core+dev CI env has no diffusers, so this
# extra) are importable. The default+dev CI env has no diffusers, so this
# must not assume the full stack is present.
import importlib.util