From 9286573f3867049e8bdec863c7d85889e1bcebab Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 29 Aug 2026 13:48:23 +0200 Subject: [PATCH] Fetch a run's artifacts from the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `save_artifact` had no download counterpart: the run detail listed a run's files and nothing in `fluksio --help` fetched one. `fluksio artifacts RUN` lists them, `fluksio artifacts RUN NAME` writes one — under the name the node saved it as, since the message name is chosen for the graph. The run detail now carries that filename, which it held in the table and did not report. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc --- backend/fluksio/api/routes/runs.py | 4 +++ backend/fluksio/sdk/cli.py | 52 ++++++++++++++++++++++++++++++ backend/tests/test_cli.py | 37 +++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/backend/fluksio/api/routes/runs.py b/backend/fluksio/api/routes/runs.py index 370b681..bea6b67 100644 --- a/backend/fluksio/api/routes/runs.py +++ b/backend/fluksio/api/routes/runs.py @@ -104,6 +104,10 @@ class ArtifactRow(BaseModel): digest: str size: int media_type: str + #: What it was called where it was written, so something downloading it can + #: give it that name back rather than the message's. Absent when the node + #: never said one. + filename: str | None = None class RunRow(BaseModel): diff --git a/backend/fluksio/sdk/cli.py b/backend/fluksio/sdk/cli.py index df83cb4..66b22b1 100644 --- a/backend/fluksio/sdk/cli.py +++ b/backend/fluksio/sdk/cli.py @@ -923,6 +923,43 @@ def cmd_runs(args: argparse.Namespace) -> int: 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: @@ -1286,6 +1323,21 @@ def add_parsers(subparsers: Any) -> None: 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" ) diff --git a/backend/tests/test_cli.py b/backend/tests/test_cli.py index 9ef4c05..ce9e923 100644 --- a/backend/tests/test_cli.py +++ b/backend/tests/test_cli.py @@ -390,6 +390,43 @@ def test_the_metric_names_are_asked_for_rather_than_guessed() -> None: 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