Files
app/backend/fluksio/sdk/cli.py
T
stroblmeandClaude Opus 5 302be52921
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m30s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m34s
Compose Smoke Test / test-compose (push) Failing after 12s
Playwright Tests / merge-reports (push) Failing after 2m49s
Let a jsonl export keep a record a value
`--format jsonl` existed, but `_cell` ran json.dumps at row-build time,
before a format was chosen — so a nested value was a string by then and
jsonl only re-escaped it, leaving a consumer against csv's 128KB field
limit either way. Stringifying moved to the csv writer, so csv is
byte-identical and jsonl carries json. The runs TUI followed, or a record
would draw as a Python repr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
2026-08-30 17:36:10 +02:00

1520 lines
55 KiB
Python

"""The `fluksio sync`, `run`, `runs`, `sweep`, `status` and `login` commands.
Kept beside the SDK rather than in `fluksio.cli`: these are the client half of
the tool, and none of them needs the engine to be importable — `--local`, which
does, imports it inside the branch that asked for it.
"""
from __future__ import annotations
import argparse
import csv
import getpass
import importlib
import itertools
import json
import pkgutil
import shutil
import sys
import time
from collections.abc import Callable, Iterable, Iterator
from contextlib import contextmanager, nullcontext
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
from fluksio import __version__
from fluksio.sdk import FLOWS, Flow, SyncError
from fluksio.sdk.client import (
GLOBAL_DATA_DIR,
RETRIES,
WAIT_TOLERANCE,
ApiError,
Client,
RunHandle,
data_dir,
login,
origin_of,
repo_root,
sync,
)
__all__ = ["add_parsers", "discover"]
#: Statuses worth a colour, and the SGR code each gets.
_COLORS = {
"ok": "32",
"error": "31",
"cached": "36",
"cancelled": "33",
"abandoned": "31",
"running": "36",
"queued": "33",
}
def _say(message: str = "") -> None:
print(message)
def _fail(message: str) -> int:
print(f"fluksio: {message}", file=sys.stderr)
return 1
#: What to try when nothing answered at all. By far the commonest reason is
#: that no engine is running, and the message said only that it was not.
NO_ENGINE = "Is one running? `fluksio serve` starts one."
def _unreachable(exc: Exception, note: str = NO_ENGINE) -> int:
"""The engine did not answer. Say so as a sentence, not a traceback."""
return _fail(
f"engine not answering ({type(exc).__name__}: {exc})"
+ (f". {note}" if note else "")
)
def _status(text: str, width: int = 0) -> str:
"""A status, coloured when a terminal is reading it.
Padded before it is coloured: the escape sequences are characters as far
as `str.format` is concerned, and a column that lines up in a pipe would
not line up on screen.
"""
body = f"{text:<{width}}" if width else text
code = _COLORS.get(text)
if not code or not sys.stdout.isatty():
return body
return f"\033[{code}m{body}\033[0m"
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
def _package_of(directory: Path) -> tuple[str, str]:
"""The path root and dotted name of a package directory."""
parts = [directory.name]
parent = directory.parent
while (parent / "__init__.py").exists():
parts.append(parent.name)
parent = parent.parent
return str(parent), ".".join(reversed(parts))
def _module_of(path: Path) -> tuple[str, str]:
"""The path root and dotted name of a module file."""
parts = [path.stem]
directory = path.parent
while (directory / "__init__.py").exists():
parts.append(directory.name)
directory = directory.parent
return str(directory), ".".join(reversed(parts))
#: Directories a walk never goes into: none of them is a study, and some of
#: them are enormous.
_SKIP_DIRS = frozenset({"__pycache__", "node_modules", "site-packages"})
def _import(root: str, dotted: str, expect: Path | None = None) -> None:
if root not in sys.path:
sys.path.insert(0, root)
module = importlib.import_module(dotted)
if expect is None:
return
# Python keeps one module per name, so a second file importing under a
# name already taken is silently the first one — and the generated body
# imports by that name too, so a worker would run the wrong study's code.
actual = getattr(module, "__file__", "") or ""
if actual and Path(actual).resolve() != expect.resolve():
raise SyncError(
f"two files would both import as '{dotted}':\n"
f" {actual}\n {expect}\n"
"Python keeps one module per name, and a node's generated body "
"imports by that name, so the second would run the first's code. "
"Sync the directory they are both under — each then imports as "
"'<dir>.<file>' — or rename one of the files."
)
def _walkable(entry: Path) -> bool:
"""Whether a walk should look inside this directory at all."""
return (
entry.is_dir()
and not entry.name.startswith(".")
and entry.name not in _SKIP_DIRS
and not (entry / "pyvenv.cfg").exists()
)
def _below(root: Path) -> Iterator[Path]:
"""Every module and package under a plain directory, however deep.
One directory per study — `dev/s1_baseline/study.py` — is a layout people
have, and naming each of them on the command line is bookkeeping the tool
can do. A package is yielded whole and not descended into: its own walk
imports its submodules under the right names.
"""
for entry in sorted(root.iterdir()):
if entry.is_file() and entry.suffix == ".py":
yield entry
elif _walkable(entry):
if (entry / "__init__.py").exists():
yield entry
else:
yield from _below(entry)
def _import_package(directory: Path) -> None:
"""A package and every module in it, by their dotted names."""
root, dotted = _package_of(directory)
_import(root, dotted, directory / "__init__.py")
for info in pkgutil.walk_packages(sys.modules[dotted].__path__, f"{dotted}."):
importlib.import_module(info.name)
def discover(targets: list[str], keep_going: bool = False) -> list[Flow]:
"""Import what was named and hand back the flows it declared.
Imported by dotted name with its root on the path, never from a file
location: the generated node bodies import the same way, and a module
loaded under a different name would generate an import that does not
resolve.
``keep_going`` warns about a module that will not import instead of
stopping, which is what a *run* wants: a study half-way through an edit
two directories away is not a reason to refuse to run this one. A
collision between two module names is never skipped — it would produce a
node body that imports the wrong file.
"""
for target in targets:
path = Path(target)
if not path.exists():
_import(str(Path.cwd()), target)
continue
path = path.resolve()
if path.is_file():
_import(*_module_of(path), path)
continue
if (path / "__init__.py").exists():
_import_package(path)
continue
# A plain directory — a repository root, or a directory of studies.
# Its own modules, the packages inside it, and the same again all the
# way down, so one folder per study needs no naming.
for entry in _below(path):
try:
if entry.is_dir():
_import_package(entry)
else:
# Named for where it sits under the directory being synced,
# so `dev/s1/study.py` imports as `s1.study` and one
# `study.py` per study collides with nothing. No
# `__init__.py` needed: the directories in between are
# namespace packages. A file at the root keeps its bare
# name, which is what it has always had.
dotted = ".".join(entry.relative_to(path).with_suffix("").parts)
_import(str(path), dotted, entry)
except SyncError:
# A name collision is never somebody else's problem: it would
# put the wrong file behind a node.
raise
except Exception as exc:
# Importing a module runs it, so this is whatever the study
# does at its top level, and a walk meets every study.
if keep_going:
_say(f"warning: skipped {entry}{type(exc).__name__}: {exc}")
continue
raise SyncError(
f"{entry} failed to import — {type(exc).__name__}: {exc}"
) from exc
return list(FLOWS.values())
# ---------------------------------------------------------------------------
# Which engine
# ---------------------------------------------------------------------------
@contextmanager
def _engine_client() -> Iterator[Client]:
"""The engine itself, in this process, behind the ordinary client.
Everything `fluksio serve` does apart from listening on a socket: the same
data directory, the same database, the same admin, the same lifespan. The
app is driven through its ASGI interface, so a run costs what it costs on
a served engine and lands in the same history — which is what makes the
stage cache carry across the two.
The engine only exists for the length of the command, so nothing here is
written back as a login: a stored token belongs to whichever engine
`fluksio login` was pointed at.
"""
# Imported here, not at module scope: everything else in this file is the
# client half and must keep working with no engine installed.
import logging
from fluksio.cli import _data_dir, _prepare, _print_new_admin
directory = _data_dir(None)
# Settings are read when the app is imported, so this comes first.
_prepare(directory)
# Nothing here goes over a network, so httpx logging each call as a
# request to "testserver" is noise that also happens to be untrue.
logging.getLogger("httpx").setLevel(logging.WARNING)
from datetime import timedelta
from fastapi.testclient import TestClient
from sqlmodel import Session
from fluksio.core import security
from fluksio.core.bootstrap import ensure_superuser
from fluksio.core.db import engine
with Session(engine) as session:
admin, generated = ensure_superuser(session)
admin_id = admin.id
if generated:
_print_new_admin(admin.email, generated)
token = security.create_access_token(admin_id, expires_delta=timedelta(hours=12))
from fluksio.main import app
# Entering the client is what runs the lifespan: the worker pool, the
# controller and the run service all start here and stop on the way out.
with TestClient(app) as http:
yield Client(http=http, token=token)
@contextmanager
def _client_for(args: argparse.Namespace, retries: int = RETRIES) -> Iterator[Client]:
"""The engine this command talks to: one running somewhere, or this one.
``retries=0`` is what a read passes: an engine that is not there otherwise
takes the backoff — seconds — to say so, and nothing in a read is worth
waiting out a restart for. A command that submits keeps them.
"""
if getattr(args, "local", False):
with _engine_client() as client:
yield client
else:
yield Client(url=args.url, token=args.token, retries=retries)
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_login(args: argparse.Namespace) -> int:
"""Only needed for an engine somewhere else — `serve` signs you in here."""
email = args.email or input("Email: ")
password = args.password or getpass.getpass("Password: ")
directory = GLOBAL_DATA_DIR.expanduser() if args.shared else data_dir()
try:
path = login(args.url, email, password, directory=directory)
except ApiError as exc:
return _fail(f"could not log in: {exc.detail}")
_say(f"Logged in to {args.url}; the token is in {path}.")
return 0
def cmd_sync(args: argparse.Namespace) -> int:
targets = args.targets or ["."]
try:
flows = discover(targets)
except (ImportError, SyncError) as exc:
return _fail(str(exc))
if not flows:
return _fail(
f"no flows declared in {', '.join(targets)} — a flow is a `Flow(...)` "
"at module level. Name the package if it is somewhere else: "
"`fluksio sync src/myresearch`."
)
repo = repo_root(targets[0])
origin = origin_of(repo)
if origin["dirty"]:
_say(f"warning: {repo} has uncommitted changes, so the stamp says -dirty")
if not origin["commit"]:
_say(f"warning: {repo} is not a git repository, so runs cannot name a commit")
if args.dry_run:
for target in flows:
_say(f"=== flow {target.name}")
_say(json.dumps(target.document(origin), indent=2))
for node_id, code in target.shims().items():
_say(f"=== {target.name}.{node_id}")
_say(code)
return 0
try:
client = Client(url=args.url, token=args.token)
reports = sync(
flows,
client,
origin=origin,
publish=not args.no_publish,
force=args.force,
)
except (SyncError, ApiError) as exc:
return _fail(str(exc))
except httpx.HTTPError as exc:
return _unreachable(exc, "Nothing was published; sync again when it is back.")
for report in reports:
if report.unchanged:
_say(f" {report.flow}: unchanged")
continue
what = "created" if report.created else "updated"
detail = ", ".join(report.changed)
# Nothing to publish is not the same as left as a draft, and saying
# the second when it was the first reads as work not finished.
state = "published" if report.published else "draft" if report.drafted else ""
_say(f" {report.flow}: {what} ({detail})" + (f" — {state}" if state else ""))
if any(report.forgot_code for report in reports):
engine = _engine_version(client)
_say(
" note: this engine did not store what each node's code reaches, "
"so its cache is still keyed on the whole repository. It is "
f"{engine or 'older'} and this client is {__version__} — "
"`pip install -U fluksio` there."
)
stamp = origin["commit"][:7] + ("-dirty" if origin["dirty"] else "")
_say(f"Stamped with {stamp or 'no commit'} from {repo}.")
return 0
def _coerce(value: str, dtype: str) -> Any:
if value.startswith("@run:") or value.startswith("sha256:"):
# A run's output, named rather than typed out. Sent as it stands: the
# engine turns it into the value itself, whatever type that is. Passing
# the whole thing as JSON still works, and is what a script that
# already has it in hand would do.
return value
if dtype == "int":
return int(value)
if dtype == "float":
return float(value)
if dtype == "bool":
return value.lower() in ("true", "1", "yes", "on")
if dtype == "str":
return value
return json.loads(value)
def _input_types(definition: dict[str, Any]) -> dict[str, str]:
"""What each of a flow's inputs is declared to be."""
return {
str(entry["spec"]["name"]): str(entry["spec"].get("dtype", "float"))
for entry in definition.get("inputs") or []
}
def _ask_params(definition: dict[str, Any]) -> dict[str, Any]:
"""The run dialog, in a terminal: one line per declared input.
An empty answer leaves the input out, which is what keeps its declared
value — the same thing the browser's dialog does with a field nobody
filled in. So pressing Enter through the lot runs the defaults.
"""
params: dict[str, Any] = {}
for entry in definition.get("inputs") or []:
spec = entry.get("spec") or {}
name = str(spec.get("name", ""))
if not name:
continue
dtype = str(spec.get("dtype", "float"))
initial = entry.get("initial")
shown = "" if initial is None else json.dumps(initial)
prompt = f"{name} ({dtype})" + (f" [{shown}]" if shown else "") + ": "
answer = input(prompt).strip()
if not answer:
continue
try:
params[name] = _coerce(answer, dtype)
except ValueError as exc:
raise SyncError(f"'{name}' takes {dtype}: {exc}") from exc
return params
def _params(definition: dict[str, Any], rest: list[str]) -> dict[str, Any]:
"""Turn `--lr 0.05` into a typed parameter, using the flow's own inputs."""
types = _input_types(definition)
# Collected as written and typed afterwards, so a name this flow does not
# have is refused by name rather than by whatever its value failed to parse
# as: `--param lr=0.002` is a sweep's spelling, and said so it reads as an
# input called `param` holding unparseable json.
raw: dict[str, str | bool] = {}
pending: str | None = None
for token in rest:
if token.startswith("--"):
if pending is not None:
# A flag with no value is a flag: `--resume` means true.
raw[pending] = True
name, sep, value = token[2:].partition("=")
# Only the name is spelled with dashes; a value may hold one, and
# `--lr=1e-4` is the case that says so.
pending = name.replace("-", "_")
if sep:
raw[pending] = value
pending = None
continue
if pending is None:
raise SyncError(f"unexpected argument '{token}'")
raw[pending] = token
pending = None
if pending is not None:
raw[pending] = True
unknown = sorted(set(raw) - set(types))
if unknown:
hint = ""
if unknown[0] == "param":
hint = (
" — one value is `--<name> <value>`; several is a sweep: "
"`fluksio sweep --param name=v1,v2`"
)
raise SyncError(
f"'{unknown[0]}' is not an input of this flow (it takes "
f"{', '.join(sorted(types)) or 'none'}){hint}"
)
params: dict[str, Any] = {}
for key, written in raw.items():
if isinstance(written, bool):
params[key] = written
continue
try:
params[key] = _coerce(written, types[key])
except ValueError as exc:
raise SyncError(f"'{key}' takes {types[key]}: {exc}") from exc
return params
def _sync_first(client: Client, targets: list[str] | None = None) -> None:
"""Upload what the working directory declares, before running it.
The reason a run exists is usually the edit that came before it, and
remembering to sync is remembering to do the thing the computer could have
done. So `run` syncs by default — including the worker refresh, which is
what makes an edit to your own package take effect at all.
The whole directory, downwards: from a repository root the flow is usually
in a study folder below, and the alternative is syncing by hand and then
running with `--no-sync`. The upload is already a no-op for a flow nothing
changed in, so the cost is importing the other studies — which `--sync`
narrows when that is not free.
A directory that declares nothing is not an error: a flow drawn on the
canvas is run the same way, and has nothing to upload.
"""
try:
flows = discover(targets or ["."], keep_going=True)
except (ImportError, SyncError) as exc:
# Do not fail a run for a module the run may not even need.
_say(f"warning: nothing synced — {exc}")
return
if not flows:
return
repo = repo_root((targets or ["."])[0])
reports = sync(flows, client, origin=origin_of(repo))
changed = [r for r in reports if not r.unchanged]
if changed:
_say(f"synced {', '.join(r.flow for r in changed)}")
def _follow(client: Client, handle: RunHandle, poll: float = 1.0) -> None:
"""Print a run's numbers as they arrive, until it is over.
Polled rather than pushed: the engine writes a metric down when it is
reported, so asking once a second draws the same curve a socket would
have, without either side holding a connection open. The status is read
before the numbers, so the last batch is never the one that gets missed.
"""
seen: set[tuple[str, int]] = set()
failures = 0
while True:
try:
done = handle.refresh().done
points = client.metrics(handle.id)
except (httpx.HTTPError, ApiError) as exc:
# Following an eight-hour run must not end because one poll of it
# did. The run is still going; only this side lost sight of it.
if isinstance(exc, ApiError) and exc.status < 500:
raise
failures += 1
if failures >= WAIT_TOLERANCE:
raise
time.sleep(poll)
continue
failures = 0
for point in points:
mark = (str(point.get("name", "")), int(point.get("step", -1)))
if mark in seen:
continue
seen.add(mark)
_say(f" {mark[0]}[{mark[1]}] = {point['value']:g}")
if done:
return
time.sleep(poll)
def _cancel(client: Client, handle: RunHandle) -> int:
"""Ctrl-C means stop the run, not just stop watching it."""
try:
client.cancel(handle.id)
except (SyncError, ApiError, httpx.HTTPError) as exc:
return _fail(f"could not cancel {handle.id}: {exc}")
_say(f"{handle.id} {_status('cancelled')}")
return 130
def _cached_note(client: Client, handle: RunHandle) -> str:
"""How much of the run earlier ones had already answered."""
try:
nodes = client.run(handle.id).get("nodes") or []
except (SyncError, ApiError, httpx.HTTPError):
return ""
cached = sum(1 for node in nodes if node.get("status") == "cached")
return f" ({cached}/{len(nodes)} {_status('cached')})" if cached else ""
def cmd_run(args: argparse.Namespace, rest: list[str]) -> int:
# An in-process engine lives exactly as long as this command, so a run
# nobody waits for would be thrown away with the queue holding it.
wait = args.wait or args.follow or args.local
handle: RunHandle | None = None
try:
with _client_for(args) as client:
if not args.no_sync:
_sync_first(client, args.sync)
stored = client.get_flow(args.flow)
if stored is None:
return _fail(f"no flow '{args.flow}' on that engine")
definition = stored.get("definition") or {}
params = _params(definition, rest)
# Nothing on the command line and somebody watching: ask, the way
# pressing Run in the browser asks. A scripted run — piped, or
# carrying parameters already — is left exactly as it was.
if (
not params
and not args.defaults
and (definition.get("inputs") or [])
and sys.stdin.isatty()
):
params = _ask_params(definition)
handle = client.submit(
args.flow,
params,
seed=args.seed,
no_cache=args.no_cache,
cause="cli",
)
_say(f"{handle.id} queued {json.dumps(params)}")
if not wait:
return 0
try:
if args.follow:
_follow(client, handle)
else:
handle.wait(timeout=args.timeout)
except KeyboardInterrupt:
return _cancel(client, handle)
_say(
f"{handle.id} {_status(handle.status)} "
f"{json.dumps(handle.result)}{_cached_note(client, handle)}"
)
return 0 if handle.status == "ok" else 1
except (SyncError, ApiError) as exc:
return _fail(str(exc))
except httpx.HTTPError as exc:
# The run is the engine's, not this command's: it carries on, and its
# id is how to find it again.
return _unreachable(
exc, f"Run {handle.id} is still on the engine." if handle else NO_ENGINE
)
# ---------------------------------------------------------------------------
# Status
#
# The home screen's top half, in a terminal: how the engine is, what each flow
# is doing, and what failed recently. Drawn with rich, which is what the serve
# dashboard renders this same group into.
# ---------------------------------------------------------------------------
#: How often `--watch` asks again. The dashboard polls its summary every ten
#: seconds; nothing here moves faster than that.
WATCH_INTERVAL_S = 5.0
def _flow_state(flow: dict[str, Any]) -> str:
if flow.get("quarantined"):
return "quarantined"
if not flow.get("enabled", True):
return "stopped"
if flow.get("paused"):
return "paused"
return "running"
def _portal_phrase(portal: dict[str, Any]) -> tuple[str, str]:
"""Where this installation stands with its portal, and how to colour it.
Three states worth telling apart: never paired, paired and linked, and
paired but not reaching it — the last being the one somebody needs to know
about, since the dashboard is served from the other end.
"""
if not portal.get("enrolled"):
return "no portal", "dim"
if portal.get("connected"):
host = str(portal.get("portal_url") or "").split("//")[-1].rstrip("/")
return f"portal {host}", "green"
trouble = str(portal.get("last_error") or "").strip()
return "portal unreachable" + (f" ({trouble[:60]})" if trouble else ""), "red"
def _resource_lines(machines: dict[str, Any]) -> list[str]:
"""One line per machine: what it holds, and what is queued for it."""
targets = machines.get("targets") or []
if not targets:
return []
waiting = len(machines.get("waiting") or [])
lines = []
for target in targets:
parts = []
for label, level in (
("cpu", target.get("cpus")),
("gpu", target.get("gpus")),
):
# A machine with no card says nothing about cards.
if level and level.get("total"):
parts.append(
f"{label} {level['total'] - level['free']}/{level['total']}"
)
ram = target.get("ram_mb")
if ram and ram.get("total"):
parts.append(
f"ram {(ram['total'] - ram['free']) // 1024}/{ram['total'] // 1024}G"
)
lines.append(f"{str(target.get('target', '')):<16}" + " · ".join(parts))
if waiting:
lines[-1] += f" {waiting} waiting"
return [f"resources {lines[0]}"] + [f" {line}" for line in lines[1:]]
def _status_screen(client: Client) -> Any:
"""One frame: health, the flows, and the failures under them."""
from rich.console import Group
from rich.table import Table
from rich.text import Text
summary = client.summary()
flows = client.flows()
# Both, because an installation is usually one or the other: a live flow
# fails as an engine event, while a batch run fails on its own row.
failures = client.events(kind="failure", limit=5)
runs = client.runs(limit=5)
try:
portal = client.cloud_status()
except (SyncError, ApiError):
# Never enrolled, or an engine too old to answer. Neither is worth
# failing a status screen over.
portal = {}
try:
machines = client.resources()
except (SyncError, ApiError):
# An engine that accounts nothing, or one too old to answer.
machines = {}
healthy = summary.get("status") == "ok"
head = Text()
head.append(
"healthy" if healthy else str(summary.get("status", "unknown")),
style="green" if healthy else "yellow",
)
problems = summary.get("problems") or []
if problems:
head.append(" " + " · ".join(str(p) for p in problems), style="yellow")
phrase, style = _portal_phrase(portal)
head.append(" " + phrase, style=style)
# What it is running, since a client is upgraded on its own and a route
# this one knows may not be there. Absent from an engine older than the
# field itself, which is the answer in its own way.
head.append(f" v{summary.get('version') or '?'}", style="dim")
counts = summary.get("flows") or {}
nodes = summary.get("nodes") or {}
queue = summary.get("queue") or {}
lag = summary.get("loop_lag") or {}
facts = Text(
f"{counts.get('running', 0)}/{counts.get('total', 0)} flows running "
f"{nodes.get('total', 0)} nodes"
+ (f", {nodes['error']} failed" if nodes.get("error") else "")
+ f" queue {queue.get('pending', 0)} pending"
+ (f", {queue['parked']} parked" if queue.get("parked") else "")
+ f" loop lag {lag.get('ewma', 0.0) * 1000:.0f}ms",
style="dim",
)
table = Table(box=None, pad_edge=False, header_style="dim")
table.add_column("flow")
table.add_column("state")
table.add_column("nodes", justify="right")
table.add_column("")
for flow in sorted(flows, key=lambda one: str(one.get("name", ""))):
state = _flow_state(flow)
notes = []
if flow.get("error_count"):
notes.append(f"{flow['error_count']} in error")
if flow.get("has_draft"):
notes.append("unpublished changes")
table.add_row(
str(flow.get("title") or flow.get("name", "")),
Text(
state,
style={"running": "green", "paused": "yellow"}.get(state, "red"),
),
str(flow.get("node_count", 0)),
Text(" · ".join(notes), style="red" if flow.get("error_count") else "dim"),
)
parts: list[Any] = [head, facts]
parts += [Text(line, style="dim") for line in _resource_lines(machines)]
parts.append("")
parts.append(
table if flows else Text("No flows yet. `fluksio sync` uploads yours.", "dim")
)
if runs:
parts += ["", Text("recent runs", style="dim")]
for row in runs:
state = str(row.get("status", ""))
parts.append(
Text(f" {str(row.get('id', ''))[-8:]} ")
+ Text(
f"{state:<9}",
style={"ok": "green", "cached": "cyan", "running": "cyan"}.get(
state, "red" if state == "error" else "yellow"
),
)
+ Text(
f"{str(row.get('flow', '')):<16}"
f"{_dur(row.get('duration_ms')):>8}"
f" {_ago(row.get('finished_at') or row.get('created_at')):>9}",
style="dim",
)
)
if failures:
parts += ["", Text("recent failures", style="dim")]
for event in failures:
where = " ".join(
str(event.get(key, "")) for key in ("flow", "node") if event.get(key)
)
parts.append(
Text(f" {where} ", style="red")
+ Text(f"{_ago(event.get('ts')):>9} ", style="dim")
+ Text(str(event.get("detail", ""))[:100], style="dim")
)
return Group(*parts)
def cmd_status(args: argparse.Namespace) -> int:
"""How the engine is doing, once or until Ctrl-C."""
from rich.console import Console
from rich.live import Live
console = Console()
try:
# A watch outlives a blip and should ride one out; a single screen is
# a read, and says "not answering" at once.
with _client_for(args, retries=RETRIES if args.watch else 0) as client:
if not args.watch:
console.print(_status_screen(client))
return 0
if not sys.stdout.isatty():
return _fail("--watch needs a terminal; without one, drop it")
if getattr(args, "local", False):
# An in-process engine is this command and nothing else, so
# nothing can change under it while it watches.
return _fail("--watch wants an engine that outlives it; drop --local")
with Live(_status_screen(client), console=console) as live:
while True:
time.sleep(WATCH_INTERVAL_S)
live.update(_status_screen(client))
except KeyboardInterrupt:
return 130
except (SyncError, ApiError) as exc:
return _fail(str(exc))
except httpx.HTTPError as exc:
return _unreachable(exc)
#: How much of a run's inputs the list shows when nothing says how wide the
#: terminal is. `client.runs()` and `fluksio export runs` are where the whole
#: value is read.
PARAMS_WIDTH = 80
#: What the columns before the inputs take: the id, status, flow, duration,
#: age and stamp, with their spacing.
LISTING_WIDTH = 95
def _declared(client: Client, flows: Iterable[str]) -> dict[str, dict[str, Any]]:
"""What each flow declares its inputs to be, by flow.
So the listing can leave out an input that is simply the declared value:
what a reader is looking for across a page of runs is where they differ,
and a flow taking a few kB of JSON crowds that off the line.
"""
known: dict[str, dict[str, Any]] = {}
for name in flows:
try:
stored = client.get_flow(name) or {}
except (SyncError, ApiError, httpx.HTTPError):
# A flow deleted since its runs were recorded still lists them.
stored = {}
known[name] = {
str((entry.get("spec") or {}).get("name", "")): entry.get("initial")
for entry in (stored.get("definition") or {}).get("inputs") or []
}
return known
def _dur(ms: Any) -> str:
"""How long something took, in the same notation `_ago` reads an age in.
A run measured in hours used to print four digits of seconds, because the
three places that formatted a duration each did it their own way.
"""
seconds = max(float(ms or 0.0), 0.0) / 1000
for span, unit in ((86400, "d"), (3600, "h"), (60, "min")):
if seconds >= span:
return f"{seconds / span:.1f}{unit}"
return f"{seconds:.1f}s"
def _ago(stamp: Any) -> str:
"""How long ago something happened, in the notation the screens use.
A duration and an age read together, so they are spelled the same way:
seconds, then minutes, hours, days.
"""
if not stamp:
return ""
try:
then = datetime.fromisoformat(str(stamp))
except ValueError:
return ""
if then.tzinfo is None:
# Everything the engine records is UTC; only some spellings say so.
then = then.replace(tzinfo=UTC)
seconds = max((datetime.now(UTC) - then).total_seconds(), 0)
for span, unit in ((86400, "d"), (3600, "h"), (60, "min")):
if seconds >= span:
return f"{seconds / span:.0f}{unit} ago"
return f"{seconds:.0f}s ago"
def _stamp(row: dict[str, Any]) -> str:
"""What code a run ran: the commit, whether it was dirty, and the digest.
The digest is the part that separates two runs of one uncommitted tree —
the commit says `-dirty` for both, however much changed in between.
"""
commit, dirty, _ = (row.get("origin_commit") or "").partition("-dirty")
digest = row.get("code_digest") or ""
return (
commit[:7] + ("-dirty" if dirty else "") + (f"+{digest[:7]}" if digest else "")
)
def cmd_runs(args: argparse.Namespace) -> int:
try:
with _client_for(args, retries=0) as client:
rows = client.runs(flow=args.flow, limit=args.limit)
declared = _declared(client, {str(row["flow"]) for row in rows})
except (SyncError, ApiError) as exc:
return _fail(str(exc))
except httpx.HTTPError as exc:
return _unreachable(exc)
# Whatever is left of the line after the fixed columns; the fallback is
# what a pipe gets, since there is no width to ask for there.
room = max(
20,
shutil.get_terminal_size((LISTING_WIDTH + PARAMS_WIDTH, 24)).columns
- LISTING_WIDTH,
)
for row in rows:
given = row["params"] or {}
defaults = declared.get(str(row["flow"])) or {}
shown = {
name: value
for name, value in given.items()
if name not in defaults or defaults[name] != value
}
params = json.dumps(shown)
if len(params) > room:
params = params[: room - 3] + "..."
_say(
f"{row['id']} {_status(row['status'], 9)} {row['flow']:<16} "
f"{_dur(row['duration_ms']):>8} {_ago(row.get('created_at')):>9} "
f"{_stamp(row):<22} {params}"
)
return 0
def _artifacts(client: Client, args: argparse.Namespace) -> int:
"""List a run's files, or write one of them here."""
handle = RunHandle(client, args.run_id, client.run(args.run_id))
rows = handle.artifacts
if not args.name:
if not rows:
_say("This run produced no artifacts.")
return 0
for row in rows:
named = row.get("filename") or ""
_say(
f"{str(row['name']):<24} {row['size']:>10} B "
f"{row.get('media_type', ''):<24} {named}"
)
return 0
data = handle.download(args.name)
# The name it was written under reads better than the message's, which is
# chosen for the graph; `--out` beats both.
match = next((row for row in rows if row.get("name") == args.name), {})
out = Path(args.out or match.get("filename") or args.name)
out.write_bytes(data)
_say(f"{out} {len(data)} bytes")
return 0
def cmd_artifacts(args: argparse.Namespace) -> int:
try:
with _client_for(args, retries=0) as client:
return _artifacts(client, args)
except KeyError as exc:
return _fail(str(exc.args[0]))
except (SyncError, ApiError) as exc:
return _fail(str(exc))
except httpx.HTTPError as exc:
return _unreachable(exc)
def cmd_flavors(args: argparse.Namespace) -> int:
"""The named sizes a node can ask for."""
try:
with _client_for(args, retries=0) as client:
rows = client.flavors()
except (SyncError, ApiError) as exc:
return _fail(str(exc))
except httpx.HTTPError as exc:
return _unreachable(exc)
if not rows:
_say("No flavors. A node can still say cpus and gpus itself.")
return 0
for row in rows:
cards = f"{row['gpus']:>3} gpu" if row.get("gpus") else " " * 7
_say(
f"{row['name']:<14}{row['cpus']:>3} cpu {row['ram'] // 1024:>5} GB"
f"{cards} {row.get('description', '')}"
)
return 0
def _grid(
definition: dict[str, Any], values: list[str], seed: int | None
) -> list[dict[str, Any]]:
"""`--param lr=0.1,0.01 --param epochs=10,50` — every combination of them."""
types = _input_types(definition)
names: list[str] = []
columns: list[list[Any]] = []
for raw in values:
name, sep, listed = raw.partition("=")
name = name.replace("-", "_")
if not sep or not listed:
raise SyncError(f"--param takes name=value,value — got '{raw}'")
if name not in types:
raise SyncError(
f"'{name}' is not an input of this flow (it takes "
f"{', '.join(sorted(types)) or 'none'})"
)
names.append(name)
columns.append([_coerce(item, types[name]) for item in listed.split(",")])
return [
{"params": dict(zip(names, combination, strict=True)), "seed": seed}
for combination in itertools.product(*columns)
]
def cmd_sweep(args: argparse.Namespace) -> int:
wait = args.wait or args.local
try:
with _client_for(args) as client:
if not args.no_sync:
_sync_first(client, args.sync)
stored = client.get_flow(args.flow)
if stored is None:
return _fail(f"no flow '{args.flow}' on that engine")
entries = _grid(stored.get("definition") or {}, args.param, args.seed)
handles = client.sweep(args.flow, entries, no_cache=args.no_cache)
for handle, entry in zip(handles, entries, strict=True):
_say(f"{handle.id} queued {json.dumps(entry['params'])}")
if not wait:
return 0
failed = 0
try:
for handle in handles:
handle.wait(timeout=args.timeout)
_say(
f"{handle.id} {_status(handle.status)} "
f"{json.dumps(handle.result)}"
)
failed += handle.status != "ok"
except KeyboardInterrupt:
for handle in handles:
if not handle.refresh().done:
_cancel(client, handle)
return 130
return 1 if failed else 0
except (SyncError, ApiError) as exc:
return _fail(str(exc))
except httpx.HTTPError as exc:
return _unreachable(exc, "The runs are still on the engine; `fluksio runs`.")
def _selection(args: argparse.Namespace) -> dict[str, Any]:
"""The filters both exports share, minus the ones left empty."""
return {
key: value
for key, value in (
("status", args.status),
("group", args.group),
("since", args.since),
("until", args.until),
)
if value
}
def _write_rows(rows: list[dict[str, Any]], fmt: str, out: str, hint: str = "") -> int:
"""The exported rows, in the format asked for, to a file or to stdout.
The engine settles the columns over the whole selection before it sends
anything, so every row carries the same keys in the same order and the
first row's are the header.
"""
if not rows:
empty = "fluksio: nothing matched, so nothing was written"
print(empty + (f". {hint}" if hint else ""), file=sys.stderr)
return 0
if fmt == "parquet":
try:
import pyarrow
import pyarrow.parquet
except ImportError:
return _fail(
"parquet needs pyarrow — `pip install 'fluksio[parquet]'`, or "
"export csv and convert it"
)
pyarrow.parquet.write_table(pyarrow.Table.from_pylist(rows), out)
return 0
with open(out, "w", newline="") if out else nullcontext(sys.stdout) as handle:
if fmt == "jsonl":
handle.writelines(json.dumps(row) + "\n" for row in rows)
else:
# The wire is jsonl either way (see `Client._export`), so a record
# a jsonl row keeps as a value is still one here — stringify it
# for the csv cell it has to sit in.
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(
{
key: value
if value is None or isinstance(value, (str, int, float, bool))
else json.dumps(value)
for key, value in row.items()
}
for row in rows
)
return 0
#: How many runs `--list` reads before giving up on finding a metric name.
#: The names belong to the flow's nodes rather than to a run, so the newest
#: one that recorded any is the whole vocabulary — the rest are for a
#: selection whose newest runs failed before they measured anything.
# ponytail: the first run with names wins; a name only an older run recorded
# is not listed. A `distinct` over the selection would be exact and is a route
# of its own.
LIST_SCAN = 10
def metric_names(client: Client, ids: Iterable[str]) -> list[str]:
"""The metric names these runs carry, since a name is flow-qualified.
`train_loss` is recorded as `train.train_loss`, and asking for the bare
one matches nothing — so this is the answer to "what would match".
"""
for run_id in list(ids)[:LIST_SCAN]:
names = sorted({point["name"] for point in client.metrics(run_id)})
if names:
return names
return []
def _list_names(client: Client, args: argparse.Namespace) -> int:
filters = _selection(args)
if "until" in filters:
# The history spells the same bound `before`, where it is also the
# cursor a page is taken from.
filters["before"] = filters.pop("until")
ids = args.run or [
row["id"] for row in client.runs(flow=args.flow, limit=LIST_SCAN, **filters)
]
names = metric_names(client, ids)
_say("\n".join(names) if names else "No metrics recorded by these runs.")
return 0
def _engine_version(client: Client) -> str:
"""What the engine says it is, or empty if it is older than saying so."""
try:
return str((client.summary() or {}).get("version") or "")
except (SyncError, ApiError, httpx.HTTPError):
return ""
def _too_old(client: Client) -> str:
"""A route this client knows and the engine does not."""
version = _engine_version(client)
engine = f"the engine is {version}" if version else "the engine is older"
return (
f"this engine has no export endpoints — {engine} and this client is "
f"{__version__}. Upgrade it: `pip install -U fluksio`"
)
def _export(
args: argparse.Namespace, fetch: Callable[[Client], list[dict[str, Any]]]
) -> int:
"""Both exports: fetch what was asked for, then write it."""
if args.format == "parquet" and not args.out:
return _fail("--format parquet writes a file; name it with -o FILE")
hint = (
"`--list` names the metrics these runs carry"
if getattr(args, "name", "")
else ""
)
try:
with _client_for(args, retries=0) as client:
if getattr(args, "list_names", False):
return _list_names(client, args)
try:
rows = fetch(client)
except ApiError as exc:
if exc.status != 404:
raise
return _fail(_too_old(client))
except (SyncError, ApiError) as exc:
return _fail(str(exc))
except httpx.HTTPError as exc:
return _unreachable(exc)
return _write_rows(rows, args.format, args.out, hint)
def cmd_export_metrics(args: argparse.Namespace) -> int:
"""Every selected run's numbers as one long table."""
return _export(
args,
lambda client: client.export_metrics(
flow=args.flow,
ids=args.run,
name=args.name,
stride=args.stride,
**_selection(args),
),
)
def cmd_export_runs(args: argparse.Namespace) -> int:
"""One row per run: its inputs, its final numbers, what it ran."""
return _export(
args,
lambda client: client.export_runs(
flow=args.flow,
ids=args.run,
params=args.params,
metrics=args.metrics,
**_selection(args),
),
)
# ---------------------------------------------------------------------------
# Wiring
# ---------------------------------------------------------------------------
def add_parsers(subparsers: Any) -> None:
"""Register the client commands on `fluksio`'s parser."""
def with_engine(sub: argparse.ArgumentParser, local: bool = False) -> None:
sub.add_argument(
"--url", default="", help="the engine (default: the last login)"
)
sub.add_argument("--token", default="", help="override the stored token")
if local:
sub.add_argument(
"--local",
action="store_true",
help="boot the engine in this process instead of talking to one",
)
parser = subparsers.add_parser(
"login", help="store a token for an engine elsewhere"
)
parser.add_argument("--url", default="http://localhost:8000")
parser.add_argument("--email", default="")
parser.add_argument("--password", default="")
parser.add_argument(
"--global",
dest="shared",
action="store_true",
help="store it for the machine rather than this project",
)
parser.set_defaults(func=cmd_login)
parser = subparsers.add_parser(
"sync", help="upload the flows declared in your own code"
)
parser.add_argument(
"targets",
nargs="*",
help="modules, packages or directories to import (default: .)",
)
parser.add_argument(
"--dry-run", action="store_true", help="print what would be uploaded"
)
parser.add_argument(
"--no-publish", action="store_true", help="leave the changes as a draft"
)
parser.add_argument(
"--force", action="store_true", help="overwrite work done on the canvas"
)
with_engine(parser)
parser.set_defaults(func=cmd_sync)
parser = subparsers.add_parser(
"run", help="sync this directory, then start a run of one of its flows"
)
parser.add_argument("flow")
parser.add_argument(
"--seed",
type=int,
default=None,
help=(
"the run's seed: recorded on it, part of what tells two runs of one "
"configuration apart, and passed to an input named 'seed' when the "
"flow declares one"
),
)
parser.add_argument("--wait", action="store_true", help="block until it finishes")
parser.add_argument(
"--follow",
action="store_true",
help="wait, printing the numbers it reports as they arrive",
)
parser.add_argument(
"--timeout",
type=float,
default=0.0,
metavar="SECONDS",
help=(
"give up waiting after this long and leave the run going; 0, the "
"default, waits as long as it takes. Not the node timeout"
),
)
parser.add_argument(
"--no-sync",
action="store_true",
help="run what is already on the engine, without uploading first",
)
parser.add_argument(
"--sync",
action="append",
default=[],
metavar="PATH",
help="what to sync first (default: this directory, downwards)",
)
parser.add_argument(
"--no-cache",
action="store_true",
help="execute every node, even one an earlier run already answered",
)
parser.add_argument(
"--defaults",
action="store_true",
help="take every input's declared value instead of asking for it",
)
with_engine(parser, local=True)
parser.set_defaults(func=cmd_run)
parser = subparsers.add_parser(
"status", help="how the engine is doing, and what each flow is up to"
)
parser.add_argument(
"--watch",
action="store_true",
help="keep it on screen, refreshed until Ctrl-C",
)
# `--local` for the same reason `runs` has it: the flows and the history are
# in this directory, and reading them should not need a server.
with_engine(parser, local=True)
parser.set_defaults(func=cmd_status)
parser = subparsers.add_parser("runs", help="the runs an engine has recorded")
parser.add_argument("--flow", default="")
parser.add_argument("--limit", type=int, default=20)
with_engine(parser, local=True)
parser.set_defaults(func=cmd_runs)
parser = subparsers.add_parser(
"artifacts", help="the files a run produced; name one to download it"
)
parser.add_argument("run_id")
parser.add_argument("name", nargs="?", default="")
parser.add_argument(
"-o",
"--out",
default="",
metavar="PATH",
help="where to write it (default: the name it was saved under)",
)
with_engine(parser, local=True)
parser.set_defaults(func=cmd_artifacts)
parser = subparsers.add_parser(
"flavors", help="the named resource sizes a node can ask for"
)
with_engine(parser)
parser.set_defaults(func=cmd_flavors)
parser = subparsers.add_parser(
"sweep", help="one flow, once per combination of the parameters given"
)
parser.add_argument("flow")
parser.add_argument(
"--param",
action="append",
default=[],
metavar="NAME=V1,V2",
help="an input and the values to try; repeat for a grid",
)
parser.add_argument(
"--seed",
type=int,
default=None,
help="the seed every run in the sweep gets; vary it with --param seed=1,2",
)
parser.add_argument("--wait", action="store_true", help="block until all finish")
parser.add_argument(
"--timeout",
type=float,
default=0.0,
metavar="SECONDS",
help="give up waiting after this long; 0, the default, waits them out",
)
parser.add_argument(
"--no-sync",
action="store_true",
help="run what is already on the engine, without uploading first",
)
parser.add_argument(
"--sync",
action="append",
default=[],
metavar="PATH",
help="what to sync first (default: this directory, downwards)",
)
parser.add_argument(
"--no-cache",
action="store_true",
help="execute every node, even one an earlier run already answered",
)
with_engine(parser, local=True)
parser.set_defaults(func=cmd_sweep)
parser = subparsers.add_parser(
"export", help="runs and their numbers as a table an analysis reads"
)
exports = parser.add_subparsers(dest="table", required=True)
def with_selection(sub: argparse.ArgumentParser) -> None:
sub.add_argument("--flow", default="", help="only runs of this flow")
sub.add_argument(
"--run",
action="append",
default=[],
metavar="ID",
help="only this run; repeat for several",
)
sub.add_argument("--group", default="", help="only the runs of this sweep")
sub.add_argument("--status", default="", help="only runs that ended this way")
sub.add_argument(
"--since", default="", metavar="TS", help="only runs created at or after"
)
sub.add_argument(
"--until", default="", metavar="TS", help="only runs created before"
)
sub.add_argument("--format", default="csv", choices=("csv", "jsonl", "parquet"))
sub.add_argument(
"-o", "--out", default="", metavar="FILE", help="write here, not to stdout"
)
with_engine(sub, local=True)
sub = exports.add_parser(
"metrics", help="one row per run, metric and step — the tidy shape"
)
with_selection(sub)
sub.add_argument("--name", default="", metavar="A,B", help="only these metrics")
sub.add_argument(
"--stride",
type=int,
default=1,
help="keep every Nth point of each curve",
)
sub.add_argument(
"--list",
dest="list_names",
action="store_true",
help="print the metric names these runs carry, and stop",
)
sub.set_defaults(func=cmd_export_metrics)
sub = exports.add_parser(
"runs", help="one row per run: its inputs, its final numbers, its status"
)
with_selection(sub)
sub.add_argument(
"--params",
default="",
metavar="A,B",
help=(
"the inputs to put in columns, dotted into a record "
"(default: every input the runs recorded)"
),
)
sub.add_argument(
"--metrics",
default="",
metavar="A,B",
help="the final numbers to keep, e.g. final_metrics.train_loss",
)
sub.set_defaults(func=cmd_export_runs)