mirror of https://github.com/razor-ai/soup.git
735 lines
25 KiB
Python
735 lines
25 KiB
Python
"""v0.69.0 Part A — `soup build` dbt-for-SFT DAG parser + topo sort + plan."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
from soup_cli.utils import build_dag
|
|
|
|
|
|
def _write(path: Path, text: str) -> Path:
|
|
path.write_text(text, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Allowlist + immutability
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class TestSupportedKinds:
|
|
def test_supported_kinds_exact(self) -> None:
|
|
assert build_dag.SUPPORTED_MODEL_KINDS == frozenset(
|
|
{"incremental", "table", "view"}
|
|
)
|
|
|
|
def test_supported_kinds_is_frozenset(self) -> None:
|
|
assert isinstance(build_dag.SUPPORTED_MODEL_KINDS, frozenset)
|
|
|
|
def test_supported_kinds_immutable(self) -> None:
|
|
with pytest.raises(AttributeError):
|
|
build_dag.SUPPORTED_MODEL_KINDS.add("evil") # type: ignore[attr-defined]
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# validate_model_kind
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateModelKind:
|
|
def test_happy_path(self) -> None:
|
|
assert build_dag.validate_model_kind("incremental") == "incremental"
|
|
assert build_dag.validate_model_kind("table") == "table"
|
|
assert build_dag.validate_model_kind("view") == "view"
|
|
|
|
def test_case_insensitive(self) -> None:
|
|
assert build_dag.validate_model_kind("Incremental") == "incremental"
|
|
assert build_dag.validate_model_kind("VIEW") == "view"
|
|
|
|
def test_unknown(self) -> None:
|
|
with pytest.raises(ValueError, match="unknown model kind"):
|
|
build_dag.validate_model_kind("ephemeral")
|
|
|
|
def test_non_string(self) -> None:
|
|
with pytest.raises(TypeError):
|
|
build_dag.validate_model_kind(123)
|
|
|
|
def test_bool_rejected(self) -> None:
|
|
with pytest.raises(TypeError):
|
|
build_dag.validate_model_kind(True)
|
|
|
|
def test_empty(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
build_dag.validate_model_kind("")
|
|
|
|
def test_null_byte(self) -> None:
|
|
with pytest.raises(ValueError, match="null"):
|
|
build_dag.validate_model_kind("incremental\x00x")
|
|
|
|
def test_oversize(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
build_dag.validate_model_kind("x" * 100)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# validate_model_name
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateModelName:
|
|
def test_happy(self) -> None:
|
|
assert build_dag.validate_model_name("raw_chat") == "raw_chat"
|
|
assert build_dag.validate_model_name("step-1") == "step-1"
|
|
assert build_dag.validate_model_name("a.b") == "a.b"
|
|
|
|
def test_non_string(self) -> None:
|
|
with pytest.raises(TypeError):
|
|
build_dag.validate_model_name(42)
|
|
|
|
def test_bool_rejected(self) -> None:
|
|
with pytest.raises(TypeError):
|
|
build_dag.validate_model_name(True)
|
|
|
|
def test_empty(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
build_dag.validate_model_name("")
|
|
|
|
def test_null_byte(self) -> None:
|
|
with pytest.raises(ValueError, match="null"):
|
|
build_dag.validate_model_name("a\x00b")
|
|
|
|
def test_path_traversal(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
build_dag.validate_model_name("../etc")
|
|
|
|
def test_path_separator(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
build_dag.validate_model_name("a/b")
|
|
|
|
def test_oversize(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
build_dag.validate_model_name("a" * 200)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# BuildModel frozen + validation
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildModel:
|
|
def test_happy(self) -> None:
|
|
model = build_dag.BuildModel(
|
|
name="raw",
|
|
kind="incremental",
|
|
transform="identity",
|
|
refs=(),
|
|
source="data/raw.jsonl",
|
|
config={},
|
|
)
|
|
assert model.name == "raw"
|
|
assert model.kind == "incremental"
|
|
assert model.refs == ()
|
|
|
|
def test_frozen(self) -> None:
|
|
model = build_dag.BuildModel(
|
|
name="raw",
|
|
kind="incremental",
|
|
transform="identity",
|
|
refs=(),
|
|
source="data/raw.jsonl",
|
|
config={},
|
|
)
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
model.name = "evil" # type: ignore[misc]
|
|
|
|
def test_refs_must_be_tuple(self) -> None:
|
|
with pytest.raises(TypeError, match="tuple"):
|
|
build_dag.BuildModel(
|
|
name="x",
|
|
kind="incremental",
|
|
transform="identity",
|
|
refs=["y"], # type: ignore[arg-type]
|
|
source=None,
|
|
config={},
|
|
)
|
|
|
|
def test_invalid_kind_propagates(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
build_dag.BuildModel(
|
|
name="raw",
|
|
kind="ephemeral",
|
|
transform="identity",
|
|
refs=(),
|
|
source=None,
|
|
config={},
|
|
)
|
|
|
|
def test_invalid_name_propagates(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
build_dag.BuildModel(
|
|
name="../etc",
|
|
kind="incremental",
|
|
transform="identity",
|
|
refs=(),
|
|
source=None,
|
|
config={},
|
|
)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Plan parsing + topo sort
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseBuildPlan:
|
|
def test_happy_linear(self) -> None:
|
|
raw = {
|
|
"models": [
|
|
{
|
|
"name": "raw",
|
|
"kind": "incremental",
|
|
"source": "data/raw.jsonl",
|
|
"transform": "identity",
|
|
},
|
|
{
|
|
"name": "filtered",
|
|
"kind": "incremental",
|
|
"refs": ["raw"],
|
|
"transform": "filter_low_quality",
|
|
},
|
|
{
|
|
"name": "tokenized",
|
|
"kind": "incremental",
|
|
"refs": ["filtered"],
|
|
"transform": "tokenize",
|
|
},
|
|
]
|
|
}
|
|
plan = build_dag.parse_build_plan(raw)
|
|
assert plan.topo_order == ("raw", "filtered", "tokenized")
|
|
assert len(plan.models) == 3
|
|
|
|
def test_happy_diamond(self) -> None:
|
|
raw = {
|
|
"models": [
|
|
{
|
|
"name": "a",
|
|
"kind": "incremental",
|
|
"source": "data/seed.jsonl",
|
|
"transform": "x",
|
|
},
|
|
{"name": "b", "kind": "incremental", "refs": ["a"], "transform": "x"},
|
|
{"name": "c", "kind": "incremental", "refs": ["a"], "transform": "x"},
|
|
{
|
|
"name": "d",
|
|
"kind": "incremental",
|
|
"refs": ["b", "c"],
|
|
"transform": "x",
|
|
},
|
|
]
|
|
}
|
|
plan = build_dag.parse_build_plan(raw)
|
|
# a must come first, d must come last
|
|
assert plan.topo_order[0] == "a"
|
|
assert plan.topo_order[-1] == "d"
|
|
|
|
def test_non_dict(self) -> None:
|
|
with pytest.raises(TypeError):
|
|
build_dag.parse_build_plan(["models"]) # type: ignore[arg-type]
|
|
|
|
def test_empty_models_rejected(self) -> None:
|
|
with pytest.raises(ValueError, match="non-empty"):
|
|
build_dag.parse_build_plan({"models": []})
|
|
|
|
def test_missing_models_key(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
build_dag.parse_build_plan({})
|
|
|
|
def test_models_not_list(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
build_dag.parse_build_plan({"models": "raw_chat"})
|
|
|
|
def test_cycle_rejected(self) -> None:
|
|
# Both have refs → no source; cycle rejection fires inside topo sort.
|
|
raw = {
|
|
"models": [
|
|
{"name": "a", "kind": "incremental", "refs": ["b"], "transform": "x"},
|
|
{"name": "b", "kind": "incremental", "refs": ["a"], "transform": "x"},
|
|
]
|
|
}
|
|
with pytest.raises(ValueError, match="cycle"):
|
|
build_dag.parse_build_plan(raw)
|
|
|
|
def test_self_loop_rejected(self) -> None:
|
|
# refs=["a"] but self-ref also implies refs (not seed); cross-validator
|
|
# accepts (refs set + source None), self-loop fires in second pass.
|
|
raw = {
|
|
"models": [
|
|
{"name": "a", "kind": "incremental", "refs": ["a"], "transform": "x"},
|
|
]
|
|
}
|
|
with pytest.raises(ValueError, match="self"):
|
|
build_dag.parse_build_plan(raw)
|
|
|
|
def test_dangling_ref_rejected(self) -> None:
|
|
raw = {
|
|
"models": [
|
|
{
|
|
"name": "a",
|
|
"kind": "incremental",
|
|
"refs": ["missing"],
|
|
"transform": "x",
|
|
},
|
|
]
|
|
}
|
|
with pytest.raises(ValueError, match="missing|unknown|not found"):
|
|
build_dag.parse_build_plan(raw)
|
|
|
|
def test_duplicate_name_rejected(self) -> None:
|
|
# Duplicate-name check fires BEFORE BuildModel construction, so the
|
|
# seed/derived cross-validator is not reached. Use minimal shape.
|
|
raw = {
|
|
"models": [
|
|
{
|
|
"name": "a",
|
|
"kind": "incremental",
|
|
"source": "data/raw.jsonl",
|
|
"transform": "x",
|
|
},
|
|
{
|
|
"name": "a",
|
|
"kind": "table",
|
|
"source": "data/raw.jsonl",
|
|
"transform": "x",
|
|
},
|
|
]
|
|
}
|
|
with pytest.raises(ValueError, match="duplicate"):
|
|
build_dag.parse_build_plan(raw)
|
|
|
|
def test_oversize_models(self) -> None:
|
|
# Many-model cap fires BEFORE BuildModel construction.
|
|
raw = {
|
|
"models": [
|
|
{
|
|
"name": f"m{i}",
|
|
"kind": "incremental",
|
|
"source": "data/raw.jsonl",
|
|
"transform": "x",
|
|
}
|
|
for i in range(build_dag._MAX_MODELS + 1)
|
|
]
|
|
}
|
|
with pytest.raises(ValueError, match="exceeds"):
|
|
build_dag.parse_build_plan(raw)
|
|
|
|
def test_duplicate_ref_in_same_model_rejected(self) -> None:
|
|
raw = {
|
|
"models": [
|
|
{
|
|
"name": "a",
|
|
"kind": "incremental",
|
|
"source": "data/raw.jsonl",
|
|
"transform": "x",
|
|
},
|
|
{
|
|
"name": "b",
|
|
"kind": "incremental",
|
|
"refs": ["a", "a"],
|
|
"transform": "x",
|
|
},
|
|
]
|
|
}
|
|
with pytest.raises(ValueError, match="duplicate"):
|
|
build_dag.parse_build_plan(raw)
|
|
|
|
def test_seed_without_source_rejected(self) -> None:
|
|
# New cross-validator: refs=() + source=None is degenerate.
|
|
raw = {
|
|
"models": [
|
|
{"name": "a", "kind": "incremental", "transform": "x"},
|
|
]
|
|
}
|
|
with pytest.raises(ValueError, match="source"):
|
|
build_dag.parse_build_plan(raw)
|
|
|
|
def test_refs_and_source_mutually_exclusive(self) -> None:
|
|
# New cross-validator: refs + source together rejected.
|
|
raw = {
|
|
"models": [
|
|
{
|
|
"name": "a",
|
|
"kind": "incremental",
|
|
"source": "data/raw.jsonl",
|
|
"transform": "x",
|
|
},
|
|
{
|
|
"name": "b",
|
|
"kind": "incremental",
|
|
"refs": ["a"],
|
|
"source": "data/other.jsonl",
|
|
"transform": "x",
|
|
},
|
|
]
|
|
}
|
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
|
build_dag.parse_build_plan(raw)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# parse_build_yaml + load_build_yaml
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseBuildYaml:
|
|
def test_happy(self) -> None:
|
|
text = (
|
|
"models:\n"
|
|
" - name: raw\n"
|
|
" kind: incremental\n"
|
|
" source: data/raw.jsonl\n"
|
|
" transform: identity\n"
|
|
" - name: out\n"
|
|
" kind: incremental\n"
|
|
" refs: [raw]\n"
|
|
" transform: identity\n"
|
|
)
|
|
plan = build_dag.parse_build_yaml(text)
|
|
assert plan.topo_order == ("raw", "out")
|
|
|
|
def test_invalid_yaml(self) -> None:
|
|
with pytest.raises(ValueError, match="invalid YAML"):
|
|
build_dag.parse_build_yaml("models: [unclosed")
|
|
|
|
def test_non_string(self) -> None:
|
|
with pytest.raises(TypeError):
|
|
build_dag.parse_build_yaml(42) # type: ignore[arg-type]
|
|
|
|
def test_null_byte(self) -> None:
|
|
with pytest.raises(ValueError, match="null"):
|
|
build_dag.parse_build_yaml("models:\x00")
|
|
|
|
def test_oversize(self) -> None:
|
|
text = "models:\n" + (" - name: x\n kind: incremental\n transform: y\n" * 100000)
|
|
with pytest.raises(ValueError, match="exceeds"):
|
|
build_dag.parse_build_yaml(text)
|
|
|
|
|
|
class TestLoadBuildYaml:
|
|
def test_happy(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
text = (
|
|
"models:\n"
|
|
" - name: raw\n"
|
|
" kind: incremental\n"
|
|
" source: data/raw.jsonl\n"
|
|
" transform: identity\n"
|
|
)
|
|
path = _write(tmp_path / "build.yaml", text)
|
|
plan = build_dag.load_build_yaml(str(path))
|
|
assert plan.topo_order == ("raw",)
|
|
|
|
def test_outside_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
_write(outside / "build.yaml", "models: []")
|
|
sub = tmp_path / "sub"
|
|
sub.mkdir()
|
|
monkeypatch.chdir(sub)
|
|
with pytest.raises(ValueError, match="cwd"):
|
|
build_dag.load_build_yaml(str(outside / "build.yaml"))
|
|
|
|
def test_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(FileNotFoundError):
|
|
build_dag.load_build_yaml(str(tmp_path / "nope.yaml"))
|
|
|
|
def test_null_byte(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(ValueError, match="null"):
|
|
build_dag.load_build_yaml("a\x00b.yaml")
|
|
|
|
def test_empty_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(ValueError):
|
|
build_dag.load_build_yaml("")
|
|
|
|
def test_non_string(self) -> None:
|
|
with pytest.raises((TypeError, ValueError)):
|
|
build_dag.load_build_yaml(42) # type: ignore[arg-type]
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink")
|
|
def test_symlink_rejected(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
target = _write(
|
|
tmp_path / "real.yaml",
|
|
"models:\n - name: a\n kind: incremental\n transform: x\n",
|
|
)
|
|
link = tmp_path / "link.yaml"
|
|
os.symlink(str(target), str(link))
|
|
with pytest.raises(ValueError, match="symlink"):
|
|
build_dag.load_build_yaml(str(link))
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Incremental diff (re-tokenize only changed rows)
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class TestRowHash:
|
|
def test_deterministic(self) -> None:
|
|
row = {"id": "1", "text": "hello"}
|
|
assert build_dag.compute_row_hash(row) == build_dag.compute_row_hash(row)
|
|
|
|
def test_key_order_independent(self) -> None:
|
|
a = {"id": "1", "text": "hello"}
|
|
b = {"text": "hello", "id": "1"}
|
|
assert build_dag.compute_row_hash(a) == build_dag.compute_row_hash(b)
|
|
|
|
def test_content_sensitive(self) -> None:
|
|
a = {"id": "1", "text": "hello"}
|
|
b = {"id": "1", "text": "world"}
|
|
assert build_dag.compute_row_hash(a) != build_dag.compute_row_hash(b)
|
|
|
|
def test_non_dict(self) -> None:
|
|
with pytest.raises(TypeError):
|
|
build_dag.compute_row_hash([1, 2]) # type: ignore[arg-type]
|
|
|
|
|
|
class TestIncrementalDiff:
|
|
def test_all_new(self) -> None:
|
|
new = [{"id": "1", "text": "a"}, {"id": "2", "text": "b"}]
|
|
report = build_dag.incremental_diff([], new)
|
|
assert report.added == 2
|
|
assert report.changed == 0
|
|
assert report.removed == 0
|
|
assert report.unchanged == 0
|
|
|
|
def test_all_unchanged(self) -> None:
|
|
rows = [{"id": "1", "text": "a"}, {"id": "2", "text": "b"}]
|
|
report = build_dag.incremental_diff(rows, rows)
|
|
assert report.unchanged == 2
|
|
assert report.added == 0
|
|
assert report.changed == 0
|
|
assert report.removed == 0
|
|
|
|
def test_changed_row(self) -> None:
|
|
prev = [{"id": "1", "text": "a"}]
|
|
new = [{"id": "1", "text": "b"}]
|
|
report = build_dag.incremental_diff(prev, new)
|
|
assert report.changed == 1
|
|
assert report.added == 0
|
|
|
|
def test_removed_row(self) -> None:
|
|
prev = [{"id": "1", "text": "a"}, {"id": "2", "text": "b"}]
|
|
new = [{"id": "1", "text": "a"}]
|
|
report = build_dag.incremental_diff(prev, new)
|
|
assert report.removed == 1
|
|
assert report.unchanged == 1
|
|
|
|
def test_missing_id_field(self) -> None:
|
|
with pytest.raises(ValueError, match="id"):
|
|
build_dag.incremental_diff([{"text": "a"}], [])
|
|
|
|
def test_frozen_report(self) -> None:
|
|
report = build_dag.incremental_diff([], [])
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
report.added = 999 # type: ignore[misc]
|
|
|
|
|
|
class TestBuildPlanFrozen:
|
|
"""TDD review #12 — BuildPlan dataclass must be immutable."""
|
|
|
|
def test_frozen(self) -> None:
|
|
raw = {
|
|
"models": [
|
|
{
|
|
"name": "a",
|
|
"kind": "incremental",
|
|
"source": "data/raw.jsonl",
|
|
"transform": "x",
|
|
}
|
|
]
|
|
}
|
|
plan = build_dag.parse_build_plan(raw)
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
plan.topo_order = ("evil",) # type: ignore[misc]
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Plan rendering (dry-run output)
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class TestRenderPlanTable:
|
|
def test_happy(self) -> None:
|
|
raw = {
|
|
"models": [
|
|
{
|
|
"name": "raw",
|
|
"kind": "incremental",
|
|
"source": "data/raw.jsonl",
|
|
"transform": "identity",
|
|
},
|
|
{
|
|
"name": "filtered",
|
|
"kind": "incremental",
|
|
"refs": ["raw"],
|
|
"transform": "filter",
|
|
},
|
|
]
|
|
}
|
|
plan = build_dag.parse_build_plan(raw)
|
|
rendered = build_dag.render_plan_table(plan)
|
|
assert "raw" in rendered
|
|
assert "filtered" in rendered
|
|
# topo order preserved
|
|
assert rendered.index("raw") < rendered.index("filtered")
|
|
|
|
def test_non_plan_type(self) -> None:
|
|
with pytest.raises(TypeError):
|
|
build_dag.render_plan_table({"models": []}) # type: ignore[arg-type]
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# run_build (deferred-live)
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class TestRunBuild:
|
|
def test_deferred(self) -> None:
|
|
raw = {
|
|
"models": [
|
|
{
|
|
"name": "raw",
|
|
"kind": "incremental",
|
|
"source": "data/raw.jsonl",
|
|
"transform": "identity",
|
|
},
|
|
]
|
|
}
|
|
plan = build_dag.parse_build_plan(raw)
|
|
with pytest.raises(NotImplementedError, match="v0.69.1"):
|
|
build_dag.run_build(plan)
|
|
|
|
def test_run_build_validates_plan_type(self) -> None:
|
|
with pytest.raises(TypeError):
|
|
build_dag.run_build({"models": []}) # type: ignore[arg-type]
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# CLI: `soup build`
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class TestSoupBuildCli:
|
|
def test_help(self) -> None:
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["build", "--help"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "build" in result.output.lower()
|
|
|
|
def test_dry_run_happy(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
path = _write(
|
|
tmp_path / "build.yaml",
|
|
"models:\n"
|
|
" - name: raw\n"
|
|
" kind: incremental\n"
|
|
" source: data/raw.jsonl\n"
|
|
" transform: identity\n"
|
|
" - name: filtered\n"
|
|
" kind: incremental\n"
|
|
" refs: [raw]\n"
|
|
" transform: filter\n",
|
|
)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["build", str(path), "--dry-run"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "raw" in result.output
|
|
assert "filtered" in result.output
|
|
|
|
def test_missing_config(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["build", "nope.yaml", "--dry-run"])
|
|
assert result.exit_code != 0
|
|
|
|
def test_live_deferred(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
path = _write(
|
|
tmp_path / "build.yaml",
|
|
"models:\n - name: raw\n kind: incremental\n"
|
|
" source: data/raw.jsonl\n transform: identity\n",
|
|
)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["build", str(path)])
|
|
# deferred runner exits non-zero with v0.69.1 marker
|
|
assert result.exit_code != 0
|
|
assert "0.69.1" in result.output or "deferred" in result.output.lower()
|
|
|
|
def test_outside_cwd(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
_write(
|
|
outside / "build.yaml",
|
|
"models:\n - name: a\n kind: incremental\n transform: x\n",
|
|
)
|
|
sub = tmp_path / "sub"
|
|
sub.mkdir()
|
|
monkeypatch.chdir(sub)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["build", str(outside / "build.yaml"), "--dry-run"])
|
|
assert result.exit_code != 0
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Source-grep wiring
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class TestSourceWiring:
|
|
def test_cli_registers_build(self) -> None:
|
|
root = Path(__file__).resolve().parent.parent
|
|
cli = (root / "src" / "soup_cli" / "cli.py").read_text(encoding="utf-8")
|
|
assert (
|
|
'name="build"' in cli
|
|
or "build_cmd" in cli
|
|
or "from soup_cli.commands import build" in cli
|
|
)
|
|
|
|
def test_no_heavy_top_level_imports(self) -> None:
|
|
root = Path(__file__).resolve().parent.parent
|
|
src = (root / "src" / "soup_cli" / "utils" / "build_dag.py").read_text(encoding="utf-8")
|
|
# yaml is also lazy-imported inside parse_build_yaml (TDD review H2).
|
|
for forbidden in (
|
|
"\nimport torch",
|
|
"\nimport transformers",
|
|
"\nimport peft",
|
|
"\nimport yaml",
|
|
):
|
|
assert forbidden not in src, f"top-level import found: {forbidden!r}"
|
|
|
|
def test_version_bumped(self) -> None:
|
|
from soup_cli import __version__
|
|
|
|
major_minor = tuple(int(x) for x in __version__.split(".")[:2])
|
|
assert major_minor >= (0, 69)
|