mirror of https://github.com/razor-ai/soup.git
429 lines
14 KiB
Python
429 lines
14 KiB
Python
"""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)
|