Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions src/lightcone/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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
Expand All @@ -450,15 +453,15 @@ 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
]
lines += [f" [yellow]![/yellow] {escape(warning)}" for warning in report.warnings]

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(
Expand Down Expand Up @@ -499,7 +502,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"
Expand Down
35 changes: 22 additions & 13 deletions src/lightcone/engine/materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
32 changes: 20 additions & 12 deletions src/lightcone/engine/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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 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

Expand All @@ -303,8 +308,11 @@ def file_of(out: object) -> Path:
)

tasks = []
no_recipe = []
for out in resolved:
if not out.command:
if out.reexports is None:
no_recipe.append(out.id)
continue
output_path = file_of(out)
values: dict[str, str] = {}
Expand Down Expand Up @@ -351,4 +359,4 @@ def file_of(out: object) -> Path:
resources=resources,
)
)
return tasks
return tasks, no_recipe
64 changes: 61 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import json
from collections.abc import Callable
from pathlib import Path

import pytest
Expand Down Expand Up @@ -427,10 +428,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(
Expand Down Expand Up @@ -712,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",
Expand Down Expand Up @@ -743,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."""
Expand Down
8 changes: 4 additions & 4 deletions tests/test_materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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)


Expand Down
Loading
Loading