diff --git a/nix_estimator/graph.py b/nix_estimator/graph.py index e6d6cbb..b33351d 100644 --- a/nix_estimator/graph.py +++ b/nix_estimator/graph.py @@ -61,29 +61,31 @@ def derivation_closure( return {_basename(k): v for k, v in raw.items()} -def to_build_set( - attr: str, system: str | None = None, extra_args: list[str] | None = None -) -> set[str] | None: - """Set of ``.drv`` paths Nix says *will be built*, or ``None`` if unparseable. +def parse_dry_run_text(text: str, returncode: int = 0) -> set[str] | None: + """Parse the *human* text of ``nix build --dry-run`` into a to-build set. - ``None`` signals the caller to fall back to "the whole closure" (a cold-cache - over-estimate) rather than silently reporting zero work. + Tri-state result (see :func:`to_build_set`): + + - a ``set`` of drv basenames when the dry-run structure was recognized + (possibly empty — a fully warm cache prints only "will be fetched"); + - ``None`` when no recognizable dry-run structure was found at all, so the + caller cannot tell built-nothing from parse-failure. + + A dry-run is considered "recognized" when the process exited cleanly *or* a + known section header ("will be built"/"will be fetched"/"will be copied") was + seen. ``returncode`` is the ``nix`` exit status (0 = success). """ - cmd = ["nix", "build", "--dry-run", attr] - if system: - cmd += ["--system", system] - if extra_args: - cmd += extra_args - proc = _run(cmd, check=False) - text = proc.stderr + "\n" + proc.stdout built: set[str] = set() + saw_header = False grabbing = False for line in text.splitlines(): s = line.strip() if re.search(r"will be built", s): + saw_header = True grabbing = True continue if re.search(r"will be fetched|will be copied", s): + saw_header = True grabbing = False continue if grabbing: @@ -92,7 +94,82 @@ def to_build_set( built.add(_basename(m.group(1))) elif s and not s.startswith("/nix/store"): grabbing = False - return built or None + if not saw_header and returncode != 0: + return None + return built + + +def parse_dry_run_json(text: str) -> set[str] | None: + """Parse ``nix build --dry-run --json`` output into a to-build set. + + The JSON form is a list of build results; each entry names its derivation + under ``drvPath`` (or, on some schemas, ``drv``). Locale- and wording-proof, + unlike the human text. Returns a ``set`` of drv basenames (possibly empty), + or ``None`` when the payload is not the expected structured shape so the + caller can fall back to the text scraper. + """ + try: + data = json.loads(text) + except (ValueError, TypeError): + return None + if not isinstance(data, list): + return None + built: set[str] = set() + for entry in data: + if not isinstance(entry, dict): + return None + drv = entry.get("drvPath") or entry.get("drv") + if drv: + built.add(_basename(drv)) + return built + + +def _supports_dry_run_json() -> bool: + """Probe whether this ``nix`` accepts ``build --dry-run --json`` at all. + + ``--json`` on ``nix build`` is unsupported on older Nix; parsing ``--help`` + is cheap and detects support without evaluating anything. + """ + try: + help_text = _run(["nix", "build", "--help"], check=False).stdout + except (OSError, subprocess.SubprocessError): + return False + return "--json" in help_text + + +def to_build_set( + attr: str, system: str | None = None, extra_args: list[str] | None = None +) -> set[str] | None: + """Set of ``.drv`` basenames Nix says *will be built*, tri-state. + + Returns an empty ``set`` when the dry-run clearly succeeded but nothing will + be built (fully warm cache), a populated ``set`` of the drvs to build, and + ``None`` *only* when the dry-run produced nothing recognizable — the sole + case in which the caller should fall back to costing the whole closure. + + Prefers structured ``--dry-run --json`` output when this ``nix`` supports it + (robust across versions and locales), falling back to scraping human text. + """ + base = ["nix", "build", "--dry-run", attr] + if system: + base += ["--system", system] + if extra_args: + base += extra_args + + if _supports_dry_run_json(): + try: + proc = _run(base + ["--json"], check=False) + except (OSError, subprocess.SubprocessError): + proc = None + if proc is not None: + parsed = parse_dry_run_json(proc.stdout) + if parsed is not None: + return parsed + # JSON unavailable/unusable at runtime: fall through to text scraper. + + proc = _run(base, check=False) + text = proc.stderr + "\n" + proc.stdout + return parse_dry_run_text(text, proc.returncode) def build_dag( @@ -101,6 +178,10 @@ def build_dag( """Return ``(preds, nodes)`` where ``preds[d]`` are the in-graph derivations ``d`` depends on. Restricted to ``to_build`` when given (edges to already-cached inputs are dropped — they contribute no build time). + + ``to_build`` is tri-state: ``None`` means "cache state unknown" so the whole + closure is costed; an *empty* set means "nothing to build" (warm cache) and + yields no nodes; a populated set restricts to those drvs. """ nodes = set(closure) if to_build is None else (set(to_build) & set(closure)) preds: dict[str, list[str]] = {} diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..4a4c405 --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,95 @@ +"""Tests for dry-run parsing and DAG restriction in :mod:`nix_estimator.graph`.""" + +from nix_estimator import graph + +# --- captured `nix build --dry-run` text (issue #5) ------------------------ + +WARM = """\ +this path will be fetched (0.02 MiB download, 0.10 MiB unpacked): + /nix/store/aaa-hello-2.12.1 +""" + +PARTIAL = """\ +these 2 derivations will be built: + /nix/store/bbb-foo-1.0.drv + /nix/store/ccc-bar-2.0.drv +these 3 paths will be fetched (1.0 MiB download): + /nix/store/ddd-baz-3.0 +""" + +GARBAGE = """\ +error: some totally unrelated failure with no dry-run structure +traceback blah blah +""" + + +def test_warm_cache_only_fetched_is_empty_set(): + # dry-run succeeded, nothing to build: distinct from a parse failure + result = graph.parse_dry_run_text(WARM, returncode=0) + assert result == set() + + +def test_partial_returns_only_built_drvs(): + result = graph.parse_dry_run_text(PARTIAL, returncode=0) + assert result == {"bbb-foo-1.0.drv", "ccc-bar-2.0.drv"} + + +def test_garbage_without_header_and_failure_is_none(): + assert graph.parse_dry_run_text(GARBAGE, returncode=1) is None + + +def test_clean_exit_without_header_is_empty_not_none(): + # exit 0 but no recognizable header still counts as "nothing to build" + assert graph.parse_dry_run_text("", returncode=0) == set() + + +# --- structured `nix build --dry-run --json` output (issue #8) ------------- + +JSON_PARTIAL = """\ +[ + {"drvPath": "/nix/store/bbb-foo-1.0.drv", "outputs": {"out": "/nix/store/e-foo"}}, + {"drvPath": "/nix/store/ccc-bar-2.0.drv", "outputs": {"out": "/nix/store/f-bar"}} +] +""" + +JSON_WARM = "[]" + +JSON_ALT_KEY = '[{"drv": "/nix/store/ggg-qux-9.drv"}]' + + +def test_json_partial_returns_built_drvs(): + assert graph.parse_dry_run_json(JSON_PARTIAL) == { + "bbb-foo-1.0.drv", + "ccc-bar-2.0.drv", + } + + +def test_json_warm_cache_is_empty_set(): + assert graph.parse_dry_run_json(JSON_WARM) == set() + + +def test_json_alternate_drv_key(): + assert graph.parse_dry_run_json(JSON_ALT_KEY) == {"ggg-qux-9.drv"} + + +def test_json_non_structured_is_none(): + # human text or an unexpected shape must fall back (None), not crash + assert graph.parse_dry_run_json(WARM) is None + assert graph.parse_dry_run_json('{"not": "a list"}') is None + assert graph.parse_dry_run_json("not json at all") is None + + +def test_build_dag_empty_set_yields_no_nodes(): + closure = {"a.drv": {"inputDrvs": {}}, "b.drv": {"inputDrvs": {"a.drv": {}}}} + preds, nodes = graph.build_dag(closure, set()) + assert nodes == set() + assert preds == {} + + +def test_build_dag_none_costs_whole_closure(): + closure = { + "a.drv": {"inputDrvs": {}}, + "b.drv": {"inputDrvs": {"/nix/store/x-a.drv": {}}}, + } + _, nodes = graph.build_dag(closure, None) + assert nodes == {"a.drv", "b.drv"}