Files
app/backend/tests/test_cli.py
T
stroblmeandClaude Opus 5 400d7d9c5c
Docs / docs (push) Successful in 19s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m5s
Playwright Tests / test-playwright (2, 2) (push) Failing after 20s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Successful in 2m7s
Compose Smoke Test / test-compose (push) Failing after 20s
Playwright Tests / merge-reports (push) Failing after 1m3s
Publish / publish (push) Failing after 12s
Stage caching for batch runs, and an engine that lives in the command
A code node in a batch run is now fingerprinted by its source, its raw
settings and the values it reads — an artifact input counting as its digest,
which is what the content addressing was always for. A run that finds the key
restores what the earlier one returned and skips the node, recorded as
`cached`. The run history is the cache: `run_node.outputs` beside the
`cache_key` the schema already had, no second store. On for code nodes, never
for the built-in and connector types that have side effects; off per node with
`@node(cache=False)` and per run with `--no-cache`.

Emissions are not replayed on a hit, so a cached training node returns its
result without redrawing its curve. Recorded in NOTEPAD.md with the two other
deliberate limits.

`fluksio run --local` boots the real app in the command's own process and
drives it through its ASGI interface behind the ordinary client, so a run no
longer needs a `serve` terminal beside it — same data directory, same history,
and the cache carries between the two. It always waits, because the engine it
starts lives exactly as long as the command.

Also: `fluksio sweep --param lr=0.1,0.01` for the product of the lists,
`run --follow` for a run's numbers as they arrive, Ctrl-C cancelling a waited
run rather than abandoning it, coloured statuses on a terminal, and `name`
made optional on the metrics endpoint so a follower can ask for every series.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:31:31 +02:00

258 lines
8.3 KiB
Python

