"""v0.57.0 Part D — adapters branch / checkout / branches list.""" from __future__ import annotations import json import os import re from pathlib import Path import pytest from typer.testing import CliRunner from soup_cli.cli import app as soup_app from soup_cli.utils.adapter_branch import ( Branch, create_branch, delete_branch, list_branches, load_branch, write_checkout, ) _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") def _strip_ansi(text: str) -> str: return _ANSI_RE.sub("", text or "") runner = CliRunner() @pytest.fixture(autouse=True) def isolated_branches_dir(tmp_path, monkeypatch): """Redirect SOUP_BRANCHES_DIR to tmp so tests don't pollute ~/.soup.""" branches = tmp_path / "branches" monkeypatch.setenv("SOUP_BRANCHES_DIR", str(branches)) monkeypatch.chdir(tmp_path) return branches def _make_config(tmp_path: Path, name: str = "soup.yaml", content: str = "base: test\n") -> Path: p = tmp_path / name p.write_text(content, encoding="utf-8") return p # ---------- create_branch ---------- def test_create_branch_happy(tmp_path): _make_config(tmp_path) snap = create_branch("v1", config_path="soup.yaml", base_model="meta/llama") assert isinstance(snap, Branch) assert snap.name == "v1" assert snap.base_model == "meta/llama" assert len(snap.config_sha256) == 64 assert snap.dataset_sha256 is None def test_create_branch_with_dataset(tmp_path): _make_config(tmp_path) (tmp_path / "data.jsonl").write_text('{"x": 1}\n', encoding="utf-8") snap = create_branch("v1", config_path="soup.yaml", base_model="b", dataset_path="data.jsonl") assert snap.dataset_sha256 is not None assert len(snap.dataset_sha256) == 64 def test_create_branch_invalid_name(tmp_path): _make_config(tmp_path) with pytest.raises(ValueError, match="must match"): create_branch("../etc", config_path="soup.yaml", base_model="b") with pytest.raises(ValueError, match="non-empty"): create_branch("", config_path="soup.yaml", base_model="b") def test_create_branch_null_byte_name(tmp_path): _make_config(tmp_path) with pytest.raises(ValueError, match="null"): create_branch("v\x001", config_path="soup.yaml", base_model="b") def test_create_branch_bool_name(tmp_path): _make_config(tmp_path) with pytest.raises(TypeError): create_branch(True, config_path="soup.yaml", base_model="b") # type: ignore[arg-type] def test_create_branch_config_outside_cwd(tmp_path): outside = os.path.join(os.path.dirname(str(tmp_path)), "x.yaml") with pytest.raises(ValueError): create_branch("v1", config_path=outside, base_model="b") def test_create_branch_missing_config(tmp_path): with pytest.raises(FileNotFoundError): create_branch("v1", config_path="missing.yaml", base_model="b") def test_create_branch_empty_base_model(tmp_path): _make_config(tmp_path) with pytest.raises(ValueError, match="non-empty"): create_branch("v1", config_path="soup.yaml", base_model="") def test_create_branch_null_byte_base_model(tmp_path): _make_config(tmp_path) with pytest.raises(ValueError, match="null"): create_branch("v1", config_path="soup.yaml", base_model="x\x00") def test_create_branch_bool_base_model_rejected(tmp_path): """bool subclasses str-checks in some idioms; ensure explicit TypeError.""" _make_config(tmp_path) with pytest.raises(TypeError): create_branch("v1", config_path="soup.yaml", base_model=True) # type: ignore[arg-type] def test_delete_branch_traversal_rejected(): with pytest.raises(ValueError): delete_branch("../etc/passwd") @pytest.mark.skipif(os.name == "nt", reason="POSIX-only symlink semantics") def test_load_branch_rejects_symlink(tmp_path, monkeypatch): branches = tmp_path / "br" branches.mkdir() monkeypatch.setenv("SOUP_BRANCHES_DIR", str(branches)) monkeypatch.chdir(tmp_path) target = tmp_path / "real.json" target.write_text("{}", encoding="utf-8") os.symlink(str(target), str(branches / "evil.json")) with pytest.raises(ValueError, match="symlink"): load_branch("evil") @pytest.mark.skipif(os.name == "nt", reason="POSIX-only symlink semantics") def test_delete_branch_rejects_symlink(tmp_path, monkeypatch): branches = tmp_path / "br" branches.mkdir() monkeypatch.setenv("SOUP_BRANCHES_DIR", str(branches)) monkeypatch.chdir(tmp_path) target = tmp_path / "real.json" target.write_text("{}", encoding="utf-8") os.symlink(str(target), str(branches / "evil.json")) with pytest.raises(ValueError, match="symlink"): delete_branch("evil") # original file untouched assert target.exists() def test_branches_dir_crlf_env_rejected(monkeypatch, tmp_path): """Control char (CRLF) in SOUP_BRANCHES_DIR falls back to default. Some CI environments also reject newlines in env values; stub the lookup to avoid platform-specific syscall validation differences. """ import os as _os from soup_cli.utils.adapter_branch import _branches_dir original = _os.environ.get def fake_get(key, default=None): if key == "SOUP_BRANCHES_DIR": return "/some\npath" return original(key, default) monkeypatch.setattr(_os.environ, "get", fake_get) monkeypatch.chdir(tmp_path) resolved = _branches_dir() assert "\n" not in str(resolved) def test_create_branch_oversize_config(tmp_path): # 1 MiB + 1 byte (tmp_path / "soup.yaml").write_bytes(b"x" * (1_048_577)) with pytest.raises(ValueError, match="cap"): create_branch("v1", config_path="soup.yaml", base_model="b") def test_create_branch_atomic_write(tmp_path, isolated_branches_dir): _make_config(tmp_path) snap = create_branch("v1", config_path="soup.yaml", base_model="b") target = isolated_branches_dir / "v1.json" assert target.exists() raw = json.loads(target.read_text(encoding="utf-8")) assert raw["name"] == "v1" assert raw["config_sha256"] == snap.config_sha256 # ---------- list_branches ---------- def test_list_branches_empty(tmp_path): assert list_branches() == () def test_list_branches_sorted(tmp_path): _make_config(tmp_path) create_branch("b", config_path="soup.yaml", base_model="x") create_branch("a", config_path="soup.yaml", base_model="x") create_branch("c", config_path="soup.yaml", base_model="x") assert list_branches() == ("a", "b", "c") # ---------- load_branch ---------- def test_load_branch_roundtrip(tmp_path): _make_config(tmp_path) created = create_branch("v1", config_path="soup.yaml", base_model="m") loaded = load_branch("v1") assert loaded.name == created.name assert loaded.config_sha256 == created.config_sha256 assert loaded.base_model == created.base_model def test_load_branch_missing(tmp_path): with pytest.raises(FileNotFoundError): load_branch("nope") def test_load_branch_invalid_name(): with pytest.raises(ValueError): load_branch("../etc") # ---------- delete_branch ---------- def test_delete_branch_true_when_present(tmp_path): _make_config(tmp_path) create_branch("v1", config_path="soup.yaml", base_model="m") assert delete_branch("v1") is True assert "v1" not in list_branches() def test_delete_branch_false_when_missing(): assert delete_branch("nope") is False # ---------- write_checkout ---------- def test_write_checkout_writes_target(tmp_path): _make_config(tmp_path, content="base: original\n") snap = create_branch("v1", config_path="soup.yaml", base_model="m") written = write_checkout(snap, "restored.yaml") assert written.exists() assert "original" in written.read_text(encoding="utf-8") def test_write_checkout_detects_drift(tmp_path): _make_config(tmp_path, content="base: original\n") snap = create_branch("v1", config_path="soup.yaml", base_model="m") # Mutate original config — SHA mismatch (tmp_path / "soup.yaml").write_text("base: drifted\n", encoding="utf-8") with pytest.raises(ValueError, match="drifted"): write_checkout(snap, "restored.yaml") def test_write_checkout_outside_cwd_rejected(tmp_path): _make_config(tmp_path) snap = create_branch("v1", config_path="soup.yaml", base_model="m") outside = os.path.join(os.path.dirname(str(tmp_path)), "x.yaml") with pytest.raises(ValueError): write_checkout(snap, outside) def test_write_checkout_rejects_non_branch(): with pytest.raises(TypeError): write_checkout("not a branch", "out.yaml") # type: ignore[arg-type] def test_write_checkout_missing_source(tmp_path): _make_config(tmp_path) snap = create_branch("v1", config_path="soup.yaml", base_model="m") (tmp_path / "soup.yaml").unlink() with pytest.raises(FileNotFoundError): write_checkout(snap, "restored.yaml") # ---------- Frozen Branch ---------- def test_branch_frozen(tmp_path): import dataclasses _make_config(tmp_path) snap = create_branch("v1", config_path="soup.yaml", base_model="m") with pytest.raises(dataclasses.FrozenInstanceError): snap.name = "v2" # type: ignore[misc] # ---------- Env override containment ---------- def test_branches_dir_env_override_outside_falls_back(monkeypatch, tmp_path): """Out-of-bounds env override is rejected; helper returns the in-bounds default. We point SOUP_BRANCHES_DIR at a tmp-rooted path that IS in-bounds (under $TMPDIR per the containment policy) so we can also assert that valid overrides ARE honoured — i.e. we test both directions of the policy here. """ from soup_cli.utils.adapter_branch import _branches_dir in_bounds = tmp_path / "valid-override" monkeypatch.setenv("SOUP_BRANCHES_DIR", str(in_bounds)) monkeypatch.chdir(tmp_path) resolved = _branches_dir() assert os.path.realpath(str(resolved)) == os.path.realpath(str(in_bounds)) def test_branches_dir_null_byte_env_ignored(monkeypatch, tmp_path): """Null-byte env override is silently ignored; default ~/.soup/branches used. POSIX ``setenv`` (and the Windows equivalent) reject null bytes at the syscall boundary, so we can't reproduce a real null-byte env via monkeypatch.setenv. Stub ``os.environ.get`` instead so the helper's rejection branch is exercised exactly as it would be if the env var arrived through some other channel. """ import os as _os from soup_cli.utils.adapter_branch import _branches_dir original = _os.environ.get def fake_get(key, default=None): if key == "SOUP_BRANCHES_DIR": return "/some\x00path" return original(key, default) monkeypatch.setattr(_os.environ, "get", fake_get) monkeypatch.chdir(tmp_path) resolved = _branches_dir() # Falls back to ~/.soup/branches — must NOT contain a null byte assert "\x00" not in str(resolved) assert ".soup" in str(resolved) and "branches" in str(resolved) # ---------- CLI smoke ---------- def test_branch_cli_help(): result = runner.invoke(soup_app, ["adapters", "branch", "--help"]) assert result.exit_code == 0, (result.output, repr(result.exception)) def test_checkout_cli_help(): result = runner.invoke(soup_app, ["adapters", "checkout", "--help"]) assert result.exit_code == 0, (result.output, repr(result.exception)) def test_branches_cli_help(): result = runner.invoke(soup_app, ["adapters", "branches", "--help"]) assert result.exit_code == 0, (result.output, repr(result.exception)) def test_branch_cli_create_and_list(tmp_path, monkeypatch): monkeypatch.setenv("SOUP_BRANCHES_DIR", str(tmp_path / "br")) monkeypatch.chdir(tmp_path) _make_config(tmp_path) r1 = runner.invoke(soup_app, [ "adapters", "branch", "v1", "-c", "soup.yaml", "--base", "meta/llama", ]) assert r1.exit_code == 0, (r1.output, repr(r1.exception)) assert "v1" in r1.output r2 = runner.invoke(soup_app, ["adapters", "branches"]) assert r2.exit_code == 0, (r2.output, repr(r2.exception)) assert "v1" in r2.output def test_branch_cli_invalid_name(tmp_path, monkeypatch): monkeypatch.setenv("SOUP_BRANCHES_DIR", str(tmp_path / "br")) monkeypatch.chdir(tmp_path) _make_config(tmp_path) result = runner.invoke(soup_app, [ "adapters", "branch", "../etc", "-c", "soup.yaml", "--base", "m", ]) assert result.exit_code == 2 assert "must match" in _strip_ansi(result.output) def test_branch_cli_missing_config(tmp_path, monkeypatch): monkeypatch.setenv("SOUP_BRANCHES_DIR", str(tmp_path / "br")) monkeypatch.chdir(tmp_path) result = runner.invoke(soup_app, [ "adapters", "branch", "v1", "-c", "missing.yaml", "--base", "m", ]) assert result.exit_code == 1 assert "not found" in _strip_ansi(result.output) def test_checkout_cli_roundtrip(tmp_path, monkeypatch): monkeypatch.setenv("SOUP_BRANCHES_DIR", str(tmp_path / "br")) monkeypatch.chdir(tmp_path) _make_config(tmp_path, content="base: x\nepochs: 1\n") create_branch("v1", config_path="soup.yaml", base_model="m") result = runner.invoke(soup_app, [ "adapters", "checkout", "v1", "-o", "restored.yaml", ]) assert result.exit_code == 0, (result.output, repr(result.exception)) assert (tmp_path / "restored.yaml").exists() def test_checkout_cli_missing_branch(tmp_path, monkeypatch): monkeypatch.setenv("SOUP_BRANCHES_DIR", str(tmp_path / "br")) monkeypatch.chdir(tmp_path) result = runner.invoke(soup_app, [ "adapters", "checkout", "nope", "-o", "out.yaml", ]) assert result.exit_code == 1 assert "not found" in _strip_ansi(result.output) def test_branches_cli_empty(tmp_path, monkeypatch): monkeypatch.setenv("SOUP_BRANCHES_DIR", str(tmp_path / "br")) monkeypatch.chdir(tmp_path) result = runner.invoke(soup_app, ["adapters", "branches"]) assert result.exit_code == 0, (result.output, repr(result.exception)) assert "No branches" in _strip_ansi(result.output)