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.
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/PKG-INFO +1 -1
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/pyproject.toml +1 -1
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/PKG-INFO +1 -1
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/commands/foundry.py +55 -51
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/hub_requests.py +5 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/result_artifacts.py +16 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/main.py +3 -3
- apheris_foundry_cli-0.4.0/src/apherisfold_cli/version.py +1 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_cli.py +185 -19
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_hub_requests.py +16 -0
- apheris_foundry_cli-0.3.0/src/apherisfold_cli/version.py +0 -1
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/LICENSE +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/README.md +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/setup.cfg +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/SOURCES.txt +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/dependency_links.txt +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/entry_points.txt +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/requires.txt +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/top_level.txt +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/__init__.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/commands/__init__.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/commands/login.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/__init__.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/auth.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/config.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/context.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/errors.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/io.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/login_profiles.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/render.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/handlers.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_auth.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_config.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_foundry_helpers.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_handlers.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_io.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_main.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_render.py +0 -0
- {apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/tests/test_result_artifacts.py +0 -0
{apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/commands/foundry.py
RENAMED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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
|
|
1221
|
-
if
|
|
1222
|
-
|
|
1223
|
-
for artifact in
|
|
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
|
|
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
|
|
1469
|
+
if _has_downloadable_results(run):
|
|
1495
1470
|
try:
|
|
1496
|
-
archive_bytes =
|
|
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
|
-
#
|
|
1502
|
-
#
|
|
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
|
-
|
|
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
|
|
1706
|
-
"
|
|
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")
|
{apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/hub_requests.py
RENAMED
|
@@ -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)
|
{apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/result_artifacts.py
RENAMED
|
@@ -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.
|
|
359
|
-
#
|
|
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
|
|
517
|
-
request = tmp_path / "request-
|
|
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
|
-
"--
|
|
535
|
-
|
|
558
|
+
"--input",
|
|
559
|
+
str(request),
|
|
560
|
+
"--param",
|
|
561
|
+
"model=openfold3",
|
|
536
562
|
],
|
|
537
563
|
)
|
|
538
564
|
|
|
539
|
-
assert result.exit_code
|
|
540
|
-
|
|
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
|
|
1469
|
-
"""--wait
|
|
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 ==
|
|
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
|
|
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 ==
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apheris_foundry_cli.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/commands/__init__.py
RENAMED
|
File without changes
|
{apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/commands/login.py
RENAMED
|
File without changes
|
{apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{apheris_foundry_cli-0.3.0 → apheris_foundry_cli-0.4.0}/src/apherisfold_cli/core/login_profiles.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|