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