diff --git a/.gitattributes b/.gitattributes index 4cbaeb3..054a10b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,5 +14,5 @@ # committing it without the exclusion would force every `git clone` to # download 2.65 GB and spend the repo's LFS bandwidth quota. Fetch it via the # "BiRefNet setup" page in the app, or: -# git lfs pull --include="weights/birefnet_leaf.pth" +# git lfs pull -X "" -I "weights/birefnet_leaf.pth" weights/*.pth filter=lfs diff=lfs merge=lfs -text diff --git a/.lfsconfig b/.lfsconfig index b6799f7..639c41b 100644 --- a/.lfsconfig +++ b/.lfsconfig @@ -12,9 +12,9 @@ # Opt in via the "BiRefNet setup" page in the MATS app, `mats fetch-weights # --only birefnet --source lfs`, or by hand: # -# git lfs pull --include="weights/birefnet_leaf.pth" +# git lfs pull -X "" -I "weights/birefnet_leaf.pth" # -# Note that a bare `git lfs pull` will NOT fetch it -- only -I/--include -# overrides fetchexclude. That's deliberate: no accidental 2.65 GB pulls. -# [lfs] -# fetchexclude = weights/birefnet_leaf.pth +# A bare pull and an include-only pull both retain fetchexclude. `-X ""` +# clears that exclusion for this invocation; `-I` limits the pull to BiRefNet. +[lfs] + fetchexclude = weights/birefnet_leaf.pth diff --git a/AGENTS.md b/AGENTS.md index 374b870..ceec693 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,8 +75,9 @@ other system libraries (QR codes are decoded with OpenCV). > fresh checkout fails to detect markers, and it looks like a model problem > rather than a setup problem. Verify with `ls -l weights/rf_detr_marker.pth` > (~134 MB, not ~134 bytes) or `mats doctor`; repair with -> `git lfs install && git lfs pull --exclude="weights/birefnet_leaf.pth"` — the `--exclude` matters: a bare `git lfs pull` can also -> fetch the 2.65 GB BiRefNet checkpoint. +> `git lfs install && git lfs pull --exclude="weights/birefnet_leaf.pth"` — the `--exclude` keeps the repair to RF-DETR. With +> `.lfsconfig` active, a bare `git lfs pull` also leaves the 2.65 GB BiRefNet checkpoint out; fetch it only with +> `git lfs pull -X "" -I "weights/birefnet_leaf.pth"` (or `mats fetch-weights --only birefnet --source lfs`). ```bash git lfs install # one-time, per machine, BEFORE cloning diff --git a/README.md b/README.md index 013c6a1..dbe70a6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# MATS — Morphometric Analysis Toolbox +# MATS — Morphometric Analysis Toolbox for Segmentation Measure leaf **area, length, and width** in real-world units from a photo of leaves laid on a printed calibration template. @@ -68,8 +68,7 @@ ls -l weights/rf_detr_marker.pth **Already cloned without Git LFS?** No need to start over — install Git LFS as above, then repair the checkout in place. The `--exclude` keeps this to the -~134 MB RF-DETR file; a bare `git lfs pull` can also fetch the 2.65 GB BiRefNet -checkpoint: +~134 MB RF-DETR file, avoiding the optional 2.65 GB BiRefNet checkpoint: ```bash git lfs install && git lfs pull --exclude="weights/birefnet_leaf.pth" @@ -148,12 +147,14 @@ checkpoint or install the pyzbar/QReader robust-QR fallbacks. The required ~134 MB RF-DETR marker checkpoint is different: it is mandatory for every run, so it ships **in the clone** via Git LFS and needs no separate -download step. If it is ever missing — a clone made without Git LFS, or an -install outside a Git checkout — MATS fetches it once on first use and prints -`Fetching weights/rf_detr_marker.pth via Git LFS ...` while it does. Set -`MATS_NO_AUTO_FETCH=1` to turn that off and require pre-staged weights instead -(recommended on HPC login nodes). The app never does this silently: a missing -RF-DETR checkpoint is a blocking Preflight error. +download step. If it is missing from a Git checkout — for example, after cloning +without Git LFS — MATS can fetch it once on first use and prints +`Fetching weights/rf_detr_marker.pth via Git LFS ...` while it does. An install +outside a Git checkout must use a pre-staged checkpoint (or a separately +configured Hugging Face source). Set `MATS_NO_AUTO_FETCH=1` to turn automatic +fetching off and require pre-staged weights instead (recommended on HPC login +nodes). The app never does this silently: a missing RF-DETR checkpoint is a +blocking Preflight error. This keeps the initial network and disk footprint predictable, avoids native `zbar` failures on managed machines, and works better on HPC systems and diff --git a/deploy/ondemand/mats/README.md b/deploy/ondemand/mats/README.md index 36bd255..e5cb7a6 100644 --- a/deploy/ondemand/mats/README.md +++ b/deploy/ondemand/mats/README.md @@ -13,12 +13,16 @@ compute node and exposes it through the Open OnDemand reverse proxy. ``` Then set `CONDA_ENV` in `template/script.sh.erb` to that env name (`mats`). (A plain virtualenv works too — the default install needs no system libs.) -2. **The model checkpoints.** Fetch them once, ideally to a shared location: +2. **The model checkpoints.** Pre-stage them once in a shared location. From a + Git checkout with Git LFS installed, materialize the files, then copy them to + the shared directory: ```bash export MATS_WEIGHTS_DIR=/shared/models/mats - mats fetch-weights --all # RF-DETR + BiRefNet (the GUI defaults to Otsu, but a - # GPU-backed OOD app is the typical BiRefNet use case) - mats doctor # confirm they resolve + mkdir -p "$MATS_WEIGHTS_DIR" + mats fetch-weights --all # writes RF-DETR + BiRefNet to the checkout's weights/ + cp weights/rf_detr_marker.pth "$MATS_WEIGHTS_DIR/" + cp weights/birefnet_leaf.pth "$MATS_WEIGHTS_DIR/" + mats doctor # confirm they resolve ``` Point the same `MATS_WEIGHTS_DIR` at that path in `template/script.sh.erb`. 3. *(Optional)* Enhanced QR reading (`pip install -e ".[qr]"`) adds the `pyzbar` diff --git a/docs/cli.md b/docs/cli.md index 5e17aa1..149193b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -57,7 +57,9 @@ mats fetch-weights --only birefnet --source lfs # explicitly fetch just BiRefNet mats fetch-weights --force # re-download even if present ``` -Downloads to `~/.cache/mats/weights` (or `$MATS_WEIGHTS_DIR`). See +Git LFS downloads write to the Git checkout's `weights/` directory. +`MATS_WEIGHTS_DIR` is for pre-staged local or shared checkpoints; a configured +Hugging Face source may use it as its download destination. See [weights.md](weights.md). ## `mats doctor` diff --git a/docs/faq.md b/docs/faq.md index 545b86f..800d686 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -37,8 +37,13 @@ Repair an existing clone without re-cloning: git lfs install && git lfs pull --exclude="weights/birefnet_leaf.pth" ``` -The `--exclude` keeps the repair to the ~134 MB RF-DETR file; a bare -`git lfs pull` can also fetch the 2.65 GB BiRefNet checkpoint. +The `--exclude` keeps the repair to the ~134 MB RF-DETR file. With this +repository's `lfs.fetchexclude`, a bare `git lfs pull` also leaves the 2.65 GB +BiRefNet checkpoint out; fetch it explicitly only when needed: + +```bash +git lfs pull -X "" -I "weights/birefnet_leaf.pth" +``` **Do I need to download the model first?** No. The clone brings RF-DETR with it, so there is no separate download step — run `mats doctor` and you're done. (If diff --git a/docs/hpc.md b/docs/hpc.md index 69087b1..d8e6e03 100644 --- a/docs/hpc.md +++ b/docs/hpc.md @@ -12,15 +12,21 @@ conda env create -f environment.yml conda activate mats pip install -e ".[app]" export MATS_WEIGHTS_DIR=/project//mats_weights # shared, readable -mats fetch-weights --all # populate both checkpoints once, from a data-transfer node +mkdir -p "$MATS_WEIGHTS_DIR" +# Run from a Git checkout with Git LFS installed. It materializes both files +# in that checkout's weights/ directory; copy the verified files to /project. +mats fetch-weights --all +cp weights/rf_detr_marker.pth "$MATS_WEIGHTS_DIR/" +cp weights/birefnet_leaf.pth "$MATS_WEIGHTS_DIR/" mats doctor ``` On USDA **SCINet** (Ceres/Atlas), a `/project` directory is a mounted filesystem shared across the project, so every job reads the weights in place — no per-user -copy. Fetch them once to that path and point `MATS_WEIGHTS_DIR` at it for all -users. External collaborators without SCINet accounts can pull the same directory -via a **Globus guest collection** (they need a free Globus login). +copy. Materialize the weights once in a Git checkout, copy the verified files to +that path, and point `MATS_WEIGHTS_DIR` at it for all users. External collaborators +without SCINet accounts can pull the same directory via a **Globus guest collection** +(they need a free Globus login). Set `MATS_NO_AUTO_FETCH=1` in your jobs so a misconfigured path fails fast with a clear error instead of triggering a 2.65 GB download on a login or compute node diff --git a/docs/weights.md b/docs/weights.md index 6ac7f88..c0b1e08 100644 --- a/docs/weights.md +++ b/docs/weights.md @@ -58,9 +58,11 @@ mats doctor # show resolved paths, channels, and so ``` After a clone made with Git LFS installed, bare `mats fetch-weights` is a no-op -that prints "already present" — RF-DETR arrived with the checkout. The command -exists to repair a checkout made *without* Git LFS, and to populate a shared -`MATS_WEIGHTS_DIR`. +that prints "already present" — RF-DETR arrived with the checkout. The Git LFS +channel repairs a checkout made *without* Git LFS and always writes to that +checkout's `weights/` directory. `MATS_WEIGHTS_DIR` controls where MATS looks +for pre-staged files; provision a shared directory by copying verified +checkpoints there rather than expecting a Git LFS fetch to populate it. BiRefNet is never downloaded automatically. If it is absent when selected, MATs reports the missing local checkpoint and leaves Otsu fully usable. @@ -78,7 +80,7 @@ only option that needs no per-user download at all: ```bash export MATS_WEIGHTS_DIR=/project//mats_weights -mats fetch-weights --all # populates it once (from a data-transfer node) +# Pre-stage the verified checkpoint files in this directory. mats doctor # confirm it resolves ``` @@ -96,10 +98,12 @@ excluded from the default clone and from a bare `git lfs pull` by `.lfsconfig` place, not the 2.65 GB file. Pull it explicitly: ```bash -git lfs pull --include="weights/birefnet_leaf.pth" +git lfs pull -X "" -I "weights/birefnet_leaf.pth" ``` or `mats fetch-weights --only birefnet --source lfs`, or the setup page. +The empty `-X` value clears `.lfsconfig`'s exclusion for this invocation, and +`-I` limits the pull to the BiRefNet checkpoint. This exclusion exists because committing BiRefNet without it would force *every* `git clone` to download 2.65 GB and spend the repository's Git LFS @@ -111,8 +115,9 @@ If your Git LFS version predates the exclusion behavior (needs the `.lfsconfig` fetchexclude to be read from the repo index/HEAD during the initial clone — true for modern Git LFS), a clone could pull BiRefNet anyway. `GIT_LFS_SKIP_SMUDGE=1 git clone ...` is a guaranteed way to skip *all* LFS -content on clone if you want to be certain, then `git lfs pull --include=...` -each file you actually need. +content on clone if you want to be certain. Afterward, fetch RF-DETR with +`git lfs pull --exclude="weights/birefnet_leaf.pth"`; add BiRefNet later with +`git lfs pull -X "" -I "weights/birefnet_leaf.pth"` if needed. ## Manual / air-gapped diff --git a/src/mats/app/pages/2_BiRefNet_Setup.py b/src/mats/app/pages/2_BiRefNet_Setup.py index 9b16c69..3671d3e 100644 --- a/src/mats/app/pages/2_BiRefNet_Setup.py +++ b/src/mats/app/pages/2_BiRefNet_Setup.py @@ -126,7 +126,7 @@ def update(phase, completed, total): "place, no download for anyone who can mount it.\n" "- Or set `BIREFNET_CHECKPOINT` to an explicit checkpoint path.\n" "- Or, from a terminal in a Git checkout: " - "`git lfs pull --include=\"weights/birefnet_leaf.pth\"`.\n" + "`git lfs pull -X \"\" -I \"weights/birefnet_leaf.pth\"`.\n" "- On air-gapped systems, pre-stage the checkpoint and verify its SHA-256 before " "launching MATS." ) diff --git a/src/mats/weights.py b/src/mats/weights.py index ec40196..d0602e6 100644 --- a/src/mats/weights.py +++ b/src/mats/weights.py @@ -1,16 +1,17 @@ """Install, verify and resolve the MATs model checkpoints. The checkpoints are large (RF-DETR ~134 MB, BiRefNet ~2.65 GB) and are -delivered through two independent channels, plus a shared-filesystem escape -hatch -- :mod:`mats.paths` resolves to whichever channel produces a real file: - -1. **Hugging Face Hub** -- the default public host. Free, no account needed, - but unreachable on some institutional networks (notably USDA's). -2. **Git LFS** -- By default, MATS will only pull the RF-DETR checkpoint file - when fetching weights via ``git-lfs pull``. To pull the larger BiRefNet file, - you can run ``mats fetch-weights --only birefnet --source lfs`` or just - ``git-lfs pull``. This channel exists because Hugging Face is not reachable - from every collaborator's network. +delivered through Git LFS or an optional Hugging Face configuration, plus a +shared-filesystem escape hatch -- :mod:`mats.paths` resolves to whichever +channel produces a real file: + +1. **Hugging Face Hub** -- available only when a MATS weights repository is + configured. It is free and needs no account, but is unreachable on some + institutional networks (notably USDA's). +2. **Git LFS** -- The default clone and pull exclude BiRefNet, so RF-DETR is + available without an automatic 2.65 GB download. Install BiRefNet explicitly + with ``mats fetch-weights --only birefnet --source lfs``. This channel exists + because Hugging Face is not reachable from every collaborator's network. 3. **A shared/mounted filesystem** (e.g. USDA SCINet ``/project``) -- point ``MATS_WEIGHTS_DIR`` at it and the weights are read in place, no download, for anyone who can mount it. @@ -205,7 +206,17 @@ def get_weight_status(name): checkout = _checkout_target(name) if checkout is not None and looks_like_lfs_pointer(checkout): - detail = "Not yet fetched via Git LFS -- fetch it via the app, `mats fetch-weights --only birefnet` or Hugging Face." + if name == "birefnet": + detail = ( + "Excluded from the default Git LFS clone. Fetch it via the app or " + "`mats fetch-weights --only birefnet --source lfs`." + ) + else: + detail = ( + "Git LFS left an RF-DETR pointer instead of the checkpoint. Run " + "`git lfs install && git lfs pull --exclude=\"weights/birefnet_leaf.pth\"` " + "or `mats fetch-weights --only rf-detr --source lfs`." + ) return WeightStatus(name, checkout, "missing", detail, 0, spec["size_bytes"], sources) target = _download_target(name) @@ -220,18 +231,36 @@ def _emit(progress_callback, phase, completed, total): progress_callback(phase, completed, total) -def _manual_instructions(): +def _lfs_pull_args(name): + """Return a Git LFS pull command that fetches only the intended weights.""" + birefnet_rel = f"weights/{_MANIFEST['birefnet']['filename']}" + if name == "birefnet": + return ["git", "lfs", "pull", "-X", "", "-I", birefnet_rel] + return ["git", "lfs", "pull", "--exclude", birefnet_rel] + + +def _lfs_manual_command(name): + """Return the shell form of the checkpoint-specific Git LFS repair.""" + if name == "birefnet": + return 'git lfs pull -X "" -I "weights/birefnet_leaf.pth"' + return 'git lfs install && git lfs pull --exclude="weights/birefnet_leaf.pth"' + + +def _manual_instructions(name): + spec = _MANIFEST[name] + override = ( + "RF_DETR_MARKER_CHECKPOINT" if name == "rf-detr" else "BIREFNET_CHECKPOINT" + ) print( "No automatic download source is available in this build.\n\n" - "Get the checkpoints one of these ways:\n" - f" - Download them and place them here:\n" - f" {WEIGHTS_DIR / RF_DETR_MARKER_FILENAME}\n" - f" {WEIGHTS_DIR / BIREFNET_FILENAME}\n" - " - Or set MATS_WEIGHTS_DIR to a directory that already contains them\n" + f"Get {spec['filename']} one of these ways:\n" + " - Download it and place it here:\n" + f" {WEIGHTS_DIR / spec['filename']}\n" + " - Or set MATS_WEIGHTS_DIR to a directory that already contains it\n" " (e.g. a shared SCINet /project path).\n" - " - Or set RF_DETR_MARKER_CHECKPOINT / BIREFNET_CHECKPOINT to specific files.\n" + f" - Or set {override} to the specific file.\n" " - Or, from a Git checkout with Git LFS installed:\n" - " git lfs pull\n\n" + f" {_lfs_manual_command(name)}\n\n" "See docs/weights.md.", file=sys.stderr, ) @@ -334,11 +363,9 @@ def _emit_lfs_progress(progress_path, progress_callback, fallback_total, last_do def _download_from_lfs(name, progress_callback=None): """Fetch one checkpoint via Git LFS. - For BiRefNet, runs a plain ``git lfs pull`` (no flags) so the large - checkpoint is fetched without affecting other files. For all other - checkpoints, runs ``git lfs pull --exclude weights/birefnet_leaf.pth`` - so the 2.65 GB BiRefNet file is never pulled as a side-effect of an - unrelated weight update. + BiRefNet clears the repository exclusion for one invocation and includes + only its checkpoint. All other checkpoints explicitly exclude BiRefNet so + it is never pulled as a side effect of an unrelated weight update. Writes into the checkout's weights/ directory -- that's where Git LFS smudges content, and it's tier 3 of paths.py's resolution order, so the @@ -364,16 +391,10 @@ def _download_from_lfs(name, progress_callback=None): rel_path = f"weights/{spec['filename']}" print(f"Fetching {rel_path} via Git LFS -> {target}") - # For BiRefNet use a plain `git lfs pull` (no flags) -- without a - # fetchexclude in .lfsconfig a bare pull fetches all LFS files, which is - # what we want for this explicit opt-in download. - # For everything else, exclude the large BiRefNet checkpoint so it is - # never pulled as an unintended side-effect. - birefnet_rel = f"weights/{_MANIFEST['birefnet']['filename']}" - if name == "birefnet": - lfs_cmd = ["git", "lfs", "pull"] - else: - lfs_cmd = ["git", "lfs", "pull", "--exclude", birefnet_rel] + # `-X ""` clears .lfsconfig's exclusion for the explicit BiRefNet request; + # `-I` keeps that pull scoped to BiRefNet. Other requests explicitly + # exclude the large optional checkpoint under either repository setting. + lfs_cmd = _lfs_pull_args(name) with tempfile.TemporaryDirectory() as tmp: progress_path = Path(tmp) / "progress" @@ -495,7 +516,7 @@ def fetch(only=None, force=False, source="auto"): print(f"error: {by_id[source].label} is unavailable: {by_id[source].reason}", file=sys.stderr) else: - _manual_instructions() + _manual_instructions(name) ok = False continue @@ -530,13 +551,13 @@ def ensure_weight(name): raise FileNotFoundError( f"{spec['filename']} not found and auto-fetch is disabled " f"({_AUTO_FETCH_DISABLED} is set). Pre-stage the weights, or run " - f"`mats fetch-weights --only {name}` after unsetting {_AUTO_FETCH_DISABLED} " - f"(from a Git checkout, `git lfs pull` fetches all weights including birefnet, or " - f"`git lfs pull --exclude weights/{_MANIFEST['birefnet']['filename']}` fetches all others)." + f"`mats fetch-weights --only {name}` after unsetting " + f"{_AUTO_FETCH_DISABLED}. From a Git checkout with Git LFS, run " + f"`{_lfs_manual_command(name)}`." ) - # A pointer stub for BiRefNet means the user hasn't run `git lfs pull` - # for it yet (it's large and opt-in) -- fetch it rather than failing. + # A pointer stub means Git LFS has not materialized this checkpoint yet. + # Fetch the requested checkpoint rather than handing the stub to a model. checkout = _checkout_target(name) if checkout is not None and looks_like_lfs_pointer(checkout) and _download_from_lfs(name): return checkout @@ -570,8 +591,9 @@ def require_local_weight(name): ) raise FileNotFoundError( f"{_MANIFEST[name]['filename']} is not installed locally. " - f"BiRefNet is optional; install it explicitly with " - f"`mats fetch-weights --only {name} --source lfs`, or place it at {status.path}." + f"BiRefNet is optional; from a Git checkout with Git LFS, install it " + f"explicitly with `mats fetch-weights --only {name} --source lfs`, or " + f"place it at {status.path}." ) diff --git a/tests/test_home_app.py b/tests/test_home_app.py index c583e00..a39fe73 100644 --- a/tests/test_home_app.py +++ b/tests/test_home_app.py @@ -1,8 +1,10 @@ from pathlib import Path -import numpy as np import pytest +# Every heavy import is gated: CI installs with `--no-deps`, so a bare +# module-level `import numpy` here is a collection error, not a skip. +np = pytest.importorskip("numpy") pd = pytest.importorskip("pandas") pytest.importorskip("streamlit") cv2 = pytest.importorskip("cv2") diff --git a/tests/test_weights.py b/tests/test_weights.py index fd6ff01..bd6452e 100644 --- a/tests/test_weights.py +++ b/tests/test_weights.py @@ -76,12 +76,38 @@ def test_pointer_is_not_counted_present(monkeypatch, tmp_path): assert weights._is_present(tmp_path / "rf_detr_marker.pth") is False -def test_fetch_without_any_source_prints_manual(monkeypatch, tmp_path, capsys): +@pytest.mark.parametrize( + ("name", "filename", "override", "command", "forbidden"), + ( + ( + "rf-detr", + "rf_detr_marker.pth", + "RF_DETR_MARKER_CHECKPOINT", + 'git lfs install && git lfs pull --exclude="weights/birefnet_leaf.pth"', + 'git lfs pull -X "" -I', + ), + ( + "birefnet", + "birefnet_leaf.pth", + "BIREFNET_CHECKPOINT", + 'git lfs pull -X "" -I "weights/birefnet_leaf.pth"', + "git lfs install &&", + ), + ), +) +def test_fetch_without_any_source_prints_checkpoint_instructions( + monkeypatch, tmp_path, capsys, name, filename, override, command, forbidden +): weights = _fresh_weights(monkeypatch, tmp_path, MATS_WEIGHTS_DIR=str(tmp_path)) assert weights._HF_REPO_ID is None - code = weights.fetch() + code = weights.fetch(only=name) assert code == 1 - assert "No automatic download source" in capsys.readouterr().err + err = capsys.readouterr().err + assert "No automatic download source" in err + assert filename in err + assert override in err + assert command in err + assert forbidden not in err def test_ensure_weight_returns_present_file(monkeypatch, tmp_path): @@ -109,12 +135,34 @@ def test_require_local_weight_missing_never_attempts_fetch(monkeypatch, tmp_path weights.require_local_weight("birefnet") -def test_ensure_weight_honors_no_auto_fetch(monkeypatch, tmp_path): +@pytest.mark.parametrize( + ("name", "command", "forbidden"), + ( + ( + "rf-detr", + 'git lfs install && git lfs pull --exclude="weights/birefnet_leaf.pth"', + "--only birefnet", + ), + ( + "birefnet", + 'git lfs pull -X "" -I "weights/birefnet_leaf.pth"', + "--only rf-detr", + ), + ), +) +def test_ensure_weight_honors_no_auto_fetch( + monkeypatch, tmp_path, name, command, forbidden +): weights = _fresh_weights( monkeypatch, tmp_path, MATS_WEIGHTS_DIR=str(tmp_path), MATS_NO_AUTO_FETCH="1" ) - with pytest.raises(FileNotFoundError, match="auto-fetch is disabled"): - weights.ensure_weight("birefnet") + with pytest.raises(FileNotFoundError, match="auto-fetch is disabled") as exc_info: + weights.ensure_weight(name) + message = str(exc_info.value) + assert f"mats fetch-weights --only {name}" in message + assert "From a Git checkout with Git LFS" in message + assert command in message + assert forbidden not in message def test_ensure_weight_pulls_checkout_pointer_via_lfs(monkeypatch, tmp_path): @@ -199,30 +247,69 @@ def test_status_missing_for_excluded_checkout_pointer(monkeypatch, tmp_path): status = weights.get_weight_status("birefnet") assert status.state == "missing" assert "excluded" in status.detail.lower() + assert "--only birefnet --source lfs" in status.detail assert {s.id for s in status.sources} == {"hf", "lfs"} -def test_download_from_lfs_success(monkeypatch, tmp_path): +def test_status_rf_detr_pointer_recommends_rf_detr_repair(monkeypatch, tmp_path): weights = _fresh_weights(monkeypatch, tmp_path, MATS_WEIGHTS_DIR=str(tmp_path / "cache")) checkout = tmp_path / "checkout" (checkout / "weights").mkdir(parents=True) (checkout / ".git").mkdir() + (checkout / "weights" / "rf_detr_marker.pth").write_bytes(LFS_POINTER) monkeypatch.setattr(weights, "_REPO_ROOT", checkout) + + status = weights.get_weight_status("rf-detr") + + assert status.state == "missing" + assert "git lfs install" in status.detail + assert "--only rf-detr --source lfs" in status.detail + assert "--only birefnet" not in status.detail + + +@pytest.mark.parametrize( + ("name", "filename", "expected_args"), + ( + ( + "rf-detr", + "rf_detr_marker.pth", + ["git", "lfs", "pull", "--exclude", "weights/birefnet_leaf.pth"], + ), + ( + "birefnet", + "birefnet_leaf.pth", + ["git", "lfs", "pull", "-X", "", "-I", "weights/birefnet_leaf.pth"], + ), + ), +) +def test_download_from_lfs_success(monkeypatch, tmp_path, name, filename, expected_args): + weights = _fresh_weights(monkeypatch, tmp_path, MATS_WEIGHTS_DIR=str(tmp_path / "cache")) + checkout = tmp_path / "checkout" + (checkout / "weights").mkdir(parents=True) + (checkout / ".git").mkdir() + monkeypatch.setattr(weights, "_REPO_ROOT", checkout) + monkeypatch.setattr(weights, "_git_lfs_installed", lambda: True) monkeypatch.setattr(weights, "free_bytes", lambda path: 10 ** 12) real_bytes = b"\x80\x02" + b"x" * 4094 digest = hashlib.sha256(real_bytes).hexdigest() - monkeypatch.setitem(weights._MANIFEST["birefnet"], "size_bytes", len(real_bytes)) - monkeypatch.setitem(weights._MANIFEST["birefnet"], "sha256", digest) + monkeypatch.setitem(weights._MANIFEST[name], "size_bytes", len(real_bytes)) + monkeypatch.setitem(weights._MANIFEST[name], "sha256", digest) + calls = [] - def fake_popen(*args, **kwargs): - (checkout / "weights" / "birefnet_leaf.pth").write_bytes(real_bytes) + def fake_popen(args, **kwargs): + calls.append((args, kwargs)) + (checkout / "weights" / filename).write_bytes(real_bytes) return _FakeProcess(0) monkeypatch.setattr(weights.subprocess, "Popen", fake_popen) - assert weights._download_from_lfs("birefnet") is True - assert (checkout / "weights" / "birefnet_leaf.pth").read_bytes() == real_bytes + assert weights._download_from_lfs(name) is True + assert (checkout / "weights" / filename).read_bytes() == real_bytes + assert len(calls) == 1 + args, kwargs = calls[0] + assert args == expected_args + assert kwargs["cwd"] == checkout def test_download_from_lfs_failure(monkeypatch, tmp_path): @@ -231,6 +318,7 @@ def test_download_from_lfs_failure(monkeypatch, tmp_path): (checkout / "weights").mkdir(parents=True) (checkout / ".git").mkdir() monkeypatch.setattr(weights, "_REPO_ROOT", checkout) + monkeypatch.setattr(weights, "_git_lfs_installed", lambda: True) monkeypatch.setattr(weights, "free_bytes", lambda path: 10 ** 12) monkeypatch.setattr(weights.subprocess, "Popen", lambda *a, **k: _FakeProcess(1))