apheris-foundry-cli 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/PKG-INFO +1 -1
  2. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/pyproject.toml +1 -1
  3. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/PKG-INFO +1 -1
  4. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/commands/foundry.py +55 -51
  5. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/hub_requests.py +5 -0
  6. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/result_artifacts.py +16 -0
  7. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/main.py +3 -3
  8. apheris_foundry_cli-0.4.0/src/apherisfold_cli/version.py +1 -0
  9. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_cli.py +185 -19
  10. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_hub_requests.py +16 -0
  11. apheris_foundry_cli-0.3.0/src/apherisfold_cli/version.py +0 -1
  12. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/LICENSE +0 -0
  13. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/README.md +0 -0
  14. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/setup.cfg +0 -0
  15. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/SOURCES.txt +0 -0
  16. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/dependency_links.txt +0 -0
  17. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/entry_points.txt +0 -0
  18. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/requires.txt +0 -0
  19. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/top_level.txt +0 -0
  20. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/__init__.py +0 -0
  21. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/commands/__init__.py +0 -0
  22. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/commands/login.py +0 -0
  23. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/__init__.py +0 -0
  24. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/auth.py +0 -0
  25. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/config.py +0 -0
  26. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/context.py +0 -0
  27. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/errors.py +0 -0
  28. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/io.py +0 -0
  29. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/login_profiles.py +0 -0
  30. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/render.py +0 -0
  31. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/handlers.py +0 -0
  32. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_auth.py +0 -0
  33. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_config.py +0 -0
  34. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_foundry_helpers.py +0 -0
  35. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_handlers.py +0 -0
  36. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_io.py +0 -0
  37. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_main.py +0 -0
  38. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_render.py +0 -0
  39. {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_result_artifacts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apheris-foundry-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Command-line interface for Apheris Foundry workflows.
5
5
  Author: Apheris
6
6
  License-Expression: LicenseRef-Apheris-Foundry-Software-License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "apheris-foundry-cli"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Command-line interface for Apheris Foundry workflows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apheris-foundry-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Command-line interface for Apheris Foundry workflows.
5
5
  Author: Apheris
6
6
  License-Expression: LicenseRef-Apheris-Foundry-Software-License
@@ -9,6 +9,7 @@ from io import BytesIO
9
9
  from pathlib import Path
10
10
  from typing import Any
11
11
 
12
+ import typer
12
13
  from rich import box
13
14
  from rich.console import Console, Group
14
15
  from rich.panel import Panel
@@ -16,9 +17,10 @@ from rich.table import Table
16
17
  from rich.text import Text
17
18
  from rich.tree import Tree
18
19
 
19
- from apherisfold_cli.core.errors import EXIT_PRECONDITION, EXIT_USAGE, CLIError
20
+ from apherisfold_cli.core.errors import EXIT_INTERNAL, EXIT_PRECONDITION, EXIT_USAGE, CLIError
20
21
  from apherisfold_cli.core.hub_requests import (
21
22
  create_foundry_run,
23
+ download_foundry_run_archive,
22
24
  download_foundry_run_artifact,
23
25
  get_foundry_run,
24
26
  get_foundry_run_logs,
@@ -706,7 +708,7 @@ def foundry_run(
706
708
  final = _poll_until_terminal(run_id)
707
709
  if json_output:
708
710
  run_simple_listing(task_name="foundry.run", payload=final)
709
-
711
+ _exit_if_failed_run(final)
710
712
  return
711
713
 
712
714
  final_status = str(final.get("status", ""))
@@ -714,6 +716,7 @@ def foundry_run(
714
716
  _CONSOLE.print(f"[{style}]Job {run_id} finished: {final_status}[/{style}]")
715
717
  if final.get("error"):
716
718
  _CONSOLE.print(f"[red]Error: {final['error']}[/red]")
719
+ _exit_if_failed_run(final)
717
720
 
718
721
 
719
722
  _DEFAULT_LIST_LIMIT = 30
@@ -959,19 +962,9 @@ def foundry_download_result(
959
962
  task_name = "foundry.predict.download-result"
960
963
  selected_result_view = validate_result_view(result_view, task=task_name)
961
964
  run = get_foundry_run(run_id)
962
- artifact = _resolve_download_artifact(run, artifact_id)
963
- if artifact is None:
964
- raise CLIError(
965
- code=EXIT_PRECONDITION,
966
- category="precondition",
967
- message=(
968
- f"No results.zip output is available for Foundry job {run_id}. "
969
- f"Check files with: apheris-foundry jobs get --id {run_id}"
970
- ),
971
- task=task_name,
972
- )
973
965
 
974
- if not _is_results_archive_artifact(artifact):
966
+ if artifact_id:
967
+ artifact = _resolve_artifact(run, artifact_id)
975
968
  _download_selected_artifact(
976
969
  run_id=run_id,
977
970
  artifact=artifact,
@@ -981,10 +974,9 @@ def foundry_download_result(
981
974
  )
982
975
  return
983
976
 
984
- archive_path = str(artifact.get("path", ""))
985
977
  destination = (output_dir or default_results_dir("foundry-predict", run_id)).expanduser().resolve()
986
978
  _ensure_download_dir(destination, task_name=task_name)
987
- archive_bytes = download_foundry_run_artifact(run_id, archive_path)
979
+ archive_bytes = download_foundry_run_archive(run_id)
988
980
  results = extract_results_archive(
989
981
  archive_bytes=archive_bytes,
990
982
  output_dir=destination,
@@ -1088,13 +1080,6 @@ def foundry_error(run_id: str) -> None:
1088
1080
  _CONSOLE.print(Group(*panels))
1089
1081
 
1090
1082
 
1091
- def _foundry_results_archive_path(run: dict[str, Any]) -> str:
1092
- artifact = _resolve_download_artifact(run, None)
1093
- if artifact is not None:
1094
- return str(artifact.get("path", ""))
1095
- return ""
1096
-
1097
-
1098
1083
  def _foundry_file_panels(run: dict[str, Any], *, result_preview: dict[str, Any] | None) -> list[Panel]:
1099
1084
  artifacts = _indexed_artifacts(run)
1100
1085
  workflow = str(run.get("workflow", ""))
@@ -1137,7 +1122,7 @@ def _foundry_commands_panel(run: dict[str, Any], *, result_preview: dict[str, An
1137
1122
  status = str(run.get("status", "")).lower()
1138
1123
  table = Table(box=box.SIMPLE_HEAD, show_header=False, padding=(0, 1), expand=False)
1139
1124
  table.add_column("command", style="cyan", overflow="fold")
1140
- has_results = _resolve_download_artifact(run, None) is not None
1125
+ has_results = _has_downloadable_results(run)
1141
1126
  if has_results:
1142
1127
  table.add_row(f"{command_prefix} jobs download --id {run_id}")
1143
1128
  table.add_row(f"{command_prefix} jobs get --id {run_id} --artifact-id <id>")
@@ -1217,13 +1202,10 @@ def _slug(value: str) -> str:
1217
1202
  return slug or "artifact"
1218
1203
 
1219
1204
 
1220
- def _resolve_download_artifact(run: dict[str, Any], artifact_id: str | None) -> dict[str, Any] | None:
1221
- if artifact_id:
1222
- return _resolve_artifact(run, artifact_id)
1223
- for artifact in _indexed_artifacts(run):
1224
- if _is_results_archive_artifact(artifact):
1225
- return artifact
1226
- return None
1205
+ def _has_downloadable_results(run: dict[str, Any]) -> bool:
1206
+ observability = run.get("observability") if isinstance(run.get("observability"), dict) else {}
1207
+ outputs = observability.get("outputs") if isinstance(observability.get("outputs"), list) else []
1208
+ return any(isinstance(artifact, dict) and str(artifact.get("path", "")).strip() for artifact in outputs)
1227
1209
 
1228
1210
 
1229
1211
  def _resolve_artifact(run: dict[str, Any], artifact_id: str) -> dict[str, Any]:
@@ -1241,10 +1223,6 @@ def _resolve_artifact(run: dict[str, Any], artifact_id: str) -> dict[str, Any]:
1241
1223
  )
1242
1224
 
1243
1225
 
1244
- def _is_results_archive_artifact(artifact: dict[str, Any]) -> bool:
1245
- return Path(str(artifact.get("path", ""))).name == "results.zip"
1246
-
1247
-
1248
1226
  def _download_selected_artifact(
1249
1227
  *,
1250
1228
  run_id: str,
@@ -1424,9 +1402,7 @@ def _artifact_action_panel(run: dict[str, Any], artifact: dict[str, Any]) -> Pan
1424
1402
  workflow = str(run.get("workflow", ""))
1425
1403
  command_prefix = _foundry_command_prefix(workflow)
1426
1404
  artifact_id = str(artifact.get("id", ""))
1427
- if _is_results_archive_artifact(artifact):
1428
- command = f"{command_prefix} jobs download --id {run_id}"
1429
- elif artifact_id:
1405
+ if artifact_id:
1430
1406
  command = f"{command_prefix} jobs download --id {run_id} --artifact-id {artifact_id}"
1431
1407
  else:
1432
1408
  command = f"{command_prefix} jobs download --id {run_id}"
@@ -1489,26 +1465,22 @@ def _foundry_queries_panel(request_data: dict[str, Any] | None) -> Panel | None:
1489
1465
 
1490
1466
  def _load_foundry_result_preview(run: dict[str, Any]) -> dict[str, Any] | None:
1491
1467
  run_id = str(run.get("id", ""))
1492
- archive_path = _foundry_results_archive_path(run)
1493
1468
  archive_result: dict[str, Any] | None = None
1494
- if archive_path:
1469
+ if _has_downloadable_results(run):
1495
1470
  try:
1496
- archive_bytes = download_foundry_run_artifact(run_id, archive_path)
1471
+ archive_bytes = download_foundry_run_archive(run_id)
1497
1472
  archive_result = _inspect_foundry_results_archive(archive_bytes)
1498
1473
  except CLIError:
1499
1474
  pass
1500
1475
 
1501
- # The mock model (and Foundry's execution path) writes meta/*.json as
1502
- # separate filesystem artifacts rather than packing them into results.zip.
1503
- # Fall back to loading those individual metadata artifacts when the zip
1504
- # contains no meta/ entries.
1476
+ # Fall back to individual metadata artifacts when the Hub archive endpoint
1477
+ # is unavailable or contains no meta/ entries.
1505
1478
  if archive_result is not None and not archive_result.get("metadata"):
1506
1479
  metadata = _load_metadata_artifacts(run_id, run)
1507
1480
  if metadata:
1508
1481
  archive_result["metadata"] = metadata
1509
1482
 
1510
1483
  if archive_result is None:
1511
- # No results.zip at all — try to build a minimal preview from loose artifacts.
1512
1484
  metadata = _load_metadata_artifacts(run_id, run)
1513
1485
  if metadata:
1514
1486
  archive_result = {"predictions": [], "metadata": metadata, "other_files": []}
@@ -1684,12 +1656,10 @@ def _foundry_result_guidance_panel(run: dict[str, Any], *, result_preview: dict[
1684
1656
  run_id = str(run.get("id", "")).strip()
1685
1657
  if not run_id:
1686
1658
  return None
1687
- archive_path = _foundry_results_archive_path(run)
1688
- if archive_path:
1659
+ if _has_downloadable_results(run):
1689
1660
  table = Table(box=box.SIMPLE_HEAD, show_header=True, padding=(0, 1), expand=True)
1690
1661
  table.add_column("FIELD", style="bold", no_wrap=True)
1691
1662
  table.add_column("VALUE", overflow="fold")
1692
- table.add_row("Archive", archive_path)
1693
1663
  table.add_row("Download", f"apheris-foundry jobs download --id {run_id}")
1694
1664
  if result_preview:
1695
1665
  predictions = result_preview.get("predictions", [])
@@ -1702,8 +1672,8 @@ def _foundry_result_guidance_panel(run: dict[str, Any], *, result_preview: dict[
1702
1672
 
1703
1673
  if str(run.get("status", "")).lower() == "succeeded":
1704
1674
  message = (
1705
- "No published output files are present in this Hub response yet. "
1706
- "After deploying the updated Hub, rerun the workflow or fetch this run again."
1675
+ "No output artifacts are present in this Hub response. "
1676
+ "Use the artifact IDs above to download individual files when available."
1707
1677
  )
1708
1678
  return Panel(Text(message, style="dim"), title="Results", border_style="dim")
1709
1679
  return None
@@ -1994,3 +1964,37 @@ def _poll_until_terminal(run_id: str) -> dict[str, Any]:
1994
1964
  if status in _TERMINAL_STATUSES:
1995
1965
  return run
1996
1966
  time.sleep(_POLL_INTERVAL)
1967
+
1968
+
1969
+ def _exit_if_failed_run(run: dict[str, Any]) -> None:
1970
+ if str(run.get("status", "")).lower() != "failed":
1971
+ return
1972
+ raise typer.Exit(code=_failed_run_exit_code(run))
1973
+
1974
+
1975
+ def _failed_run_exit_code(run: dict[str, Any]) -> int:
1976
+ for value in _run_exit_code_candidates(run):
1977
+ try:
1978
+ code = int(str(value).strip())
1979
+ except (TypeError, ValueError):
1980
+ continue
1981
+ if 0 < code < 256:
1982
+ return code
1983
+ return EXIT_INTERNAL
1984
+
1985
+
1986
+ def _run_exit_code_candidates(run: dict[str, Any]) -> Iterable[Any]:
1987
+ yield run.get("exitCode")
1988
+ observability = run.get("observability")
1989
+ if not isinstance(observability, dict):
1990
+ return
1991
+ steps = observability.get("steps")
1992
+ if not isinstance(steps, list):
1993
+ return
1994
+ for step in steps:
1995
+ if not isinstance(step, dict):
1996
+ continue
1997
+ status = str(step.get("status", "")).upper()
1998
+ exit_code = str(step.get("exitCode", "")).strip()
1999
+ if status in {"FAILED", "ERROR"} or exit_code not in {"", "0", "-"}:
2000
+ yield step.get("exitCode")
@@ -247,5 +247,10 @@ def download_foundry_run_artifact(run_id: str, path: str) -> bytes:
247
247
  )
248
248
 
249
249
 
250
+ def download_foundry_run_archive(run_id: str) -> bytes:
251
+ encoded = urllib.parse.quote(run_id, safe="")
252
+ return _request_bytes(path=f"/api/v1/foundry/runs/{encoded}/archive", method="GET", task="foundry.runs.archive")
253
+
254
+
250
255
  def create_foundry_run(body: dict[str, Any]) -> dict[str, Any]:
251
256
  return _request_json(path="/api/v1/foundry/runs", method="POST", task="foundry.run", body=body)
@@ -62,6 +62,22 @@ def extract_results_archive(
62
62
  archive.extractall(output_dir)
63
63
  file_names = sorted(info.filename for info in archive.infolist() if not info.is_dir())
64
64
 
65
+ return summarize_result_files(
66
+ output_dir=output_dir,
67
+ file_names=file_names,
68
+ result_view=result_view,
69
+ task=task,
70
+ )
71
+
72
+
73
+ def summarize_result_files(
74
+ *,
75
+ output_dir: Path,
76
+ file_names: list[str],
77
+ result_view: str,
78
+ task: str,
79
+ ) -> dict[str, Any]:
80
+ validate_result_view(result_view, task=task)
65
81
  metadata_entries: list[dict[str, Any]] = []
66
82
  metric_entries: list[dict[str, Any]] = []
67
83
  prediction_entries: list[dict[str, Any]] = []
@@ -353,10 +353,10 @@ def workflows_run_cmd(
353
353
  if model_params is not None:
354
354
  params = _validate_foundry_model_params(workflow_id, params, model_params)
355
355
  params = {**(params or {}), "modelParams": json.dumps(model_params, separators=(",", ":"))}
356
- elif weight:
356
+ elif weight or (params and "model" in params):
357
357
  # A weight identifies a model version only in the context of a
358
- # model. For a one-model workflow we can make that selection
359
- # explicit without burdening the user with --param model=... .
358
+ # model. An explicit model also needs a concrete default weight so
359
+ # Hub launches the same model artifact shown by workflow metadata.
360
360
  _, params = _resolve_foundry_model_selection(workflow_id, params)
361
361
  except typer.BadParameter as error:
362
362
  typer.echo(str(error), err=True)
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -19,12 +19,21 @@ from apherisfold_cli.main import app
19
19
  runner = CliRunner()
20
20
 
21
21
 
22
+ def _make_foundry_archive(*entries: tuple[str, bytes]) -> bytes:
23
+ buffer = BytesIO()
24
+ with zipfile.ZipFile(buffer, "w") as archive:
25
+ for name, content in entries:
26
+ archive.writestr(name, content)
27
+ return buffer.getvalue()
28
+
29
+
22
30
  @pytest.fixture(autouse=True)
23
31
  def hub_api_fixture(monkeypatch):
24
32
  calls: dict[str, Any] = {
25
33
  "create_foundry_run": [],
26
34
  "get_foundry_run": [],
27
35
  "get_foundry_run_logs": [],
36
+ "download_foundry_run_archive": [],
28
37
  "download_foundry_run_artifact": [],
29
38
  "list_foundry_runs": [],
30
39
  "list_foundry_workflows": [],
@@ -217,6 +226,25 @@ def hub_api_fixture(monkeypatch):
217
226
  ).encode()
218
227
  return b"trace-bytes"
219
228
 
229
+ def fake_download_foundry_run_archive(run_id):
230
+ calls["download_foundry_run_archive"].append(run_id)
231
+ buffer = BytesIO()
232
+ with zipfile.ZipFile(buffer, "w") as archive:
233
+ archive.writestr(
234
+ "meta/mcl1_sample_1.json",
235
+ json.dumps(
236
+ {
237
+ "query_id": "mcl1",
238
+ "sample_id": 1,
239
+ "affinity_prediction": 6.8,
240
+ "plddt": [94.9, 95.02],
241
+ }
242
+ ),
243
+ )
244
+ archive.writestr("mcl1_sample_1_model.cif", "model-cif")
245
+ archive.writestr("errors/mcl1_sample_2.json", json.dumps({"error": "failed sample"}))
246
+ return buffer.getvalue()
247
+
220
248
  def fake_list_foundry_runs():
221
249
  calls["list_foundry_runs"].append(True)
222
250
  return {"runs": [fake_get_foundry_run("foundry_test_failed")]}
@@ -345,6 +373,9 @@ def hub_api_fixture(monkeypatch):
345
373
  monkeypatch.setattr(
346
374
  "apherisfold_cli.commands.foundry.download_foundry_run_artifact", fake_download_foundry_run_artifact
347
375
  )
376
+ monkeypatch.setattr(
377
+ "apherisfold_cli.commands.foundry.download_foundry_run_archive", fake_download_foundry_run_archive
378
+ )
348
379
  monkeypatch.setattr("apherisfold_cli.commands.foundry.list_foundry_runs", fake_list_foundry_runs)
349
380
  monkeypatch.setattr("apherisfold_cli.commands.foundry.list_foundry_workflows", fake_list_foundry_workflows)
350
381
  monkeypatch.setattr("apherisfold_cli.commands.foundry.get_foundry_workflow", fake_get_foundry_workflow)
@@ -513,17 +544,10 @@ def test_foundry_workflows_run_serializes_selected_weight(hub_api_fixture) -> No
513
544
  assert hub_api_fixture["create_foundry_run"][0]["params"] == {"model": "openfold3", "weight": "0.3.0"}
514
545
 
515
546
 
516
- def test_foundry_workflows_run_preserves_predict_request_filename(tmp_path: Path, hub_api_fixture) -> None:
517
- request = tmp_path / "request-protein-ligand.json"
547
+ def test_foundry_workflows_run_resolves_default_weight_for_selected_model(tmp_path: Path, hub_api_fixture) -> None:
548
+ request = tmp_path / "request-minimal.json"
518
549
  request.write_text('{"queries": []}', encoding="utf-8")
519
550
 
520
- result = runner.invoke(app, ["workflows", "run", "--workflow", "predict", "--input", str(request)])
521
-
522
- assert result.exit_code == 0
523
- assert hub_api_fixture["create_foundry_run"][0]["inputs"] == {"request-protein-ligand.json": '{"queries": []}'}
524
-
525
-
526
- def test_foundry_workflows_run_rejects_model_params_outside_schema(hub_api_fixture) -> None:
527
551
  result = runner.invoke(
528
552
  app,
529
553
  [
@@ -531,13 +555,45 @@ def test_foundry_workflows_run_rejects_model_params_outside_schema(hub_api_fixtu
531
555
  "run",
532
556
  "--workflow",
533
557
  "predict",
534
- "--model-params",
535
- '{"seed":7}',
558
+ "--input",
559
+ str(request),
560
+ "--param",
561
+ "model=openfold3",
536
562
  ],
537
563
  )
538
564
 
539
- assert result.exit_code != 0
540
- assert "invalid --model-params" in result.stderr
565
+ assert result.exit_code == 0
566
+ body = hub_api_fixture["create_foundry_run"][0]
567
+ assert body["params"] == {"model": "openfold3", "weight": "0.0.0"}
568
+ assert body["inputs"] == {"request-minimal.json": '{"queries": []}'}
569
+
570
+
571
+ def test_foundry_workflows_run_preserves_predict_request_filename(tmp_path: Path, hub_api_fixture) -> None:
572
+ request = tmp_path / "request-protein-ligand.json"
573
+ request.write_text('{"queries": []}', encoding="utf-8")
574
+
575
+ result = runner.invoke(app, ["workflows", "run", "--workflow", "predict", "--input", str(request)])
576
+
577
+ assert result.exit_code == 0
578
+ assert hub_api_fixture["create_foundry_run"][0]["inputs"] == {"request-protein-ligand.json": '{"queries": []}'}
579
+
580
+
581
+ def test_foundry_workflows_run_rejects_model_params_outside_schema(hub_api_fixture) -> None:
582
+ for model_params in ['{"seed":7}', '{"diffusion_samples":2}']:
583
+ result = runner.invoke(
584
+ app,
585
+ [
586
+ "workflows",
587
+ "run",
588
+ "--workflow",
589
+ "predict",
590
+ "--model-params",
591
+ model_params,
592
+ ],
593
+ )
594
+
595
+ assert result.exit_code != 0
596
+ assert "invalid --model-params" in result.stderr
541
597
  assert hub_api_fixture["create_foundry_run"] == []
542
598
 
543
599
 
@@ -1171,6 +1227,8 @@ def test_foundry_download_result_extracts_archive(hub_api_fixture) -> None:
1171
1227
  extracted = list(Path(tmpdir).rglob("*"))
1172
1228
  assert any(f.suffix == ".cif" for f in extracted if f.is_file())
1173
1229
  assert (Path(tmpdir) / "result.json").exists()
1230
+ assert hub_api_fixture["download_foundry_run_archive"] == ["foundry_test_001"]
1231
+ assert not hub_api_fixture["download_foundry_run_artifact"]
1174
1232
 
1175
1233
 
1176
1234
  def test_foundry_download_result_json_output(hub_api_fixture) -> None:
@@ -1183,6 +1241,8 @@ def test_foundry_download_result_json_output(hub_api_fixture) -> None:
1183
1241
  payload = json.loads(result.stdout.strip())
1184
1242
  assert payload["kind"] == "foundry-predict-results"
1185
1243
  assert "results" in payload
1244
+ assert hub_api_fixture["download_foundry_run_archive"] == ["foundry_test_001"]
1245
+ assert not hub_api_fixture["download_foundry_run_artifact"]
1186
1246
 
1187
1247
 
1188
1248
  def test_foundry_download_result_show_flag(hub_api_fixture) -> None:
@@ -1214,6 +1274,72 @@ def test_foundry_download_result_files_result_view(hub_api_fixture) -> None:
1214
1274
  assert result.exit_code == 0, result.stdout
1215
1275
 
1216
1276
 
1277
+ def test_foundry_download_result_extracts_hub_archive_without_results_zip(monkeypatch) -> None:
1278
+ run = {
1279
+ "id": "foundry_loose_outputs",
1280
+ "workflow": "predict",
1281
+ "status": "succeeded",
1282
+ "observability": {
1283
+ "outputs": [
1284
+ {
1285
+ "name": "Prediction metadata",
1286
+ "type": "metadata",
1287
+ "path": "output/output/meta/mcl1_sample_1.json",
1288
+ "sizeBytes": 120,
1289
+ },
1290
+ {
1291
+ "name": "Predicted structure",
1292
+ "type": "structure",
1293
+ "path": "output/output/mcl1_sample_1_model.cif",
1294
+ "sizeBytes": 9,
1295
+ },
1296
+ {
1297
+ "name": "Prediction metrics",
1298
+ "type": "metrics",
1299
+ "path": "output/output/metrics.json",
1300
+ "sizeBytes": 20,
1301
+ },
1302
+ {
1303
+ "name": "Prediction error",
1304
+ "type": "error",
1305
+ "path": "output/output/errors/mcl1_sample_2.json",
1306
+ "sizeBytes": 18,
1307
+ },
1308
+ ]
1309
+ },
1310
+ }
1311
+ archive_calls: list[str] = []
1312
+
1313
+ monkeypatch.setattr("apherisfold_cli.commands.foundry.get_foundry_run", lambda run_id: run)
1314
+ monkeypatch.setattr(
1315
+ "apherisfold_cli.commands.foundry.download_foundry_run_archive",
1316
+ lambda run_id: (
1317
+ archive_calls.append(run_id)
1318
+ or _make_foundry_archive(
1319
+ ("meta/mcl1_sample_1.json", json.dumps({"query_id": "mcl1", "sample_id": 1}).encode()),
1320
+ ("mcl1_sample_1_model.cif", b"model-cif"),
1321
+ ("errors/mcl1_sample_2.json", b'{"error":"failed sample"}'),
1322
+ )
1323
+ ),
1324
+ )
1325
+ monkeypatch.setattr(
1326
+ "apherisfold_cli.commands.foundry.download_foundry_run_artifact",
1327
+ lambda run_id, path: pytest.fail("default download must not materialize loose artifacts"),
1328
+ )
1329
+
1330
+ with TemporaryDirectory() as tmpdir:
1331
+ result = runner.invoke(
1332
+ app,
1333
+ ["jobs", "download", "--id", run["id"], "--output", tmpdir],
1334
+ env={"COLUMNS": "200"},
1335
+ )
1336
+
1337
+ assert result.exit_code == 0, result.stdout
1338
+ assert (Path(tmpdir) / "mcl1_sample_1_model.cif").read_text() == "model-cif"
1339
+ assert (Path(tmpdir) / "errors" / "mcl1_sample_2.json").exists()
1340
+ assert archive_calls == [run["id"]]
1341
+
1342
+
1217
1343
  # ---------------------------------------------------------------------------
1218
1344
  # Foundry commands — list
1219
1345
  # ---------------------------------------------------------------------------
@@ -1465,9 +1591,18 @@ def test_foundry_logs_follow_exits_on_terminal_status(monkeypatch) -> None:
1465
1591
  assert result.exit_code == 0
1466
1592
 
1467
1593
 
1468
- def test_foundry_run_wait_mode(hub_api_fixture, monkeypatch) -> None:
1469
- """--wait blocks until run reaches terminal status."""
1594
+ def test_foundry_run_wait_mode_returns_failed_run_exit_code(hub_api_fixture, monkeypatch) -> None:
1595
+ """--wait returns a non-zero exit code when the remote run fails."""
1470
1596
  monkeypatch.setattr("apherisfold_cli.commands.foundry.time.sleep", lambda _: None)
1597
+ monkeypatch.setattr(
1598
+ "apherisfold_cli.commands.foundry.get_foundry_run",
1599
+ lambda run_id: {
1600
+ "id": run_id,
1601
+ "workflow": "fragment-analysis",
1602
+ "status": "failed",
1603
+ "observability": {"steps": [{"name": "SCORE", "status": "FAILED", "exitCode": "137"}]},
1604
+ },
1605
+ )
1471
1606
  with TemporaryDirectory() as tmpdir:
1472
1607
  input_path = Path(tmpdir) / "molecules.csv"
1473
1608
  input_path.write_text("smiles\nCCO\n")
@@ -1475,12 +1610,12 @@ def test_foundry_run_wait_mode(hub_api_fixture, monkeypatch) -> None:
1475
1610
  app,
1476
1611
  ["workflows", "run", "--workflow", "fragment-analysis", "--wait", "--input", str(input_path)],
1477
1612
  )
1478
- assert result.exit_code == 0
1613
+ assert result.exit_code == 137
1479
1614
  assert "failed" in result.stdout.lower() or "finished" in result.stdout.lower()
1480
1615
 
1481
1616
 
1482
- def test_foundry_run_wait_mode_json(hub_api_fixture, monkeypatch) -> None:
1483
- """--wait with --json outputs final run JSON."""
1617
+ def test_foundry_run_wait_mode_json_returns_failed_run_exit_code(hub_api_fixture, monkeypatch) -> None:
1618
+ """--wait with --json outputs final run JSON and returns non-zero on failure."""
1484
1619
  monkeypatch.setattr("apherisfold_cli.commands.foundry.time.sleep", lambda _: None)
1485
1620
  with TemporaryDirectory() as tmpdir:
1486
1621
  input_path = Path(tmpdir) / "molecules.csv"
@@ -1498,11 +1633,34 @@ def test_foundry_run_wait_mode_json(hub_api_fixture, monkeypatch) -> None:
1498
1633
  str(input_path),
1499
1634
  ],
1500
1635
  )
1501
- assert result.exit_code == 0
1636
+ assert result.exit_code == 1
1502
1637
  # stdout has "Waiting for run..." prefix then JSON; check JSON is embedded
1503
1638
  assert '"id"' in result.stdout or '"status"' in result.stdout
1504
1639
 
1505
1640
 
1641
+ def test_foundry_run_wait_mode_succeeded_run_exits_zero(hub_api_fixture, monkeypatch) -> None:
1642
+ """--wait keeps exit code 0 when the remote run succeeds."""
1643
+ monkeypatch.setattr("apherisfold_cli.commands.foundry.time.sleep", lambda _: None)
1644
+ monkeypatch.setattr(
1645
+ "apherisfold_cli.commands.foundry.get_foundry_run",
1646
+ lambda run_id: {
1647
+ "id": run_id,
1648
+ "workflow": "fragment-analysis",
1649
+ "status": "succeeded",
1650
+ "observability": {"steps": [{"name": "SCORE", "status": "COMPLETED", "exitCode": "0"}]},
1651
+ },
1652
+ )
1653
+ with TemporaryDirectory() as tmpdir:
1654
+ input_path = Path(tmpdir) / "molecules.csv"
1655
+ input_path.write_text("smiles\nCCO\n")
1656
+ result = runner.invoke(
1657
+ app,
1658
+ ["workflows", "run", "--workflow", "fragment-analysis", "--wait", "--input", str(input_path)],
1659
+ )
1660
+ assert result.exit_code == 0
1661
+ assert "succeeded" in result.stdout.lower()
1662
+
1663
+
1506
1664
  def test_foundry_error_non_kubernetes_error(monkeypatch) -> None:
1507
1665
  """Error command for a run with a specific (non-generic) error message."""
1508
1666
  monkeypatch.setattr(
@@ -1674,6 +1832,10 @@ def test_foundry_download_result_non_archive_artifact(hub_api_fixture) -> None:
1674
1832
  )
1675
1833
  assert result.exit_code == 0
1676
1834
  assert (Path(tmpdir) / "metrics.json").exists()
1835
+ assert not hub_api_fixture["download_foundry_run_archive"]
1836
+ assert hub_api_fixture["download_foundry_run_artifact"] == [
1837
+ {"run_id": "foundry_test_001", "path": "output/output/metrics.json"}
1838
+ ]
1677
1839
 
1678
1840
 
1679
1841
  def test_foundry_download_result_non_archive_artifact_json(hub_api_fixture) -> None:
@@ -1697,6 +1859,10 @@ def test_foundry_download_result_non_archive_artifact_json(hub_api_fixture) -> N
1697
1859
  payload = json.loads(result.stdout.strip())
1698
1860
  assert payload["runId"] == "foundry_test_001"
1699
1861
  assert "output" in payload
1862
+ assert not hub_api_fixture["download_foundry_run_archive"]
1863
+ assert hub_api_fixture["download_foundry_run_artifact"] == [
1864
+ {"run_id": "foundry_test_001", "path": "output/output/metrics.json"}
1865
+ ]
1700
1866
 
1701
1867
 
1702
1868
  def test_foundry_download_result_unknown_artifact_id(hub_api_fixture) -> None:
@@ -13,6 +13,7 @@ from apherisfold_cli.core.errors import CLIError
13
13
  from apherisfold_cli.core.hub_requests import (
14
14
  _request_json,
15
15
  create_foundry_run,
16
+ download_foundry_run_archive,
16
17
  download_foundry_run_artifact,
17
18
  get_foundry_run,
18
19
  get_foundry_run_logs,
@@ -253,6 +254,21 @@ def test_download_foundry_run_artifact_returns_bytes(tmp_path: Path, monkeypatch
253
254
  assert data == b"binary artifact content"
254
255
 
255
256
 
257
+ def test_download_foundry_run_archive_returns_bytes(tmp_path: Path, monkeypatch) -> None:
258
+ _save_refreshable_session(tmp_path, monkeypatch)
259
+ seen_url: list[str] = []
260
+
261
+ def fake_urlopen(request: urllib.request.Request, **_kwargs: object) -> FakeResponse:
262
+ seen_url.append(request.full_url)
263
+ assert request.method == "GET"
264
+ return FakeResponse(b"zip content")
265
+
266
+ monkeypatch.setattr("apherisfold_cli.core.hub_requests.urllib.request.urlopen", fake_urlopen)
267
+ data = download_foundry_run_archive("run/1")
268
+ assert data == b"zip content"
269
+ assert "/api/v1/foundry/runs/run%2F1/archive" in seen_url[0]
270
+
271
+
256
272
  # ---------------------------------------------------------------------------
257
273
  # Error handling
258
274
  # ---------------------------------------------------------------------------
@@ -1 +0,0 @@
1
- __version__ = "0.3.0"