Files
nix-estimator/tests/test_cli.py
T
Oleks a967c2aec3 provision: size ramGb from modeled peak per-node RAM (#19)
The --provision renderer defaulted ramGb to 2 GiB/core. Since #15 the
scheduler tracks per-derivation RAM, so compute the peak concurrent RAM on
the busiest node of the recommended shape (peak_ram_gb_per_node) and feed it
into each EphemeralBuilder's requirements.ramGb. Explicit --node-ram-gb still
overrides; a nothing-to-build closure falls back to the per-core default.
2026-07-08 20:51:25 +03:00

241 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CLI-level tests for grid parsing / normalization (issue #11)."""
import json
import pytest
from nix_estimator import cli
def _p(name):
return f"/nix/store/{'0' * 32}-{name}.drv"
@pytest.fixture
def stub_graph(monkeypatch, tmp_path):
"""Stub out the nix-invoking graph layer with a deterministic toy DAG."""
leaves = [_p(f"heavylib{i}") for i in range(4)]
root, sink = _p("root"), _p("sink")
closure = {d: {} for d in [root, *leaves, sink]}
preds = {root: [], sink: leaves, **{leaf: [root] for leaf in leaves}}
nodes = set(closure)
monkeypatch.setattr(cli.graph, "derivation_closure", lambda *a, **k: closure)
monkeypatch.setattr(cli.graph, "to_build_set", lambda *a, **k: nodes)
monkeypatch.setattr(cli.graph, "build_dag", lambda *a, **k: (preds, nodes))
hist = tmp_path / "history.json"
hist.write_text(
json.dumps(
{"root": 1.0, "sink": 1.0, **{f"heavylib{i}": 20.0 for i in range(4)}}
)
)
return str(hist)
def _run_json(capsys, argv):
assert cli.main(argv) == 0
return json.loads(capsys.readouterr().out)
def test_unsorted_cores_match_sorted(stub_graph, capsys):
sorted_out = _run_json(
capsys, [".#x", "--history", stub_graph, "--cores", "8,16,32", "--json"]
)
unsorted_out = _run_json(
capsys, [".#x", "--history", stub_graph, "--cores", "32,8,16", "--json"]
)
assert unsorted_out["recommendation"] == sorted_out["recommendation"]
assert unsorted_out["grid"] == sorted_out["grid"]
def test_unsorted_nodes_match_sorted(stub_graph, capsys):
sorted_out = _run_json(
capsys, [".#x", "--history", stub_graph, "--nodes", "1,2,4,8", "--json"]
)
unsorted_out = _run_json(
capsys, [".#x", "--history", stub_graph, "--nodes", "8,2,1,4", "--json"]
)
assert unsorted_out["recommendation"] == sorted_out["recommendation"]
def test_non_positive_cores_rejected(stub_graph, capsys):
with pytest.raises(SystemExit) as exc:
cli.main([".#x", "--history", stub_graph, "--cores", "8,0,16", "--json"])
assert exc.value.code != 0
assert "positive" in capsys.readouterr().err
def test_negative_nodes_rejected(stub_graph, capsys):
with pytest.raises(SystemExit) as exc:
cli.main([".#x", "--history", stub_graph, "--nodes", "1,-2,4", "--json"])
assert exc.value.code != 0
def test_non_integer_cores_rejected(stub_graph, capsys):
with pytest.raises(SystemExit) as exc:
cli.main([".#x", "--history", stub_graph, "--cores", "8,abc", "--json"])
assert exc.value.code != 0
assert "integer" in capsys.readouterr().err
# --- new feature surfaces (issues #12#16) ---------------------------------
def test_backcompat_plain_estimate(stub_graph, capsys):
"""Plain `nix-estimate <attr> --json` still parses with no subcommand."""
out = _run_json(capsys, [".#foo", "--history", stub_graph, "--json"])
assert out["attr"] == ".#foo"
assert out["max_jobs"] == 1
assert out["node_ram_gb"] is None
def test_max_jobs_accepted_and_affects_output(stub_graph, capsys):
base = _run_json(capsys, [".#x", "--history", stub_graph, "--json"])
wide = _run_json(
capsys, [".#x", "--history", stub_graph, "--max-jobs", "4", "--json"]
)
assert base["max_jobs"] == 1 and wide["max_jobs"] == 4
# Sharing cores across 4 jobs changes at least one grid makespan.
assert wide["grid"] != base["grid"]
def test_node_ram_gb_accepted(stub_graph, capsys):
out = _run_json(
capsys,
[
".#x",
"--history",
stub_graph,
"--max-jobs",
"4",
"--node-ram-gb",
"8",
"--json",
],
)
assert out["node_ram_gb"] == 8.0
def test_timeline_renders_report(stub_graph, capsys):
assert cli.main([".#x", "--history", stub_graph, "--timeline"]) == 0
out = capsys.readouterr().out
assert "timeline — recommended shape" in out
assert "node0" in out
def test_timeline_json_carries_assignments(stub_graph, capsys):
out = _run_json(capsys, [".#x", "--history", stub_graph, "--timeline", "--json"])
assert "timeline" in out
tl = out["timeline"]
assert tl["makespan_min"] > 0
# every scheduled task name appears in some lane
tasks = [t[0] for lane in tl["assignments"].values() for t in lane]
assert "sink" in tasks
def test_provision_emits_yaml_to_stdout(stub_graph, capsys):
assert cli.main([".#x", "--history", stub_graph, "--provision"]) == 0
cap = capsys.readouterr()
assert "kind: EphemeralBuilder" in cap.out
assert "apiVersion: arbitrage.oleks.space/v1alpha1" in cap.out
assert "class: nix-builder" in cap.out
assert "requirements:" in cap.out and "cores:" in cap.out
assert "namespace: builder-arbitrage" in cap.out
# human report is diverted to stderr, not stdout
assert "RECOMMENDATION" in cap.err
assert "RECOMMENDATION" not in cap.out
def test_provision_custom_class_and_name(stub_graph, capsys):
assert (
cli.main(
[
".#x",
"--history",
stub_graph,
"--provision",
"runner",
"--provision-name",
"my-builder",
]
)
== 0
)
out = capsys.readouterr().out
assert "class: runner" in out
assert "name: my-builder-1" in out
def test_mine_subcommand_writes_json(monkeypatch, capsys, tmp_path):
"""`mine` runs mine_history (stubbed) and writes {name: minutes} JSON."""
calls = []
def fake_mine_history(installables, **kw):
calls.append(list(installables))
return {"hello": 2.0, "world": 5.0}
monkeypatch.setattr(cli.miner, "mine_history", fake_mine_history)
out_file = tmp_path / "hist.json"
rc = cli.main(["mine", ".#hello", ".#world", "--repeat", "3", "-o", str(out_file)])
assert rc == 0
assert len(calls) == 3 # repeated
data = json.loads(out_file.read_text())
assert data == {"hello": 2.0, "world": 5.0}
def test_mine_subcommand_to_stdout(monkeypatch, capsys):
monkeypatch.setattr(cli.miner, "mine_history", lambda inst, **kw: {"pkg": 1.5})
assert cli.main(["mine", ".#pkg"]) == 0
assert json.loads(capsys.readouterr().out) == {"pkg": 1.5}
@pytest.fixture
def stub_graph_heavy(monkeypatch):
"""Toy DAG with a RAM-heavy chromium build (10 GB) to exercise --provision sizing."""
root = _p("root")
heavy = _p("chromium")
leaf = _p("lib")
closure = {root: {}, heavy: {}, leaf: {}}
preds = {root: [], heavy: [root], leaf: [root]}
nodes = set(closure)
monkeypatch.setattr(cli.graph, "derivation_closure", lambda *a, **k: closure)
monkeypatch.setattr(cli.graph, "to_build_set", lambda *a, **k: nodes)
monkeypatch.setattr(cli.graph, "build_dag", lambda *a, **k: (preds, nodes))
def _ramgb_from_yaml(text):
for line in text.splitlines():
s = line.strip()
if s.startswith("ramGb:"):
return int(s.split(":", 1)[1])
raise AssertionError(f"no ramGb in:\n{text}")
def test_provision_ramgb_from_model(stub_graph_heavy, capsys):
# No --node-ram-gb: ramGb is sized from the modeled peak, which must cover
# the 10 GB chromium build (issue #19), not just fall back to 2*cores blindly.
assert cli.main([".#x", "--provision"]) == 0
ram = _ramgb_from_yaml(capsys.readouterr().out)
assert ram >= 10
def test_provision_explicit_node_ram_overrides(stub_graph_heavy, capsys):
assert cli.main([".#x", "--provision", "--node-ram-gb", "64"]) == 0
ram = _ramgb_from_yaml(capsys.readouterr().out)
assert ram == 64
def test_provision_fallback_when_nothing_to_build(monkeypatch, capsys):
# Empty to_build -> no model figure -> renderer's 2 GiB/core default (16c -> 32).
root = _p("root")
closure = {root: {}}
monkeypatch.setattr(cli.graph, "derivation_closure", lambda *a, **k: closure)
monkeypatch.setattr(cli.graph, "to_build_set", lambda *a, **k: set())
monkeypatch.setattr(cli.graph, "build_dag", lambda *a, **k: ({}, set()))
# nothing-to-build short-circuits the human report; force provision path via a
# closure that still yields a recommendation is not possible when to_build==0,
# so assert the CLI exits cleanly and prints the nothing-to-build notice.
assert cli.main([".#x", "--provision"]) == 0