From a43082782704a439ff62ab0bfa50f5a6175670ad Mon Sep 17 00:00:00 2001 From: "Guillaume Meyer (The Opinionated Man)" <1385518+guillaumemeyer@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:34:51 -0700 Subject: [PATCH] fix: prefer checkout venv for SynthID scorer subprocess (#115) (#123) --- service/scripts/image_meta.py | 23 +++++++++++++++--- tests/test_synthid_score.py | 46 +++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/service/scripts/image_meta.py b/service/scripts/image_meta.py index 7a804b5..e2b883b 100755 --- a/service/scripts/image_meta.py +++ b/service/scripts/image_meta.py @@ -1240,6 +1240,17 @@ def _synthid_score_http( return payload +def _synthid_python(upstream: Path) -> str: + """Prefer the checkout venv so the scorer deps (cv2, sklearn) are importable.""" + if os.name == "nt": + venv = upstream / ".venv" / "Scripts" / "python.exe" + else: + venv = upstream / ".venv" / "bin" / "python" + if venv.is_file(): + return str(venv) + return sys.executable + + def run_synthid_score( path: Path, upstream_dir: str | None = None, @@ -1248,8 +1259,9 @@ def run_synthid_score( Uses the HTTP sidecar when WATERMARKS_SYNTHID_SCORER_URL is set, otherwise a subprocess against a local checkout. Returns None when the - scorer is not configured or unavailable (exit 3), so callers can keep - the default "no SynthID score" behavior. + scorer is not configured; a dict with "available": False and an "error" + when it is configured but unavailable (e.g. exit 3), so callers can + distinguish "not scored" from "scored and clean". """ scorer_url = os.environ.get("WATERMARKS_SYNTHID_SCORER_URL", "").strip() if scorer_url: @@ -1266,7 +1278,7 @@ def run_synthid_score( script = SCRIPTS_DIR / "score_synthid.py" cmd = [ - sys.executable, + _synthid_python(Path(upstream_dir)), str(script), str(path), "--upstream-dir", @@ -1286,7 +1298,10 @@ def run_synthid_score( return {"available": False, "error": str(e)} if r.returncode == 3: - return None + return { + "available": False, + "error": (r.stderr or "SynthID scorer unavailable (exit 3)").strip()[:2000], + } if r.returncode != 0: return {"available": False, "error": (r.stderr or "").strip()[:2000]} try: diff --git a/tests/test_synthid_score.py b/tests/test_synthid_score.py index efaf5e2..82c3e26 100644 --- a/tests/test_synthid_score.py +++ b/tests/test_synthid_score.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -45,7 +46,7 @@ def test_run_synthid_score_unconfigured_returns_none( assert run_synthid_score(Path("x.png")) is None -def test_run_synthid_score_unavailable_returns_none( +def test_run_synthid_score_unavailable_returns_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): @@ -53,7 +54,48 @@ def test_run_synthid_score_unavailable_returns_none( return SimpleNamespace(returncode=3, stdout="", stderr="unavailable") monkeypatch.setattr(image_meta.subprocess, "run", fake_run) - assert run_synthid_score(Path("x.png"), upstream_dir=str(tmp_path / "upstream")) is None + result = run_synthid_score(Path("x.png"), upstream_dir=str(tmp_path / "upstream")) + + assert result is not None + assert result.get("available") is False + assert "unavailable" in result.get("error", "") + + +def test_run_synthid_score_prefers_checkout_venv_python( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + upstream = tmp_path / "upstream" + venv_python = upstream / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + venv_python.parent.mkdir(parents=True) + venv_python.write_text("#!/bin/sh\n") + captured: dict = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + return SimpleNamespace(returncode=0, stdout="{}", stderr="") + + monkeypatch.setattr(image_meta.subprocess, "run", fake_run) + run_synthid_score(Path("img.png"), upstream_dir=str(upstream)) + + assert captured["cmd"][0] == str(venv_python) + + +def test_run_synthid_score_falls_back_to_sys_executable( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + upstream = tmp_path / "upstream" # no .venv present + captured: dict = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + return SimpleNamespace(returncode=0, stdout="{}", stderr="") + + monkeypatch.setattr(image_meta.subprocess, "run", fake_run) + run_synthid_score(Path("img.png"), upstream_dir=str(upstream)) + + assert captured["cmd"][0] == sys.executable def test_run_synthid_score_parses_json(