Docs / docs (push) Successful in 27s
Playwright Tests / test-playwright (1, 2) (push) Failing after 17s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 1m59s
Test Backend / test-backend (push) Failing after 2m30s
Compose Smoke Test / test-compose (push) Failing after 13s
Playwright Tests / merge-reports (push) Failing after 2m19s
serve: refuse a second engine for one data directory whatever port it was asked for, using the pidfile and a token this directory signed. The check runs before the database is touched and before the credential is written, which is what left every later CLI call pointing at a dead port. The terminal dashboard is three tabs (Overview, Runs, Logs) with the toolbar following the focused pane, the engine's output goes to serve.log rather than down a pipe, and closing the screen stops both reader threads so the prompt comes back. It adopts a running engine on every start, so stop/start and restart work on one it did not start, and a stop waits for the process to be gone before the next start. Enrolment reports itself in the modal. enroll: a new claim code replaces the pairing instead of being refused. The code is redeemed before anything is written, mappings to a portal being left are cleared, and a running engine redials when the stored enrolment changes. runs: an engine re-queues the runs left `queued` by the one before it, and `fluksio retry <id>` / `retry --group <sweep>` submits an interrupted run again with the same inputs and group, recorded through Run.parent_id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U9BoNGq6V9MdRWAte7JBuC
857 lines
29 KiB
Python
857 lines
29 KiB
Python
"""The command line, and the one ordering it depends on."""
|
|
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
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_a_sweeps_param_spelling_is_refused_by_name() -> None:
|
|
"""`run --param lr=0.002` is a name this flow has not got, and says so."""
|
|
import pytest
|
|
|
|
from fluksio.sdk import SyncError
|
|
from fluksio.sdk.cli import _params
|
|
|
|
definition = {"inputs": [{"spec": {"name": "lr", "dtype": "float"}}]}
|
|
|
|
# Not a JSONDecodeError over `lr=0.002`, which is what reading the value
|
|
# before the name used to give.
|
|
with pytest.raises(SyncError, match="sweep --param"):
|
|
_params(definition, ["--param", "lr=0.002"])
|
|
|
|
with pytest.raises(SyncError, match="'lr' takes float"):
|
|
_params(definition, ["--lr", "fast"])
|
|
|
|
|
|
def test_serve_uses_the_instance_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, cause="sdk"):
|
|
submitted["flow"] = flow
|
|
submitted["no_cache"] = no_cache
|
|
submitted["cause"] = cause
|
|
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
|
|
# "cli" rather than the SDK's default: the history says which asked.
|
|
assert submitted == {
|
|
"flow": "train",
|
|
"no_cache": True,
|
|
"waited": True,
|
|
"cause": "cli",
|
|
}
|
|
|
|
|
|
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, cause="sdk"):
|
|
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"]
|
|
|
|
|
|
def test_a_failed_run_prints_what_each_node_said(monkeypatch, capsys) -> None:
|
|
"""Otherwise the traceback is stored and nothing at a terminal shows it."""
|
|
from contextlib import contextmanager
|
|
|
|
from fluksio.cli import _parser
|
|
from fluksio.sdk import cli
|
|
|
|
class FakeHandle:
|
|
id = "run-1"
|
|
status = "error"
|
|
result: dict[str, object] = {}
|
|
failures = [
|
|
{
|
|
"node": "study.train",
|
|
"error": "ValueError: no convergence",
|
|
"logs": 'Traceback (most recent call last):\n File "<node>"\n',
|
|
}
|
|
]
|
|
|
|
def wait(self, timeout: float = 0.0) -> "FakeHandle":
|
|
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, cause="sdk"):
|
|
return FakeHandle()
|
|
|
|
def run(self, run_id: str) -> dict[str, object]:
|
|
return {"nodes": []}
|
|
|
|
@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, []) == 1
|
|
|
|
printed = capsys.readouterr().out
|
|
assert "study.train" in printed
|
|
assert "ValueError: no convergence" in printed
|
|
assert "Traceback (most recent call last):" in printed
|
|
|
|
|
|
def test_an_artifact_input_may_be_named_rather_than_pasted() -> None:
|
|
"""The engine resolves either spelling; the CLI just stops mangling them."""
|
|
import json
|
|
|
|
from fluksio.sdk.cli import _coerce
|
|
|
|
assert _coerce("@run:123-abc.dataset", "artifact") == "@run:123-abc.dataset"
|
|
digest = "sha256:" + "a1" * 32
|
|
assert _coerce(digest, "artifact") == digest
|
|
# A reference a script already holds still arrives as the object it is.
|
|
reference = {"digest": digest, "size": 3}
|
|
assert _coerce(json.dumps(reference), "artifact") == reference
|
|
|
|
|
|
def test_the_run_prompt_keeps_declared_values_for_anything_left_blank() -> None:
|
|
"""Enter through the lot is what running with the defaults looks like."""
|
|
from unittest.mock import patch
|
|
|
|
from fluksio.sdk.cli import _ask_params
|
|
|
|
definition = {
|
|
"inputs": [
|
|
{"spec": {"name": "lr", "dtype": "float"}, "initial": 0.05},
|
|
{"spec": {"name": "epochs", "dtype": "int"}, "initial": 10},
|
|
]
|
|
}
|
|
|
|
with patch("builtins.input", side_effect=["", ""]):
|
|
# Nothing sent: an input the caller leaves out keeps what it declares,
|
|
# which is the same contract the browser's dialog has.
|
|
assert _ask_params(definition) == {}
|
|
|
|
with patch("builtins.input", side_effect=["0.01", "50"]):
|
|
assert _ask_params(definition) == {"lr": 0.01, "epochs": 50}
|
|
|
|
|
|
def test_the_run_prompt_names_an_answer_of_the_wrong_type() -> None:
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from fluksio.sdk import SyncError
|
|
from fluksio.sdk.cli import _ask_params
|
|
|
|
definition = {"inputs": [{"spec": {"name": "lr", "dtype": "float"}}]}
|
|
with patch("builtins.input", side_effect=["fast"]):
|
|
with pytest.raises(SyncError, match="lr"):
|
|
_ask_params(definition)
|
|
|
|
|
|
def test_an_export_is_parsed_with_its_selection() -> None:
|
|
"""`export` is a group of two tables, and both take the same filters."""
|
|
from fluksio.cli import _parser
|
|
from fluksio.sdk.cli import cmd_export_metrics, cmd_export_runs
|
|
|
|
parser = _parser()
|
|
metrics = parser.parse_args(
|
|
[
|
|
"export",
|
|
"metrics",
|
|
"--flow",
|
|
"train",
|
|
"--run",
|
|
"a",
|
|
"--run",
|
|
"b",
|
|
"--name",
|
|
"loss,val",
|
|
"--stride",
|
|
"5",
|
|
]
|
|
)
|
|
assert metrics.func is cmd_export_metrics
|
|
assert metrics.run == ["a", "b"]
|
|
assert metrics.stride == 5
|
|
assert metrics.format == "csv"
|
|
|
|
runs = parser.parse_args(["export", "runs", "--params", "lr", "--format", "jsonl"])
|
|
assert runs.func is cmd_export_runs
|
|
assert runs.params == "lr"
|
|
assert runs.format == "jsonl"
|
|
|
|
|
|
def test_the_metric_names_are_asked_for_rather_than_guessed() -> None:
|
|
"""A name is flow-qualified, so `--list` is what says what would match."""
|
|
from fluksio.cli import _parser
|
|
from fluksio.sdk.cli import _list_names
|
|
|
|
parser = _parser()
|
|
assert parser.parse_args(["export", "metrics"]).list_names is False
|
|
assert parser.parse_args(["export", "metrics", "--list"]).list_names is True
|
|
|
|
class Engine:
|
|
def runs(self, flow="", limit=0, **filters):
|
|
assert filters == {"status": "ok"}
|
|
return [{"id": "r-empty"}, {"id": "r-1"}]
|
|
|
|
def metrics(self, run_id, name="", stride=1):
|
|
# The newest run failed before it measured anything; the next one
|
|
# carries the vocabulary.
|
|
return [] if run_id == "r-empty" else [{"name": "train.train_loss"}]
|
|
|
|
args = parser.parse_args(
|
|
["export", "metrics", "--flow", "train", "--status", "ok", "--list"]
|
|
)
|
|
assert _list_names(Engine(), args) == 0
|
|
|
|
|
|
def test_a_runs_artifact_is_listed_and_downloaded(tmp_path, monkeypatch) -> None:
|
|
"""`save_artifact` had no counterpart: the bytes were API-only."""
|
|
from fluksio.cli import _parser
|
|
from fluksio.sdk.cli import _artifacts
|
|
|
|
row = {
|
|
"name": "weights",
|
|
"node": "fit",
|
|
"digest": "sha256:abc",
|
|
"size": 3,
|
|
"media_type": "application/octet-stream",
|
|
"filename": "weights.npz",
|
|
}
|
|
|
|
class Engine:
|
|
def run(self, run_id):
|
|
assert run_id == "r-1"
|
|
return {"id": run_id, "status": "ok", "artifacts": [row]}
|
|
|
|
def download(self, digest):
|
|
assert digest == "sha256:abc"
|
|
return b"abc"
|
|
|
|
parser = _parser()
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
assert _artifacts(Engine(), parser.parse_args(["artifacts", "r-1"])) == 0
|
|
|
|
# Written under the name the node saved it as, not the message's.
|
|
assert _artifacts(Engine(), parser.parse_args(["artifacts", "r-1", "weights"])) == 0
|
|
assert (tmp_path / "weights.npz").read_bytes() == b"abc"
|
|
|
|
args = parser.parse_args(["artifacts", "r-1", "weights", "-o", "here.bin"])
|
|
assert _artifacts(Engine(), args) == 0
|
|
assert (tmp_path / "here.bin").read_bytes() == b"abc"
|
|
|
|
|
|
def test_an_engine_without_the_route_is_named_rather_than_404() -> None:
|
|
"""A client ships ahead of the engine; a flat 404 does not say so."""
|
|
from fluksio.sdk.cli import _too_old
|
|
|
|
class Old:
|
|
def summary(self):
|
|
return {"status": "ok", "version": "0.1.4"}
|
|
|
|
class Ancient:
|
|
def summary(self):
|
|
# Older than the field itself.
|
|
return {"status": "ok"}
|
|
|
|
assert "engine is 0.1.4" in _too_old(Old())
|
|
assert "engine is older" in _too_old(Ancient())
|
|
assert "pip install -U fluksio" in _too_old(Ancient())
|
|
|
|
|
|
def test_a_study_in_a_subfolder_is_found(tmp_path) -> None:
|
|
"""One directory per study is a layout; naming each is bookkeeping."""
|
|
from fluksio.sdk.cli import _below
|
|
|
|
(tmp_path / "dev" / "s1").mkdir(parents=True)
|
|
(tmp_path / "dev" / "s2" / "inner").mkdir(parents=True)
|
|
(tmp_path / "results").mkdir()
|
|
(tmp_path / "pkg").mkdir()
|
|
(tmp_path / ".hidden").mkdir()
|
|
(tmp_path / "dev" / "s1" / "study.py").write_text("")
|
|
(tmp_path / "dev" / "s2" / "inner" / "probe.py").write_text("")
|
|
(tmp_path / "results" / "notes.py").write_text("")
|
|
(tmp_path / "pkg" / "__init__.py").write_text("")
|
|
(tmp_path / "pkg" / "deep.py").write_text("")
|
|
(tmp_path / ".hidden" / "skip.py").write_text("")
|
|
|
|
found = {path.relative_to(tmp_path).as_posix() for path in _below(tmp_path)}
|
|
|
|
# However deep, plus packages whole — and nothing under a dot directory.
|
|
assert found == {
|
|
"dev/s1/study.py",
|
|
"dev/s2/inner/probe.py",
|
|
"results/notes.py",
|
|
"pkg",
|
|
}
|
|
|
|
|
|
def test_a_study_per_directory_imports_under_its_own_name(tmp_path) -> None:
|
|
"""One `study.py` per folder is a layout people have, and it works.
|
|
|
|
Named for where each sits under the directory being synced, so nothing
|
|
collides and no `__init__.py` has to be added — which would break the
|
|
bare `from study import ...` a test beside it does.
|
|
"""
|
|
import sys
|
|
|
|
from fluksio.sdk.cli import discover
|
|
|
|
for study in ("s1", "s2"):
|
|
(tmp_path / "dev" / study).mkdir(parents=True)
|
|
(tmp_path / "dev" / study / "study.py").write_text(f"VALUE = {study!r}\n")
|
|
|
|
try:
|
|
discover([str(tmp_path / "dev")])
|
|
assert sys.modules["s1.study"].VALUE == "s1"
|
|
assert sys.modules["s2.study"].VALUE == "s2"
|
|
finally:
|
|
for name in ("s1.study", "s2.study", "s1", "s2"):
|
|
sys.modules.pop(name, None)
|
|
sys.path[:] = [entry for entry in sys.path if entry != str(tmp_path / "dev")]
|
|
|
|
|
|
def test_two_files_of_one_name_are_refused(tmp_path) -> None:
|
|
"""Python keeps one module per name, and a node's body imports by it.
|
|
|
|
Unreachable from one sync of a directory now that a file is named for
|
|
where it sits; this is the spelling that still gets there — two files
|
|
named on the command line, each rooted at its own directory.
|
|
"""
|
|
import pytest
|
|
|
|
from fluksio.sdk import SyncError
|
|
from fluksio.sdk.cli import _import, _module_of
|
|
|
|
for study in ("s1", "s2"):
|
|
(tmp_path / study).mkdir()
|
|
(tmp_path / study / "study.py").write_text("VALUE = 1\n")
|
|
|
|
first = tmp_path / "s1" / "study.py"
|
|
second = tmp_path / "s2" / "study.py"
|
|
_import(*_module_of(first), first)
|
|
with pytest.raises(SyncError, match="both import as 'study'"):
|
|
_import(*_module_of(second), second)
|
|
|
|
|
|
def test_how_long_ago_reads_like_a_duration() -> None:
|
|
"""A failure with no time on it says nothing about whether it is current."""
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from fluksio.sdk.cli import _ago
|
|
|
|
def then(**delta):
|
|
return (datetime.now(UTC) - timedelta(**delta)).isoformat()
|
|
|
|
assert _ago(then(seconds=5)) == "5s ago"
|
|
assert _ago(then(minutes=3)) == "3min ago"
|
|
assert _ago(then(hours=2)) == "2h ago"
|
|
assert _ago(then(days=3)) == "3d ago"
|
|
# What the engine stores is UTC whether or not the spelling says so.
|
|
assert _ago(datetime.now(UTC).replace(tzinfo=None).isoformat()) == "0s ago"
|
|
assert _ago(None) == ""
|
|
|
|
|
|
def test_serve_says_when_a_flow_wants_a_card_nobody_declared(
|
|
tmp_path, monkeypatch, capsys
|
|
) -> None:
|
|
"""The clamp warning goes to the log; this is said while someone is reading.
|
|
|
|
Cards are declared rather than detected, so a fresh install that forgets
|
|
`--gpus` clamps a GPU node to zero and runs them all at once.
|
|
"""
|
|
from fluksio import cli
|
|
from fluksio.core.config import settings
|
|
from fluksio.flow.schemas import FlowDef, NodeDef, Resources
|
|
from fluksio.flow.store import FlowStore
|
|
|
|
store = FlowStore(tmp_path / "flows")
|
|
store.write_flow(
|
|
FlowDef(
|
|
name="finetune",
|
|
mode="batch",
|
|
nodes=[NodeDef(id="fit", type="python", resources=Resources(gpus=1))],
|
|
)
|
|
)
|
|
monkeypatch.setattr(settings, "FLOWS_DIR", tmp_path / "flows")
|
|
|
|
monkeypatch.setattr(settings, "FLOW_GPUS", 0)
|
|
cli._mention_undeclared_cards()
|
|
said = capsys.readouterr().out
|
|
assert "finetune asks for one" in said
|
|
assert "--gpus" in said
|
|
|
|
# Told how many there are, it has nothing to say.
|
|
monkeypatch.setattr(settings, "FLOW_GPUS", 1)
|
|
cli._mention_undeclared_cards()
|
|
assert capsys.readouterr().out == ""
|
|
|
|
|
|
def test_a_serve_limit_is_refused_as_a_flag_not_as_a_traceback(capsys) -> None:
|
|
"""These are written into the environment before the settings are built."""
|
|
import pytest
|
|
|
|
from fluksio.cli import _parser
|
|
|
|
parser = _parser()
|
|
assert parser.parse_args(["serve", "--max-workers", "2"]).max_workers == 2
|
|
# A machine may genuinely have no card, so zero is a number here.
|
|
assert parser.parse_args(["serve", "--gpus", "0"]).gpus == 0
|
|
assert parser.parse_args(["serve"]).gpus is None
|
|
|
|
for flag, value in (("--max-workers", "0"), ("--gpus", "-1")):
|
|
with pytest.raises(SystemExit):
|
|
parser.parse_args(["serve", flag, value])
|
|
assert "at least" in capsys.readouterr().err
|
|
|
|
|
|
def test_run_and_sweep_take_what_to_sync() -> None:
|
|
from fluksio.cli import _parser
|
|
|
|
parser = _parser()
|
|
assert parser.parse_args(["sweep", "train", "--sync", "dev/s1"]).sync == ["dev/s1"]
|
|
args, _ = parser.parse_known_args(["run", "train", "--sync", "dev/s1"])
|
|
assert args.sync == ["dev/s1"]
|
|
# Nothing named means this directory, downwards.
|
|
assert parser.parse_args(["sweep", "train"]).sync == []
|
|
|
|
|
|
def test_the_dashboard_runs_the_engine_as_a_child_of_itself(monkeypatch) -> None:
|
|
"""At a terminal `serve` is a dashboard; the engine is a plain serve.
|
|
|
|
Every flag is passed through, so what the child runs with is what serve
|
|
was asked for — and `--plain` is what stops it opening a second one.
|
|
"""
|
|
import sys
|
|
|
|
from fluksio import cli
|
|
from fluksio.tui import child_argv
|
|
|
|
argv = child_argv(["serve", "--port", "8123", "--gpus", "1"])
|
|
assert argv[:3] == [sys.executable, "-m", "fluksio.cli"]
|
|
assert argv[3:] == ["serve", "--port", "8123", "--gpus", "1", "--plain"]
|
|
# Already plain: told once, not twice.
|
|
assert child_argv(["serve", "--plain"])[3:] == ["serve", "--plain"]
|
|
|
|
opened: list[str] = []
|
|
monkeypatch.setattr(
|
|
"fluksio.tui.run_tui", lambda args: opened.append("tui") or 0, raising=False
|
|
)
|
|
monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False)
|
|
monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False)
|
|
|
|
parser = cli._parser()
|
|
assert cli.cmd_serve(parser.parse_args(["serve"])) == 0
|
|
assert opened == ["tui"]
|
|
|
|
# `--plain` goes past it, which is what the child and every container does.
|
|
# Nothing else of serve runs here, so it fails on the data directory it is
|
|
# given rather than opening a dashboard.
|
|
opened.clear()
|
|
monkeypatch.setattr(
|
|
cli, "_data_dir", lambda *a, **k: (_ for _ in ()).throw(SystemExit(3))
|
|
)
|
|
with __import__("pytest").raises(SystemExit):
|
|
cli.cmd_serve(parser.parse_args(["serve", "--plain"]))
|
|
assert opened == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_the_dashboard_is_three_tabs_and_lets_go_of_its_threads(
|
|
tmp_path, monkeypatch
|
|
) -> None:
|
|
"""The tabs, and what `q` has to do before the screen goes.
|
|
|
|
Its two readers are worker threads on the event loop's own executor, which
|
|
is joined while the loop closes — so a reader still blocked on the log file
|
|
or the websocket holds the terminal after the dashboard has gone, which is
|
|
what `q` used to do.
|
|
"""
|
|
from textual.widgets import TabbedContent
|
|
|
|
from fluksio import cli
|
|
from fluksio.tui.app import ServeApp
|
|
|
|
# Nothing is started under this screen: the engine has its own tests.
|
|
monkeypatch.setattr(ServeApp, "start_engine", lambda self: None)
|
|
args = cli._parser().parse_args(["serve", "--data-dir", str(tmp_path / ".fluksio")])
|
|
app = ServeApp(args)
|
|
async with app.run_test() as pilot:
|
|
tabs = app.query_one(TabbedContent)
|
|
assert tabs.active == "overview-tab"
|
|
await pilot.press("2")
|
|
assert tabs.active == "runs-tab"
|
|
# The run keys live on the table, so they are in the footer only here.
|
|
assert "app.pick" in {binding[1] for binding in app.query_one("#runs").BINDINGS}
|
|
await pilot.press("3")
|
|
assert tabs.active == "logs-tab"
|
|
await pilot.press("q")
|
|
|
|
assert app.stop_log.is_set()
|
|
assert app.stop_stream.is_set()
|
|
|
|
|
|
def test_a_serving_engine_records_itself_until_it_stops(tmp_path) -> None:
|
|
"""A pid nobody is running is the same as no pidfile at all."""
|
|
import os
|
|
|
|
from fluksio.cli import read_pidfile, write_pidfile
|
|
|
|
assert read_pidfile(tmp_path) is None
|
|
|
|
written = write_pidfile(tmp_path, 8000)
|
|
assert read_pidfile(tmp_path) == {"pid": os.getpid(), "port": 8000}
|
|
|
|
# Killed outright: the file outlives the process it names.
|
|
written.write_text('{"pid": 2147483646, "port": 8000}')
|
|
assert read_pidfile(tmp_path) is None
|
|
|
|
written.write_text("not json")
|
|
assert read_pidfile(tmp_path) is None
|
|
|
|
|
|
def test_who_holds_the_port_is_told_apart_by_the_token() -> None:
|
|
"""Only this directory's own engine may be reported as already up.
|
|
|
|
The token is signed with this directory's secret key, so an engine that
|
|
accepts it is one reading this directory's database. Another
|
|
instance's Fluksio answers the health check and refuses it.
|
|
"""
|
|
import httpx
|
|
|
|
from fluksio.cli import probe_engine
|
|
|
|
def engine(health: int, summary: int):
|
|
def handle(request: httpx.Request) -> httpx.Response:
|
|
if request.url.path.endswith("/health-check/"):
|
|
return httpx.Response(health)
|
|
return httpx.Response(summary)
|
|
|
|
return httpx.Client(transport=httpx.MockTransport(handle))
|
|
|
|
with engine(200, 200) as client:
|
|
assert probe_engine("http://x", "t", client) == "ours"
|
|
with engine(200, 401) as client:
|
|
assert probe_engine("http://x", "t", client) == "foreign"
|
|
# A directory with no credential yet cannot prove anything is its own, and
|
|
# `Bearer ` is not a legal header value — so it asks without one.
|
|
with engine(200, 401) as client:
|
|
assert probe_engine("http://x", "", client) == "foreign"
|
|
# Somebody else's dev server, or nothing listening at all.
|
|
with engine(404, 404) as client:
|
|
assert probe_engine("http://x", "t", client) == "other"
|
|
assert probe_engine("http://127.0.0.1:1", "t") == "other"
|
|
|
|
|
|
def test_a_second_engine_for_one_directory_is_refused(tmp_path, monkeypatch, capsys):
|
|
"""One SQLite file, one engine — whatever port the second was asked for.
|
|
|
|
The refusal is worth more than the duplicate it prevents: the second
|
|
engine signs in on the way up, so `client.json` would point at a port
|
|
that dies with it and every later command would reach nothing.
|
|
"""
|
|
import json
|
|
import os
|
|
|
|
from fluksio import cli
|
|
|
|
data_dir = tmp_path / ".fluksio"
|
|
data_dir.mkdir()
|
|
(data_dir / "client.json").write_text(
|
|
json.dumps({"url": "http://127.0.0.1:8000", "token": "the-first-one"})
|
|
)
|
|
cli.write_pidfile(data_dir, 8000)
|
|
monkeypatch.setattr(cli, "probe_engine", lambda *a, **k: "ours")
|
|
|
|
args = cli._parser().parse_args(
|
|
["serve", "--plain", "--port", "8123", "--data-dir", str(data_dir)]
|
|
)
|
|
assert cli.cmd_serve(args) == 0
|
|
said = capsys.readouterr().out
|
|
assert "already serving" in said and str(os.getpid()) in said
|
|
# The credential still names the engine that is actually up.
|
|
stored = json.loads((data_dir / "client.json").read_text())
|
|
assert stored["url"] == "http://127.0.0.1:8000"
|
|
|
|
# A pid that is alive but is not an engine of ours is not a refusal.
|
|
monkeypatch.setattr(cli, "probe_engine", lambda *a, **k: "foreign")
|
|
assert cli.already_serving(data_dir, "127.0.0.1") == ""
|
|
|
|
|
|
def test_serve_moves_off_a_port_that_is_taken() -> None:
|
|
"""A first start should not die on somebody else's dev server."""
|
|
import socket
|
|
|
|
from fluksio.cli import DEFAULT_PORT, _free_port, _parser
|
|
|
|
with socket.socket() as held:
|
|
held.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
held.bind(("127.0.0.1", 0))
|
|
held.listen(1)
|
|
taken = held.getsockname()[1]
|
|
|
|
assert _free_port("127.0.0.1", taken) == taken + 1
|
|
|
|
# A port that was asked for is not moved off: that is what asking means.
|
|
assert _parser().parse_args(["serve"]).port is None
|
|
assert _parser().parse_args(["serve", "--port", "9000"]).port == 9000
|
|
assert DEFAULT_PORT == 8000
|
|
|
|
|
|
def test_a_duration_reads_the_way_an_age_does() -> None:
|
|
"""One notation for both, rather than one per place that printed one."""
|
|
from fluksio.sdk.cli import _dur
|
|
|
|
assert _dur(None) == "0.0s"
|
|
assert _dur(1240) == "1.2s"
|
|
assert _dur(90_000) == "1.5min"
|
|
# A run measured in hours used to print four digits of seconds.
|
|
assert _dur(7_200_000) == "2.0h"
|
|
assert _dur(172_800_000) == "2.0d"
|
|
|
|
|
|
def test_the_dashboard_subscribes_where_the_browser_does() -> None:
|
|
"""The same socket, and the token where a handshake can carry it."""
|
|
from fluksio.sdk.stream import socket_url
|
|
|
|
assert socket_url("http://127.0.0.1:8000", "abc") == (
|
|
"ws://127.0.0.1:8000/api/v1/flows/ws?token=abc"
|
|
)
|
|
# TLS on the one side is TLS on the other.
|
|
assert socket_url("https://engine.example.com", "t").startswith("wss://")
|