"""The command line, and the one ordering it depends on."""
import subprocess
import sys
from pathlib import Path
from fluksio.cli import load_or_create_secret_key
def test_importing_the_cli_does_not_build_the_settings() -> None:
"""`_configure_environment` has to run before anything reads a setting.
The settings are built on the first import of `fluksio.core.config`, and
every engine module reaches it within an import or two. If importing the
CLI pulled it in, `DATA_DIR` would be fixed at whatever directory the
command was run from — which is how the database ends up in the cwd.
"""
leaked = subprocess.run(
[
sys.executable,
"-c",
"import fluksio.cli, sys; print('fluksio.core.config' in sys.modules)",
],
capture_output=True,
text=True,
check=True,
)
assert leaked.stdout.strip() == "False", leaked.stdout
def test_the_secret_key_is_kept_rather_than_regenerated(tmp_path: Path) -> None:
"""A new key each start would sign out every session and orphan secrets.enc."""
path = tmp_path / "secret_key"
first = load_or_create_secret_key(path)
assert load_or_create_secret_key(path) == first
assert path.stat().st_mode & 0o777 == 0o600
def test_the_store_works_without_git(tmp_path: Path, monkeypatch) -> None:
"""A pip install on a locked-down host may have no git.
Flows are files, and that is what the store is for; the history is the part
that needs git. Losing it must not be a refusal to start.
"""
import subprocess as sp
from fluksio.flow.store import FlowStore
real_run = sp.run
def no_git(cmd, *args, **kwargs):
if cmd and cmd[0] == "git":
raise FileNotFoundError(2, "No such file or directory", "git")
return real_run(cmd, *args, **kwargs)
monkeypatch.setattr(sp, "run", no_git)
monkeypatch.setattr(FlowStore, "_warned_no_git", False)
store = FlowStore(tmp_path / "flows")
assert store.head() == ""
store.write_requirements("numpy\n")
assert store.read_requirements() == "numpy\n"
def test_run_arguments_are_typed_by_the_flow_they_are_for() -> None:
"""`--lr 0.05` is a float because the flow says `lr` is one."""
from fluksio.sdk import SyncError
from fluksio.sdk.cli import _params
definition = {
"inputs": [
{"spec": {"name": "lr", "dtype": "float"}},
{"spec": {"name": "epochs", "dtype": "int"}},
{"spec": {"name": "resume", "dtype": "bool"}},
]
}
assert _params(definition, ["--lr", "0.05", "--epochs", "3", "--resume"]) == {
"lr": 0.05,
"epochs": 3,
"resume": True,
}
assert _params(definition, ["--lr=1e-4"]) == {"lr": 0.0001}
import pytest
with pytest.raises(SyncError, match="not an input of this flow"):
_params(definition, ["--nonesuch", "1"])
def test_serve_uses_the_installation_the_directory_belongs_to(
tmp_path: Path, monkeypatch
) -> None:
"""A repository with its own venv gets its own engine, not the machine's."""
from fluksio import cli
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(cli, "DEFAULT_HOME", tmp_path / "home" / ".fluksio")
# Nothing above it yet: one is made here rather than in the home directory.
made = cli._data_dir(None)
assert made == (tmp_path / ".fluksio").resolve()
assert (made / ".gitignore").exists()
# And is then found again from anywhere below it.
deep = tmp_path / "src" / "deep"
deep.mkdir(parents=True)
monkeypatch.chdir(deep)
assert cli._data_dir(None) == made
# `--global` asks for the shared one even so.
assert (
cli._data_dir(None, shared=True) == (tmp_path / "home" / ".fluksio").resolve()
)
# And `--data-dir` still names any directory outright.
named = cli._data_dir(str(tmp_path / "named"))
assert named == (tmp_path / "named").resolve()
def test_enrolling_needs_only_a_claim_code() -> None:
"""The default portal is what makes first-run one flag rather than two."""
from fluksio.cli import DEFAULT_PORTAL, _parser
args = _parser().parse_args(["enroll", "ABC-123"])
assert args.portal == DEFAULT_PORTAL
assert DEFAULT_PORTAL.startswith("https://")
# And a portal of your own still wins.
mine = _parser().parse_args(["enroll", "ABC-123", "--portal", "https://hub.me"])
assert mine.portal == "https://hub.me"
def test_run_syncs_by_default_and_can_be_told_not_to() -> None:
from fluksio.cli import _parser
assert _parser().parse_args(["run", "train"]).no_sync is False
assert _parser().parse_args(["run", "train", "--no-sync"]).no_sync is True
def test_a_sweep_is_the_product_of_the_parameters_given() -> None:
"""`--param lr=0.1,0.01 --param epochs=1,2` is four runs, typed by the flow."""
import pytest
from fluksio.sdk import SyncError
from fluksio.sdk.cli import _grid
definition = {
"inputs": [
{"spec": {"name": "lr", "dtype": "float"}},
{"spec": {"name": "epochs", "dtype": "int"}},
]
}
grid = _grid(definition, ["lr=0.1,0.01", "epochs=1,2"], seed=7)
assert [entry["params"] for entry in grid] == [
{"lr": 0.1, "epochs": 1},
{"lr": 0.1, "epochs": 2},
{"lr": 0.01, "epochs": 1},
{"lr": 0.01, "epochs": 2},
]
assert all(entry["seed"] == 7 for entry in grid)
with pytest.raises(SyncError, match="not an input of this flow"):
_grid(definition, ["nonesuch=1"], seed=None)
with pytest.raises(SyncError, match="name=value"):
_grid(definition, ["lr"], seed=None)
def test_the_local_engine_is_asked_for_rather_than_guessed() -> None:
from fluksio.cli import _parser
parser = _parser()
assert parser.parse_args(["run", "train"]).local is False
assert parser.parse_args(["run", "train", "--local"]).local is True
assert parser.parse_args(["runs", "--local"]).local is True
assert parser.parse_args(["sweep", "train", "--param", "lr=1"]).local is False
def test_a_local_run_always_waits(monkeypatch) -> None:
"""The engine is this process, so a run nobody waits for is thrown away."""
from contextlib import contextmanager
from fluksio.cli import _parser
from fluksio.sdk import cli
submitted: dict[str, object] = {}
class FakeHandle:
id = "run-1"
status = "ok"
result: dict[str, object] = {}
def wait(self, timeout: float = 0.0) -> "FakeHandle":
submitted["waited"] = True
return self
class FakeClient:
def get_flow(self, name: str) -> dict[str, object]:
return {"definition": {"inputs": []}}
def submit(self, flow, params, seed=None, no_cache=False):
submitted["flow"] = flow
submitted["no_cache"] = no_cache
return FakeHandle()
def run(self, run_id: str) -> dict[str, object]:
return {"nodes": [{"status": "cached"}, {"status": "ok"}]}
@contextmanager
def fake_engine():
yield FakeClient()
monkeypatch.setattr(cli, "_engine_client", fake_engine)
args = _parser().parse_args(["run", "train", "--local", "--no-sync", "--no-cache"])
assert cli.cmd_run(args, []) == 0
assert submitted == {"flow": "train", "no_cache": True, "waited": True}
def test_ctrl_c_while_waiting_cancels_the_run(monkeypatch) -> None:
"""Interrupting means stop the run, not walk away leaving it going."""
from contextlib import contextmanager
from fluksio.cli import _parser
from fluksio.sdk import cli
cancelled: list[str] = []
class FakeHandle:
id = "run-1"
status = "running"
result: dict[str, object] = {}
def wait(self, timeout: float = 0.0):
raise KeyboardInterrupt
class FakeClient:
def get_flow(self, name: str) -> dict[str, object]:
return {"definition": {"inputs": []}}
def submit(self, flow, params, seed=None, no_cache=False):
return FakeHandle()
def cancel(self, run_id: str) -> None:
cancelled.append(run_id)
@contextmanager
def fake_engine():
yield FakeClient()
monkeypatch.setattr(cli, "_engine_client", fake_engine)
args = _parser().parse_args(["run", "train", "--local", "--no-sync"])
assert cli.cmd_run(args, []) == 130
assert cancelled == ["run-1"]