From 43df76be47264801bd4d337654a675448b779324 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Sat, 3 Oct 2026 00:10:56 +0200 Subject: [PATCH 1/3] Align materialize check JSON with its exit code A check with planned outputs fails the gate, so report ok as false. Co-Authored-By: GPT-6 Luna --- docs/cli/materialize.md | 10 ++++++---- src/lightcone/cli/commands.py | 6 +++--- src/lightcone/engine/materialize.py | 6 +++--- tests/test_cli.py | 31 +++++++++++++++++++++++++++-- tests/test_materialize.py | 2 +- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/docs/cli/materialize.md b/docs/cli/materialize.md index a323913c..b8a71b88 100644 --- a/docs/cli/materialize.md +++ b/docs/cli/materialize.md @@ -121,10 +121,12 @@ operation, and stronger consent than a flag. } ``` -The first two keys are the ones to branch on: `ok` — everything -attempted finished; `up_to_date` — nothing needed doing (a failed run -is never up to date, and `behind` outputs don't count against it). -`planned` is check mode's answer, mapping each would-run output to why; +The first two keys are the ones to branch on. `ok` is the command result: +in execution mode, every attempted output finished; in check mode, no +output would run. A failed run or a check with planned work returns +`ok: false` and exits 1. `up_to_date` means nothing was or needs to be +done (a failed run is never up to date, and `behind` outputs do not count +against it). `planned` is check mode's answer, mapping each would-run output to why; `behind` maps each left-alone output to the commit that can rebuild its environment. `notes` carries sandbox messages verbatim — denial remedies are built to be pasted. diff --git a/src/lightcone/cli/commands.py b/src/lightcone/cli/commands.py index e9346c34..c63454c6 100644 --- a/src/lightcone/cli/commands.py +++ b/src/lightcone/cli/commands.py @@ -298,7 +298,7 @@ def build(as_json: bool) -> None: is_flag=True, help=( "Report what would run and why, without executing or committing " - "anything; exit 1 if anything is out of date." + "anything. Exit 1 if any output would run; in JSON, `ok` is false." ), ) @click.option( @@ -391,7 +391,7 @@ def materialize( click.echo("\n".join(["", *report.notes]), err=True) _render_materialize_output(report, root, dry_run=check_only) - if not report.ok or (check_only and not report.up_to_date): + if not report.ok: sys.exit(1) @@ -499,7 +499,7 @@ def _render_materialize_output(report: MaterializeReport, root: Path, *, dry_run lines += [f" [red]✗[/red] blocked {name}" for name in report.blocked] lines += [f" [yellow]![/yellow] {escape(warning)}" for warning in report.warnings] - if not report.ok: + if report.failed or report.blocked: verdict = f"[red]✗[/red] {where} did not finish" elif report.up_to_date: verdict = f"[green]✓[/green] {where} is up to date — nothing to do" diff --git a/src/lightcone/engine/materialize.py b/src/lightcone/engine/materialize.py index 60247ca1..11e5bdea 100644 --- a/src/lightcone/engine/materialize.py +++ b/src/lightcone/engine/materialize.py @@ -80,8 +80,8 @@ class MaterializeReport: @property def ok(self) -> bool: - """Whether everything that was attempted finished.""" - return not self.failed and not self.blocked + """Whether the run finished or the check gate passed.""" + return not self.failed and not self.blocked and not self.planned @property def up_to_date(self) -> bool: @@ -97,7 +97,7 @@ def up_to_date(self) -> bool: so without ``ok`` here the first two keys of the JSON report would read "nothing to do" over a list of failures. """ - return self.ok and not self.made and not self.planned + return self.ok and not self.made def as_dict(self) -> dict[str, Any]: """Return the report as JSON-ready data. diff --git a/tests/test_cli.py b/tests/test_cli.py index 2fb60cc7..6977e82f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -427,10 +427,37 @@ def test_check_exits_nonzero_when_something_would_run( _stub(monkeypatch, check=MaterializeReport(planned={"baseline/fit": "no manifest"})) - result = runner.invoke(main, ["materialize", "--check"]) + human = runner.invoke(main, ["materialize", "--check"]) + assert human.exit_code == 1 + assert "would run baseline/fit" in human.output + + result = runner.invoke(main, ["materialize", "--check", "--json"]) assert result.exit_code == 1 - assert "would run baseline/fit" in result.output + payload = json.loads(result.output) + assert payload["ok"] is False + assert payload["up_to_date"] is False + assert payload["planned"] == {"baseline/fit": "no manifest"} + + +def test_check_json_succeeds_when_every_output_is_current( + runner: CliRunner, project: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from lightcone.engine.materialize import MaterializeReport + + _stub(monkeypatch, check=MaterializeReport(current=["baseline/fit"])) + + result = runner.invoke(main, ["materialize", "--check", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["ok"] is True + assert payload["up_to_date"] is True + + +def test_check_help_documents_json_gate_result(runner: CliRunner) -> None: + help_text = runner.invoke(main, ["materialize", "--help"]).output + assert "`ok` is false" in help_text def test_check_treats_all_positionals_as_targets_without_querying_compute( diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 44b323af..86a37d46 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -585,7 +585,7 @@ def test_a_lock_that_builds_from_source_is_a_warning_not_a_refusal(root: Path) - report = engine.check(root, []) - assert report.ok + assert not report.failed and not report.blocked assert any("oldlib" in w for w in report.warnings) From 70fe01b1c9679e84bbcf99942a15fc90d5a8cba5 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Sat, 3 Oct 2026 00:18:34 +0200 Subject: [PATCH 2/3] Show recipe-less outputs in status The plan preserves non-executable declarations so users can see what still needs a recipe. Co-Authored-By: GPT-6 Luna --- docs/api/materialize.md | 9 ++++---- docs/api/plan.md | 5 ++++- docs/cli/status.md | 16 ++++++++------ src/lightcone/cli/commands.py | 5 ++++- src/lightcone/engine/materialize.py | 29 ++++++++++++++++--------- src/lightcone/engine/plan.py | 31 ++++++++++++++++----------- tests/test_cli.py | 33 ++++++++++++++++++++++++++++- tests/test_materialize.py | 6 +++--- tests/test_plan.py | 1 + 9 files changed, 96 insertions(+), 39 deletions(-) diff --git a/docs/api/materialize.md b/docs/api/materialize.md index d767404b..29386ab8 100644 --- a/docs/api/materialize.md +++ b/docs/api/materialize.md @@ -16,7 +16,7 @@ driver's stderr, independently of success or failure, leaving stdout for the rep |---|---| | `materialize(root, targets, *, cluster_id, refresh)` | Project checks → graph → cluster connection → fetch/converge → schedule → save/restore → crate converge. | | `check(root, targets, *, refresh)` | The same classification without executing, committing, or fetching. Exempt from the dirty refusal. | -| `status(root)` | The report: every output's state and provenance commit, plus the mode/image/sandbox header facts. | +| `status(root)` | The report: every output's state and provenance commit, including `no recipe` outputs, plus the mode/image/sandbox header facts. | | `MaterializeReport` / `StatusReport` | The JSON surfaces; `ok` and `up_to_date` first. | | `cluster_for_run(cluster_id)` | Borrow the cluster; expose resource validation, submission, and completion. | | `run_record(...)` / `datalad_run_subject(...)` | The commit message `datalad rerun` replays, and the one spelling of its subject line — shared with the foreign-write comparator, because two strings here would drift. | @@ -59,9 +59,10 @@ driver's stderr, independently of success or failure, leaving stdout for the rep measured-safe (the clean filter renames over the path, which never stops existing) and must not be "fixed" by moving the save into the task. -- **`up_to_date` is `ok and not made and not planned`** — a run where - every recipe failed must not report "nothing to do", and `behind` - never counts against it. +- **`ok` is a successful run or a passed check gate** — execution requires + no failures or blocked tasks; check mode also requires an empty `planned` + mapping. `up_to_date` means `ok and not made`; a failed run must not report + "nothing to do", and `behind` never counts against it. - **A read-only verb never tracebacks.** Anything `check`/`status` cannot read classifies as "will be remade" and the real error belongs to the recipe that follows. diff --git a/docs/api/plan.md b/docs/api/plan.md index 49f06a78..8c3bab47 100644 --- a/docs/api/plan.md +++ b/docs/api/plan.md @@ -13,7 +13,7 @@ Source: `src/lightcone/engine/plan.py`. | Symbol | Role | |---|---| | `build(root)` | Validate the spec with ASTRA's own validators, resolve every universe, return the `Graph`. | -| `Graph` | Tasks keyed on `(universe_id, output_id)`; `order()` for the read-only topological walk, `resolve(targets)` for what a user typed, `closure(keys)` to narrow a run. | +| `Graph` | Recipe tasks keyed on `(universe_id, output_id)` plus `no_recipe` keys retained for status; `order()` for the read-only topological walk, `resolve(targets)` for what a user typed, `closure(keys)` to narrow a run. | | `Task` | One output in one universe, frozen, retaining ASTRA's resource declaration in `resources`. | | `declared_path(root, path)` | The one rule that names a path: project-relative inside the tree, absolute outside, never resolved. | @@ -31,6 +31,9 @@ Source: `src/lightcone/engine/plan.py`. schema, file, and universe validators before resolving anything — resolution answers what a *valid* spec means and does not re-check that it is one. +- **Outputs without recipes remain reportable.** `Graph.no_recipe` lists active + `(universe, output)` pairs ASTRA resolved without a command. They are not tasks + and cannot be materialized; `lc status` shows them as `no recipe`. - **Resource declarations survive resolution.** `build` reads `recipe.resources` from ASTRA's resolved output definition and preserves the mapping. A valid declaration remains readable by `status` and diff --git a/docs/cli/status.md b/docs/cli/status.md index fae5fdb9..b38c5a32 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -31,12 +31,13 @@ The header is repository facts: which mode the project executes in what enforcement a run on this host would get. No runtime and no network is needed to answer either. -Then one line per output the spec declares, in dependency order: its -state, **the commit it was made at**, and — for anything not current — -why. The commit column is the verb's reason to exist: "which code made -this?" has an answer for a current output too, and for a `behind` -output that commit is where the environment that produced it can be -read back. +Then one line per output the spec declares: recipe outputs appear in +dependency order, and outputs without recipes appear as `no recipe`. +Each line gives its state, **the commit it was made at**, and — for +stale or behind outputs — why. The commit column is the verb's reason to +exist: "which code made this?" has an answer for a current output too, +and for a `behind` output that commit is where the environment that +produced it can be read back. ## States @@ -46,6 +47,7 @@ read back. - `stale` — contradicts the project: definition changed, an input's content changed, or the output was edited by hand since it was made (a *foreign write* — the offending commit is named). +- `no recipe` — declared but not executable yet; add a recipe to make it. ## Report vs gate @@ -67,7 +69,7 @@ eyes, check for exit codes. "mode": "direct", "image": null, "sandbox": "landlock (fs: declared, network: allowed)", - "counts": {"current": 4, "behind": 0, "stale": 0}, + "counts": {"current": 4, "behind": 0, "stale": 0, "no recipe": 0}, "outputs": [ { "output": "baseline/fit", diff --git a/src/lightcone/cli/commands.py b/src/lightcone/cli/commands.py index c63454c6..7cdbd770 100644 --- a/src/lightcone/cli/commands.py +++ b/src/lightcone/cli/commands.py @@ -441,7 +441,10 @@ def status(as_json: bool) -> None: lines.append(f" sandbox: {escape(report.sandbox)}") lines.append(f" crate: {escape(report.crate)}") lines.append("") - marks = {"current": "[dim]·[/dim]", "behind": "[cyan]·[/cyan]", "stale": "[yellow]![/yellow]"} + marks = { + "current": "[dim]·[/dim]", "behind": "[cyan]·[/cyan]", + "stale": "[yellow]![/yellow]", "no recipe": "[yellow]·[/yellow]", + } width = max((len(o.output) for o in report.outputs), default=0) # The commit gets a column of its own, for every state and not only # the interesting ones: "which code made this" is the question the diff --git a/src/lightcone/engine/materialize.py b/src/lightcone/engine/materialize.py index 11e5bdea..92f4feb6 100644 --- a/src/lightcone/engine/materialize.py +++ b/src/lightcone/engine/materialize.py @@ -38,7 +38,7 @@ from contextlib import contextmanager from dataclasses import asdict, dataclass, field, replace from pathlib import Path -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Literal, Protocol from uuid import uuid4 from lightcone.engine import assets, container, dataset, identity, plan, project, worker @@ -138,7 +138,8 @@ def check(root: Path, targets: Sequence[str], *, refresh: bool = False) -> Mater read, or a target matches nothing. """ report = MaterializeReport() - for key, verdict, _, _ in _classified(root, targets, report, refresh=refresh): + _, classified = _classified(root, targets, report, refresh=refresh) + for key, verdict, _, _ in classified: name = _name(key) if verdict.calls_for_a_remake(refresh=refresh): report.planned[name] = verdict.why @@ -151,7 +152,10 @@ def check(root: Path, targets: Sequence[str], *, refresh: bool = False) -> Mater def _classified( root: Path, targets: Sequence[str], report: MaterializeReport, *, refresh: bool -) -> list[tuple[Key, assets.Verdict, assets.Manifest | None, dataset.LastWrite | None]]: +) -> tuple[ + Graph, + list[tuple[Key, assets.Verdict, assets.Manifest | None, dataset.LastWrite | None]], +]: """Classify every task in topological order, reading nothing but disk. The walk both read-only modes share, so there is one answer to "what @@ -170,8 +174,8 @@ def _classified( decides whether their dependents see the sentinel. Returns: - One ``(key, verdict, manifest, foreign write)`` per task, upstream - first. + The graph and one ``(key, verdict, manifest, foreign write)`` per + task, upstream first. """ graph, env_version, _ = _graph(root, targets, report) unfetched: set[str] = set() @@ -186,7 +190,7 @@ def _classified( "`lc materialize` fetches declared inputs before executing " "recipes. Compute is required for outputs whose inputs cannot yet be checked." ) - return classified + return graph, classified def _classify_graph( @@ -287,8 +291,8 @@ class OutputStatus: #: ``universe/output_id``. output: str - status: assets.Status - #: Why, for ``stale`` and ``behind``. Empty for ``current``. + status: assets.Status | Literal["no recipe"] + #: Why, for ``stale`` and ``behind``. Empty for ``current`` and ``no recipe``. why: str #: The commit the output was materialized at, or empty if it never was. #: This is the whole point of the verb: an artifact that is behind is @@ -344,7 +348,7 @@ class StatusReport: @property def counts(self) -> dict[str, int]: """How many outputs are in each state, states with none included.""" - tally = {"current": 0, "behind": 0, "stale": 0} + tally = {"current": 0, "behind": 0, "stale": 0, "no recipe": 0} for output in self.outputs: tally[output.status] += 1 return tally @@ -392,7 +396,8 @@ def status(root: Path) -> StatusReport: result.image = {"tag": tag, "state": state, "archive": archive} result.sandbox = _sandbox_line(result.mode) stamps = [] - for key, verdict, manifest, foreign in _classified(root, [], report, refresh=False): + graph, classified = _classified(root, [], report, refresh=False) + for key, verdict, manifest, foreign in classified: if manifest and manifest.finished_at: stamps.append(manifest.finished_at) result.outputs.append( @@ -405,6 +410,10 @@ def status(root: Path) -> StatusReport: foreign_write=foreign.sha if foreign else "", ) ) + result.outputs.extend( + OutputStatus(output=_name(key), status="no recipe", why="", git_sha="", data_version="") + for key in graph.no_recipe + ) result.crate = _crate_line(root, max(stamps, default="")) result.warnings = report.warnings return result diff --git a/src/lightcone/engine/plan.py b/src/lightcone/engine/plan.py index 513f410c..bdc4ad21 100644 --- a/src/lightcone/engine/plan.py +++ b/src/lightcone/engine/plan.py @@ -4,7 +4,8 @@ ``(universe, output)`` pair that has a recipe. A task carries everything executing it needs and nothing about *how* it will be executed: the rendered command, where its bytes go, what it reads, which decisions it -was made under, and its ``definition_version``. +was made under, and its ``definition_version``. Active outputs without a +recipe are retained separately for status and are not executable tasks. What the spec *means* is ASTRA's to say. ``astra.resolve`` settles each universe's decisions, resolves every output's inputs to what supplies @@ -73,9 +74,11 @@ def depends_on(self) -> tuple[Key, ...]: @dataclass(frozen=True) class Graph: - """Every task a run could make, and how they relate.""" + """Every task a run could make, and outputs with no recipe.""" tasks: dict[Key, Task] + #: Active outputs ASTRA resolved without a recipe; reportable, not executable. + no_recipe: tuple[Key, ...] = () def order(self) -> list[Key]: """Return the tasks in dependency order. @@ -157,8 +160,7 @@ def build(root: Path) -> Graph: root: The project root. Returns: - One task per ``(universe, output)`` pair that has a recipe and is - active in that universe. + The active recipe tasks and keys of active outputs without recipes. Raises: ProjectError: If the spec is missing, declares no universe, gives @@ -181,6 +183,7 @@ def build(root: Path) -> Graph: spec = dict(resolve_analysis_tree(load_yaml(spec_path), root)) tasks: dict[Key, Task] = {} + no_recipe: list[Key] = [] declared_in: dict[str, Path] = {} for path in universes: universe = load_yaml(path) @@ -196,10 +199,12 @@ def build(root: Path) -> Graph: f"results/{universe_id}/. Give each universe its own id." ) declared_in[universe_id] = path - for task in _tasks(root, universe_id, spec, universe): + universe_tasks, universe_no_recipe = _tasks(root, universe_id, spec, universe) + for task in universe_tasks: tasks[task.key] = task + no_recipe.extend((universe_id, output_id) for output_id in universe_no_recipe) - return Graph(tasks=tasks) + return Graph(tasks=tasks, no_recipe=tuple(no_recipe)) def declared_path(root: Path, path: Path) -> str: @@ -271,13 +276,13 @@ def _tasks( universe_id: str, spec: dict[str, object], universe: dict[str, object], -) -> list[Task]: - """Every task one universe contributes. +) -> tuple[list[Task], list[str]]: + """Resolve one universe's tasks and outputs that cannot be executed. ``resolve_outputs`` has already dropped what this universe does not - produce, so the only filter left is whether an output carries a - command: a re-export names bytes another output makes, and making it - twice under two ids is not a thing to do. + produce. Outputs without a command remain visible to status but are + not scheduled: a re-export names bytes another output makes, and + making it twice under two ids is not a thing to do. """ from astra.resolve import render_command, resolve_outputs @@ -303,8 +308,10 @@ def file_of(out: object) -> Path: ) tasks = [] + no_recipe = [] for out in resolved: if not out.command: + no_recipe.append(out.id) continue output_path = file_of(out) values: dict[str, str] = {} @@ -351,4 +358,4 @@ def file_of(out: object) -> Path: resources=resources, ) ) - return tasks + return tasks, no_recipe diff --git a/tests/test_cli.py b/tests/test_cli.py index 6977e82f..11f8db31 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +from collections.abc import Callable from pathlib import Path import pytest @@ -739,7 +740,7 @@ def test_status_json_is_machine_readable( "image": None, "sandbox": "", "crate": "", - "counts": {"current": 1, "behind": 1, "stale": 1}, + "counts": {"current": 1, "behind": 1, "stale": 1, "no recipe": 0}, "outputs": [ { "output": "baseline/first", @@ -770,6 +771,36 @@ def test_status_json_is_machine_readable( } +def test_status_shows_outputs_without_recipes_in_text_and_json( + runner: CliRunner, analysis: Callable[..., Path], monkeypatch: pytest.MonkeyPatch, +) -> None: + spec = """ + version: "0.0.13" + name: analysis + inputs: [] + outputs: + - id: pending + type: metric + format: txt + decisions: {} + """ + root = analysis(spec, universes={"baseline": "id: baseline\ndecisions: {}\n"}) + monkeypatch.chdir(root) + + human = runner.invoke(main, ["status"]) + assert human.exit_code == 0 + assert "baseline/pending" in human.output + assert "no recipe" in human.output + + machine = runner.invoke(main, ["status", "--json"]) + assert machine.exit_code == 0 + report = json.loads(machine.output) + assert report["counts"]["no recipe"] == 1 + assert {output["output"]: output["status"] for output in report["outputs"]} == { + "baseline/pending": "no recipe", + } + + def test_status_has_exactly_one_flag(runner: CliRunner) -> None: """Minimal by decision: it answers one question, and every way of narrowing it is a way of getting a partial answer to that question.""" diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 86a37d46..a8e9d039 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -265,7 +265,7 @@ def test_status_names_the_commit_each_output_came_from(root: Path, inline: None) # The commit the tree was at when the run *started* — the code that # produced the output, not the commit the run itself went on to make. assert all(o.git_sha == ran_against for o in report.outputs) - assert report.counts == {"current": 2, "behind": 0, "stale": 0} + assert report.counts == {"current": 2, "behind": 0, "stale": 0, "no recipe": 0} def test_status_reports_behind_after_the_environment_moves( @@ -277,7 +277,7 @@ def test_status_reports_behind_after_the_environment_moves( report = engine.status(root) - assert report.counts == {"current": 0, "behind": 2, "stale": 0} + assert report.counts == {"current": 0, "behind": 2, "stale": 0, "no recipe": 0} assert all(o.git_sha == made_at for o in report.outputs), "the commit it was made at" assert "earlier environment" in report.outputs[0].why @@ -287,7 +287,7 @@ def test_status_leaves_a_never_materialized_output_without_a_commit(root: Path) which would claim the output came from a commit that never made it.""" report = engine.status(root) - assert report.counts == {"current": 0, "behind": 0, "stale": 2} + assert report.counts == {"current": 0, "behind": 0, "stale": 2, "no recipe": 0} assert all(o.git_sha == "" and o.data_version == "" for o in report.outputs) assert "never been materialized" in report.outputs[0].why diff --git a/tests/test_plan.py b/tests/test_plan.py index 9f069d38..d5dfcb4c 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -77,6 +77,7 @@ def test_one_task_per_universe_and_output_with_a_recipe(tmp_path: Path) -> None: nothing to schedule for it.""" graph = _build(_project(tmp_path)) assert sorted(graph.tasks) == [("baseline", "fit"), ("baseline", "report")] + assert graph.no_recipe == (("baseline", "reexport"),) def test_every_universe_gets_its_own_task(tmp_path: Path) -> None: From 8dcdb902d77c39753bdd385f6113f466abb2d641 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Sat, 3 Oct 2026 00:56:00 +0200 Subject: [PATCH 3/3] Keep re-exports out of no-recipe status and align its column A `from:` re-export stands for an output that is already reported, and the nine-character `no recipe` state pushed its row out of the table. Co-Authored-By: Claude Opus 5.5 --- src/lightcone/cli/commands.py | 4 ++-- src/lightcone/engine/plan.py | 9 +++++---- tests/test_plan.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/lightcone/cli/commands.py b/src/lightcone/cli/commands.py index 7cdbd770..44fe5fc6 100644 --- a/src/lightcone/cli/commands.py +++ b/src/lightcone/cli/commands.py @@ -453,7 +453,7 @@ def status(as_json: bool) -> None: # in `why`, so this one path covers it; the dedicated field exists # for machine consumers of `--json`. lines += [ - f" {marks[o.status]} {o.status:<8} {o.output:<{width}} " + f" {marks[o.status]} {o.status:<9} {o.output:<{width}} " f"{o.git_sha[:7] or '—':<7}" + (f" [dim]{escape(o.why)}[/dim]" if o.why else "") for o in report.outputs ] @@ -461,7 +461,7 @@ def status(as_json: bool) -> None: counts = report.counts if not report.outputs: - lines.append("[dim]The analysis declares no output with a recipe.[/dim]") + lines.append("[dim]The analysis declares no output.[/dim]") else: lines.append("") lines.append( diff --git a/src/lightcone/engine/plan.py b/src/lightcone/engine/plan.py index bdc4ad21..2142a043 100644 --- a/src/lightcone/engine/plan.py +++ b/src/lightcone/engine/plan.py @@ -280,9 +280,9 @@ def _tasks( """Resolve one universe's tasks and outputs that cannot be executed. ``resolve_outputs`` has already dropped what this universe does not - produce. Outputs without a command remain visible to status but are - not scheduled: a re-export names bytes another output makes, and - making it twice under two ids is not a thing to do. + produce. Outputs without a command are not scheduled. Declared-only + ones are returned for status; a re-export is not, because it names + bytes another output makes and that output is reported in its place. """ from astra.resolve import render_command, resolve_outputs @@ -311,7 +311,8 @@ def file_of(out: object) -> Path: no_recipe = [] for out in resolved: if not out.command: - no_recipe.append(out.id) + if out.reexports is None: + no_recipe.append(out.id) continue output_path = file_of(out) values: dict[str, str] = {} diff --git a/tests/test_plan.py b/tests/test_plan.py index d5dfcb4c..9f52fc93 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -80,6 +80,35 @@ def test_one_task_per_universe_and_output_with_a_recipe(tmp_path: Path) -> None: assert graph.no_recipe == (("baseline", "reexport"),) +def test_a_reexport_is_not_reported_as_missing_a_recipe(tmp_path: Path) -> None: + """A ``from:`` re-export stands for its target, so only the target is + reported when neither carries a recipe.""" + spec = """ + version: "0.0.13" + name: demo + inputs: [] + analyses: + sub: + name: sub + inputs: + - id: cat + type: data + source: data/cat.txt + outputs: + - id: made + type: metric + format: txt + decisions: {} + outputs: + - id: top + type: metric + from: sub.made + decisions: {} + """ + graph = _build(_project(tmp_path, spec, baseline="id: baseline\ndecisions: {}\n")) + assert graph.no_recipe == (("baseline", "sub.made"),) + + def test_every_universe_gets_its_own_task(tmp_path: Path) -> None: graph = _build( _project(