From 31217498cea602f3f4986552cc2e8d7893cd2b63 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 26 Jun 2026 10:44:49 -0400 Subject: [PATCH] Add Forgejo CI: ruff lint + pytest unit + gated image publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Family-consistent CI for StashHandler (mirrors the NhenArchiver Python sibling, minus the integration lane — StashHandler is SQLite-only with no service containers). - .forgejo/workflows/ci.yml: lint (ruff) -> unit (pytest) -> publish. publish builds + pushes git.fabledsword.com/bvandeusen/stashhandler with the family rule-46 tag scheme (dev->:dev+:c-, main->:latest+:c-, v* tag->:+:latest). Gated on lint+unit. Needs REGISTRY_TOKEN. - tests/: starter unit suite — hashing, SQLite dedup, media filter, transfer modes. - pyproject.toml: ruff target + pytest pythonpath/testpaths. - ci-requirements.md (family rule 39), .dockerignore. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016nyYC9dTQ78SqhpNFtpMvT --- .dockerignore | 20 ++++++++ .forgejo/workflows/ci.yml | 100 ++++++++++++++++++++++++++++++++++++ ci-requirements.md | 52 +++++++++++++++++++ pyproject.toml | 11 ++++ tests/test_db.py | 47 +++++++++++++++++ tests/test_hasher.py | 42 +++++++++++++++ tests/test_stash_handler.py | 47 +++++++++++++++++ 7 files changed, 319 insertions(+) create mode 100644 .dockerignore create mode 100644 .forgejo/workflows/ci.yml create mode 100644 ci-requirements.md create mode 100644 pyproject.toml create mode 100644 tests/test_db.py create mode 100644 tests/test_hasher.py create mode 100644 tests/test_stash_handler.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9b6edab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# Keep the build context lean and the image free of dev/CI cruft. +.git +.gitignore +.dockerignore +.forgejo +.claude +tests +pyproject.toml +*.db +*.db-wal +*.db-shm +__pycache__ +*.pyc +.pytest_cache +.venv +README.md +summary.md +ci-requirements.md +docker-compose.yaml +docker-compose.prod.example.yaml diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..bd8681c --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +# Family CI lanes (FabledRulebook "CI philosophy", rule 6), mirrored from the +# NhenArchiver Python sibling and adapted for StashHandler: +# - lint: ruff only, no dep install (fast-fail for the common bounce). +# - unit: pytest, no service containers, no DB beyond SQLite tmpfiles. +# - publish: build + push the runtime image to the Forgejo registry, gated on +# the test lanes (family rule 46 tag scheme). +# +# No integration lane: StashHandler is SQLite-only with no service deps +# (family rules 79-82 do not apply). +# +# Requires repo Actions secret REGISTRY_TOKEN -- a Forgejo PAT scoped +# read:package + write:package. The injected GITHUB_TOKEN CANNOT be used: it +# lacks write:package and `docker push` 401s with reqPackageAccess (rule 7). + +on: + push: + branches: [dev, main] + # A v* tag publishes an immutable per-version image (e.g. :v26.06.26) and + # refreshes :latest (family rule 46). + tags: ['v*'] + +jobs: + # Fast-fail lint lane. ruff is pre-installed in ci-python, so this runs with + # NO dependency install and surfaces the common bounce class in seconds. + lint: + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v4 + - name: Ruff lint + run: ruff check . + + unit: + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v4 + - name: Install Python deps + # uv: faster wheel resolve than pip on cold caches. Falls back to pip on + # uv-missing runners (older images). blake3 is the optional runtime hash + # backend exercised by the hasher tests. + run: | + if command -v uv >/dev/null 2>&1; then + uv pip install --system -r requirements.txt pytest blake3 + else + pip install -r requirements.txt pytest blake3 + fi + - name: Pytest (unit) + run: pytest tests/ -v + + # Build + push the runtime image. Gated on the test lanes. ci-python ships the + # docker CLI + buildx; act_runner auto-mounts the host docker socket via + # valid_volumes, so we do NOT set container.options (family rule 8). + publish: + needs: [lint, unit] + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v4 + + - name: Determine tags + id: tag + run: | + # Three trigger shapes (family rule 46 -- commit-SHA images are the + # rollback unit; :latest tracks main's tip; no separate :main tag): + # refs/tags/v* -> immutable : + refresh :latest + # refs/heads/main -> :latest + :c- + # refs/heads/dev -> :dev + :c- + # POSIX-safe substring (runner shell is dash/BusyBox sh, not bash -- + # ${var:0:7} errors with "Bad substitution"; cut works everywhere). + IMG=git.fabledsword.com/bvandeusen/stashhandler + SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7) + if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then + TAG_NAME="${GITHUB_REF#refs/tags/}" + echo "tags=${IMG}:${TAG_NAME},${IMG}:latest" >> "$GITHUB_OUTPUT" + elif [ "${GITHUB_REF##*/}" = "main" ]; then + echo "tags=${IMG}:latest,${IMG}:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT" + else + echo "tags=${IMG}:dev,${IMG}:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT" + fi + + - name: Login to Forgejo registry + uses: docker/login-action@v3 + with: + registry: git.fabledsword.com + username: ${{ github.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + push: true + tags: ${{ steps.tag.outputs.tags }} diff --git a/ci-requirements.md b/ci-requirements.md new file mode 100644 index 0000000..261cd9b --- /dev/null +++ b/ci-requirements.md @@ -0,0 +1,52 @@ +# CI Requirements — StashHandler + +> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md) +> in the CI-Runner repo. + +## Runtime image + +The runner image this project's CI consumes via `container.image`. + +``` +git.fabledsword.com/bvandeusen/ci-python:3.14 +``` + +CI lints and tests under 3.14; the **shipped** runtime is Python 3.12 (the app +`Dockerfile` is `FROM python:3.12-slim`). The code targets 3.12+ features only +(walrus, `datetime.now(timezone.utc)`), so the version gap is intentional and +harmless. + +## Image deps used + +Tools/libraries already in the image that this project actually relies on: + +- python 3.14 (test interpreter) +- ruff (lint lane — runs with no per-job install) +- docker CLI + buildx (used by the `publish` job's build-push step) + +## Per-job tool installs + +Anything CI installs *at job time* (not in the image): + +- `pip`/`uv install -r requirements.txt pytest blake3` in the **unit** job. + - `pytest` — test runner (not in the image). + - `blake3` — optional runtime hash backend (`HASH_ALGORITHM=blake3`), + exercised by the hasher tests. Not in `requirements.txt` because the + default `sha256` path uses stdlib `hashlib`. + +## Notes + +Friction, expectations, and anything the maintainer wouldn't see otherwise: + +- **No integration lane.** StashHandler persists to SQLite (per-run tmpfiles in + tests) and talks to qBittorrent/Stash over HTTP at runtime only — there is no + service container to stand up, so family rules 79–82 don't apply here. +- **Three lanes:** `lint` (ruff) → `unit` (pytest) → `publish` (gated image + build+push, family rule 46 tag scheme). Publish image is + `git.fabledsword.com/bvandeusen/stashhandler` (lowercase — matches the prod + compose pull). +- Unit suite is a starter set covering the pure logic (hashing, SQLite dedup, + media-extension filter, file transfer modes). Grow it as behavior is added. +- Requires repo Actions secret **`REGISTRY_TOKEN`** (Forgejo PAT, + `read:package` + `write:package`) for the publish lane — the injected + `GITHUB_TOKEN` can't push to the registry (family rule 7). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..26b25ca --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.ruff] +# Match the runtime image (Dockerfile FROM python:3.12-slim); CI lints under +# ci-python:3.14 but the shipped target is 3.12. +target-version = "py312" + +[tool.pytest.ini_options] +# Tests live in tests/ but import the app modules from the repo root +# (db, hasher, qbit_client, stash_handler). pythonpath makes the root +# importable without packaging the app. +pythonpath = ["."] +testpaths = ["tests"] diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..6e2e4f3 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,47 @@ +import db + + +def make_conn(tmp_path): + return db.init_db(str(tmp_path / "seen.db")) + + +def test_record_and_is_known(tmp_path): + conn = make_conn(tmp_path) + assert not db.is_known(conn, "deadbeef") + db.record_file(conn, "deadbeef", "a.mp4", "scan") + assert db.is_known(conn, "deadbeef") + + +def test_record_file_is_idempotent_on_hash(tmp_path): + conn = make_conn(tmp_path) + db.record_file(conn, "h1", "first.mp4", "scan") + db.record_file(conn, "h1", "second.mp4", "scan") # INSERT OR IGNORE + row = conn.execute("SELECT filename FROM seen_files WHERE hash='h1'").fetchone() + assert row[0] == "first.mp4" # first write wins, no duplicate row + + +def test_increment_seen(tmp_path): + conn = make_conn(tmp_path) + db.record_file(conn, "h1", "a.mp4", "scan") # times_seen defaults to 1 + db.increment_seen(conn, "h1") + db.increment_seen(conn, "h1") + row = conn.execute("SELECT times_seen FROM seen_files WHERE hash='h1'").fetchone() + assert row[0] == 3 + + +def test_processed_torrent_roundtrip(tmp_path): + conn = make_conn(tmp_path) + assert not db.is_torrent_processed(conn, "ih1") + db.record_torrent(conn, "ih1", "Some.Torrent.Name") + assert db.is_torrent_processed(conn, "ih1") + + +def test_processed_file_is_scoped_to_torrent_and_name(tmp_path): + conn = make_conn(tmp_path) + assert not db.is_file_processed(conn, "ih1", "f.mp4") + db.record_processed_file(conn, "ih1", "f.mp4") + assert db.is_file_processed(conn, "ih1", "f.mp4") + # A different file under the same torrent is tracked independently. + assert not db.is_file_processed(conn, "ih1", "other.mp4") + # Same file name under a different torrent is also independent. + assert not db.is_file_processed(conn, "ih2", "f.mp4") diff --git a/tests/test_hasher.py b/tests/test_hasher.py new file mode 100644 index 0000000..f40857f --- /dev/null +++ b/tests/test_hasher.py @@ -0,0 +1,42 @@ +import builtins +import hashlib + +import pytest + +from hasher import hash_file + + +def test_sha256_matches_hashlib(tmp_path): + p = tmp_path / "f.bin" + # Larger than the 64KB read chunk so the streaming loop iterates. + data = b"hello world\n" * 10000 + p.write_bytes(data) + assert hash_file(p, "sha256") == hashlib.sha256(data).hexdigest() + + +def test_default_algorithm_is_sha256(tmp_path): + p = tmp_path / "f.bin" + p.write_bytes(b"abc") + assert hash_file(p) == hashlib.sha256(b"abc").hexdigest() + + +def test_blake3_produces_expected_digest(tmp_path): + blake3 = pytest.importorskip("blake3") + p = tmp_path / "f.bin" + p.write_bytes(b"abc") + assert hash_file(p, "blake3") == blake3.blake3(b"abc").hexdigest() + + +def test_blake3_missing_raises_importerror(tmp_path, monkeypatch): + p = tmp_path / "f.bin" + p.write_bytes(b"abc") + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "blake3": + raise ImportError("simulated missing blake3") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + with pytest.raises(ImportError): + hash_file(p, "blake3") diff --git a/tests/test_stash_handler.py b/tests/test_stash_handler.py new file mode 100644 index 0000000..3a11dc6 --- /dev/null +++ b/tests/test_stash_handler.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import stash_handler + +EXTS = {".mp4", ".mkv"} + + +def test_is_media_is_case_insensitive(): + assert stash_handler.is_media(Path("a.mp4"), EXTS) + assert stash_handler.is_media(Path("A.MKV"), EXTS) + assert not stash_handler.is_media(Path("notes.txt"), EXTS) + + +def test_walk_media_recurses_and_filters(tmp_path): + (tmp_path / "sub").mkdir() + (tmp_path / "a.mp4").write_bytes(b"x") + (tmp_path / "sub" / "b.mkv").write_bytes(b"x") + (tmp_path / "c.txt").write_bytes(b"x") + found = {p.name for p in stash_handler.walk_media(tmp_path, EXTS)} + assert found == {"a.mp4", "b.mkv"} + + +def test_transfer_copy_preserves_original(tmp_path): + src = tmp_path / "src.mp4" + src.write_bytes(b"data") + dst = tmp_path / "out" / "src.mp4" + stash_handler.transfer_file(src, dst, "copy") + assert dst.read_bytes() == b"data" + assert src.exists() # seeding can continue + + +def test_transfer_hardlink_preserves_original(tmp_path): + src = tmp_path / "src.mp4" + src.write_bytes(b"data") + dst = tmp_path / "out" / "src.mp4" + stash_handler.transfer_file(src, dst, "hardlink") + assert dst.read_bytes() == b"data" + assert src.exists() + + +def test_transfer_move_removes_original(tmp_path): + src = tmp_path / "src.mp4" + src.write_bytes(b"data") + dst = tmp_path / "out" / "src.mp4" + stash_handler.transfer_file(src, dst, "move") + assert dst.read_bytes() == b"data" + assert not src.exists()