dh-cli 0.8.8__tar.gz → 0.9.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.
- {dh_cli-0.8.8 → dh_cli-0.9.0}/PKG-INFO +1 -1
- {dh_cli-0.8.8 → dh_cli-0.9.0}/pyproject.toml +1 -1
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/__init__.py +2 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/finalize.py +36 -0
- dh_cli-0.9.0/src/dh_cli/batch/commands/rfd3.py +402 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/bedrock/commands.py +2 -5
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_rotate.py +2 -6
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/conftest.py +1 -1
- dh_cli-0.9.0/tests/test_finalize_rfd3.py +130 -0
- dh_cli-0.9.0/tests/test_rfd3_command.py +456 -0
- dh_cli-0.8.8/tests/bedrock/test_cursor_bedrock_isolation.py +0 -271
- {dh_cli-0.8.8 → dh_cli-0.9.0}/.gitignore +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/LICENSE +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/README.md +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/_identity.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/train.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/bedrock/__init__.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/bedrock/cost_report.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/bedrock/pricing.yaml +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/main.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/utility_commands.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/__init__.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/test_aws_batch_resources.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/test_image_override.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/test_submit_cpu_only.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/test_submit_image_validation.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/test_submit_merge.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/conftest.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/A_cache_write.json +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/B_cache_read.json +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/C_plain.json +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/E_service_role.json +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_build_report.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_classify_arn.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_cli_exit_codes.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_cost_calc.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_cost_command.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_cur_reconciliation.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_key_command.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_render_formats.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_resolve_base_model.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_s3_walker.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/__init__.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_identity.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_login.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_login_error_paths.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_login_security.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_logout.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_rotate.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_status.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/hz/test_init.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/hz/test_users.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/test_finalize_boltz_tar.py +0 -0
- {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/test_finalize_protmpnn.py +0 -0
|
@@ -22,6 +22,7 @@ from .commands.orca import orca
|
|
|
22
22
|
from .commands.protmpnn import protmpnn
|
|
23
23
|
from .commands.protmpnn_to_boltz import protmpnn_to_boltz
|
|
24
24
|
from .commands.retry import retry
|
|
25
|
+
from .commands.rfd3 import rfd3
|
|
25
26
|
from .commands.status import status
|
|
26
27
|
from .commands.submit import submit
|
|
27
28
|
from .commands.train import train
|
|
@@ -102,6 +103,7 @@ batch_cli.add_command(boltz)
|
|
|
102
103
|
batch_cli.add_command(protmpnn)
|
|
103
104
|
batch_cli.add_command(orca)
|
|
104
105
|
batch_cli.add_command(protmpnn_to_boltz, name="protmpnn-to-boltz")
|
|
106
|
+
batch_cli.add_command(rfd3)
|
|
105
107
|
batch_cli.add_command(train)
|
|
106
108
|
|
|
107
109
|
__all__ = ["batch_cli"]
|
|
@@ -143,6 +143,8 @@ def finalize(job_id, output, force, keep_intermediates, full_output, skip_dedup,
|
|
|
143
143
|
_finalize_boltz(output_dir, output_path, full_output=full_output)
|
|
144
144
|
elif manifest.pipeline == "protmpnn":
|
|
145
145
|
_finalize_protmpnn(output_dir, output_path)
|
|
146
|
+
elif manifest.pipeline == "rfd3":
|
|
147
|
+
_finalize_rfd3(output_dir, output_path)
|
|
146
148
|
else:
|
|
147
149
|
_finalize_generic(output_dir, output_path)
|
|
148
150
|
|
|
@@ -544,3 +546,37 @@ def _finalize_protmpnn(output_dir: Path, output_path: Path):
|
|
|
544
546
|
|
|
545
547
|
click.echo(f"{num_variants} variants from {num_configs} config(s), top confidence: {top_conf}")
|
|
546
548
|
click.echo(click.style(f"Results: {output_path / 'results.csv'}", fg="green"))
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _finalize_rfd3(output_dir: Path, output_path: Path):
|
|
552
|
+
"""Collate completed RFD3 per-config backbone dirs into the final output.
|
|
553
|
+
|
|
554
|
+
The worker promotes each config's flat `.cif.gz` + `.json` design files
|
|
555
|
+
into `output/<stem>/` and drops a `output/rfd3_<stem>.done` marker only
|
|
556
|
+
when validation passed. Finalize copies each completed config's directory
|
|
557
|
+
into `output_path/<stem>/`, skipping configs without a `.done` marker and
|
|
558
|
+
the loose worker-index markers.
|
|
559
|
+
"""
|
|
560
|
+
completed = []
|
|
561
|
+
for marker in sorted(output_dir.glob("rfd3_*.done")):
|
|
562
|
+
stem = marker.name[len("rfd3_") : -len(".done")]
|
|
563
|
+
if (output_dir / stem).is_dir():
|
|
564
|
+
completed.append(stem)
|
|
565
|
+
|
|
566
|
+
if not completed:
|
|
567
|
+
click.echo("No completed RFD3 configs found in output directory.", err=True)
|
|
568
|
+
raise SystemExit(1)
|
|
569
|
+
|
|
570
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
571
|
+
|
|
572
|
+
copied_count = 0
|
|
573
|
+
for stem in completed:
|
|
574
|
+
dest = output_path / stem
|
|
575
|
+
if dest.exists():
|
|
576
|
+
click.echo(f" Skipping {stem} (already exists)")
|
|
577
|
+
continue
|
|
578
|
+
shutil.copytree(output_dir / stem, dest)
|
|
579
|
+
copied_count += 1
|
|
580
|
+
|
|
581
|
+
click.echo(f"Collated {copied_count} RFD3 config(s)")
|
|
582
|
+
click.echo(click.style(f"Results: {output_path}", fg="green"))
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""RFD3 protein backbone design pipeline command."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from ..aws_batch import BatchClient, BatchError, resolve_dependency
|
|
13
|
+
from ..job_id import generate_job_id, get_aws_username
|
|
14
|
+
from ..manifest import (
|
|
15
|
+
BATCH_JOBS_BASE,
|
|
16
|
+
BatchConfig,
|
|
17
|
+
InputConfig,
|
|
18
|
+
JobManifest,
|
|
19
|
+
JobStatus,
|
|
20
|
+
OutputConfig,
|
|
21
|
+
create_job_directory,
|
|
22
|
+
save_manifest,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DEFAULT_QUEUE = "a10g-1x-spot"
|
|
26
|
+
MAX_WORKERS = 50
|
|
27
|
+
FILES_PER_WORKER = 2
|
|
28
|
+
DEFAULT_JOB_DEFINITION = "dayhoff-rfd3"
|
|
29
|
+
DEFAULT_IMAGE_URI = "074735440724.dkr.ecr.us-east-1.amazonaws.com/dayhoff:rfd3-latest"
|
|
30
|
+
OFFLINE_ENV_VAR = "HF_HUB_OFFLINE"
|
|
31
|
+
|
|
32
|
+
CHECKPOINT_SUBTREE = "/primordial/.cache/foundry/checkpoints"
|
|
33
|
+
PRIMORDIAL_CHECKPOINT_DIR = Path(CHECKPOINT_SUBTREE)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.command()
|
|
37
|
+
@click.argument("input_dir", type=click.Path(exists=True))
|
|
38
|
+
@click.option(
|
|
39
|
+
"--workers",
|
|
40
|
+
default=None,
|
|
41
|
+
type=int,
|
|
42
|
+
help="Number of parallel workers [default: ~1 per 2 files]",
|
|
43
|
+
)
|
|
44
|
+
@click.option(
|
|
45
|
+
"--queue",
|
|
46
|
+
default=DEFAULT_QUEUE,
|
|
47
|
+
help=f"Batch queue [default: {DEFAULT_QUEUE}]",
|
|
48
|
+
)
|
|
49
|
+
@click.option("--dry-run", is_flag=True, help="Show plan without submitting")
|
|
50
|
+
@click.option(
|
|
51
|
+
"--local",
|
|
52
|
+
"run_local",
|
|
53
|
+
is_flag=True,
|
|
54
|
+
help="Force local execution via Docker",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--remote",
|
|
58
|
+
"run_remote",
|
|
59
|
+
is_flag=True,
|
|
60
|
+
help="Force Batch submission (override auto-detect)",
|
|
61
|
+
)
|
|
62
|
+
@click.option("--base-path", default=BATCH_JOBS_BASE, help="Base path for job data")
|
|
63
|
+
@click.option("--after", "after", multiple=True, help="Job ID(s) to wait for before starting")
|
|
64
|
+
@click.option("--num-designs", default=1, type=int, help="Number of designs per config")
|
|
65
|
+
def rfd3(
|
|
66
|
+
input_dir,
|
|
67
|
+
workers,
|
|
68
|
+
queue,
|
|
69
|
+
dry_run,
|
|
70
|
+
run_local,
|
|
71
|
+
run_remote,
|
|
72
|
+
base_path,
|
|
73
|
+
after,
|
|
74
|
+
num_designs,
|
|
75
|
+
):
|
|
76
|
+
"""Design protein backbones with RFD3.
|
|
77
|
+
|
|
78
|
+
Processes a directory of YAML config files, each specifying design
|
|
79
|
+
parameters and an optional motif structure.
|
|
80
|
+
|
|
81
|
+
\b
|
|
82
|
+
Examples:
|
|
83
|
+
# Run on a GPU engine (auto-detects GPU, runs locally)
|
|
84
|
+
dh batch rfd3 input/
|
|
85
|
+
|
|
86
|
+
# Force remote Batch submission
|
|
87
|
+
dh batch rfd3 input/ --remote
|
|
88
|
+
|
|
89
|
+
# Preview what would run
|
|
90
|
+
dh batch rfd3 input/ --dry-run
|
|
91
|
+
|
|
92
|
+
# Run locally via Docker
|
|
93
|
+
dh batch rfd3 input/ --local
|
|
94
|
+
"""
|
|
95
|
+
input_path = Path(input_dir).resolve()
|
|
96
|
+
|
|
97
|
+
if run_local:
|
|
98
|
+
_run_local_mode(input_path, base_path)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if not run_remote and not dry_run:
|
|
102
|
+
if _has_local_gpu():
|
|
103
|
+
click.echo("GPU detected — running locally (use --remote to override)")
|
|
104
|
+
_run_local_mode(input_path, base_path)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
_submit_batch_job(input_path, workers, queue, dry_run, base_path, after, num_designs)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _has_local_gpu() -> bool:
|
|
111
|
+
"""Check if a local NVIDIA GPU is available."""
|
|
112
|
+
try:
|
|
113
|
+
result = subprocess.run(
|
|
114
|
+
["nvidia-smi"],
|
|
115
|
+
capture_output=True,
|
|
116
|
+
timeout=5,
|
|
117
|
+
)
|
|
118
|
+
return result.returncode == 0
|
|
119
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _has_nvidia_runtime() -> bool:
|
|
124
|
+
"""Check if the NVIDIA container runtime is available to Docker."""
|
|
125
|
+
try:
|
|
126
|
+
result = subprocess.run(
|
|
127
|
+
["docker", "info", "--format", "{{json .Runtimes}}"],
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
timeout=5,
|
|
131
|
+
)
|
|
132
|
+
return result.returncode == 0 and "nvidia" in result.stdout
|
|
133
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _discover_configs(input_path: Path) -> list[Path]:
|
|
138
|
+
return sorted(input_path.glob("*.yaml"))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _validate_motif_paths(yaml_path: Path, input_root: Path):
|
|
142
|
+
"""Reject absolute, escaping, or missing motif paths referenced by a config."""
|
|
143
|
+
with open(yaml_path) as f:
|
|
144
|
+
data = yaml.safe_load(f)
|
|
145
|
+
|
|
146
|
+
if not isinstance(data, dict):
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
for entry in data.values():
|
|
150
|
+
if not isinstance(entry, dict) or "input" not in entry:
|
|
151
|
+
continue
|
|
152
|
+
motif = entry["input"]
|
|
153
|
+
|
|
154
|
+
if Path(motif).is_absolute():
|
|
155
|
+
raise click.ClickException(f"Motif path must be relative, got absolute: {motif}")
|
|
156
|
+
|
|
157
|
+
resolved = (input_root / motif).resolve()
|
|
158
|
+
if not resolved.is_relative_to(input_root):
|
|
159
|
+
raise click.ClickException(f"Motif path escapes input dir: {motif}")
|
|
160
|
+
|
|
161
|
+
if not resolved.exists():
|
|
162
|
+
raise click.ClickException(f"Motif file not found: {motif}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _validate_inputs(input_path: Path) -> list[Path]:
|
|
166
|
+
"""Discover and validate configs; raise a clean CLI error on any violation."""
|
|
167
|
+
configs = _discover_configs(input_path)
|
|
168
|
+
if not configs:
|
|
169
|
+
raise click.ClickException(f"No YAML config files found in {input_path}")
|
|
170
|
+
|
|
171
|
+
for config in configs:
|
|
172
|
+
_validate_motif_paths(config, input_path)
|
|
173
|
+
|
|
174
|
+
return configs
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _referenced_motifs(config: Path) -> list[str]:
|
|
178
|
+
"""Return the relative motif ``input:`` paths referenced by a config."""
|
|
179
|
+
with open(config) as f:
|
|
180
|
+
data = yaml.safe_load(f)
|
|
181
|
+
|
|
182
|
+
if not isinstance(data, dict):
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
motifs = []
|
|
186
|
+
for entry in data.values():
|
|
187
|
+
if isinstance(entry, dict) and "input" in entry:
|
|
188
|
+
motifs.append(entry["input"])
|
|
189
|
+
return motifs
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _copy_inputs_to_job_dir(input_path: Path, job_dir: Path) -> int:
|
|
193
|
+
"""Copy configs and their referenced motif files into the job directory.
|
|
194
|
+
|
|
195
|
+
Motifs are copied at their relative subpath (e.g. ``motifs/x.pdb``) so
|
|
196
|
+
RFD3 resolves the config's relative ``input:`` path from the copied dir.
|
|
197
|
+
"""
|
|
198
|
+
dest = job_dir / "input"
|
|
199
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
|
|
201
|
+
count = 0
|
|
202
|
+
for yaml_file in sorted(input_path.glob("*.yaml")):
|
|
203
|
+
shutil.copy2(yaml_file, dest / yaml_file.name)
|
|
204
|
+
count += 1
|
|
205
|
+
for motif in _referenced_motifs(yaml_file):
|
|
206
|
+
src = input_path / motif
|
|
207
|
+
target = dest / motif
|
|
208
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
shutil.copy2(src, target)
|
|
210
|
+
|
|
211
|
+
return count
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _submit_batch_job(
|
|
215
|
+
input_path: Path,
|
|
216
|
+
workers: int | None,
|
|
217
|
+
queue: str,
|
|
218
|
+
dry_run: bool,
|
|
219
|
+
base_path: str,
|
|
220
|
+
after: tuple[str, ...],
|
|
221
|
+
num_designs: int,
|
|
222
|
+
):
|
|
223
|
+
"""Submit an RFD3 job to AWS Batch."""
|
|
224
|
+
click.echo(f"Scanning {input_path} for YAML files...")
|
|
225
|
+
configs = _validate_inputs(input_path)
|
|
226
|
+
num_files = len(configs)
|
|
227
|
+
click.echo(f"Found {num_files} config(s) to process")
|
|
228
|
+
|
|
229
|
+
if workers is None:
|
|
230
|
+
workers = max(1, min(math.ceil(num_files / FILES_PER_WORKER), MAX_WORKERS))
|
|
231
|
+
array_size = min(num_files, workers)
|
|
232
|
+
|
|
233
|
+
job_id = generate_job_id("rfd3")
|
|
234
|
+
|
|
235
|
+
click.echo()
|
|
236
|
+
click.echo(f"Job ID: {job_id}")
|
|
237
|
+
click.echo(f"Input: {input_path}")
|
|
238
|
+
click.echo(f"Configs: {num_files}")
|
|
239
|
+
click.echo(f"Workers: {array_size}")
|
|
240
|
+
click.echo(f"Num designs: {num_designs}")
|
|
241
|
+
click.echo(f"Queue: {DEFAULT_QUEUE}")
|
|
242
|
+
click.echo(f"Job definition: {DEFAULT_JOB_DEFINITION}")
|
|
243
|
+
click.echo(f"Image: {DEFAULT_IMAGE_URI}")
|
|
244
|
+
|
|
245
|
+
if dry_run:
|
|
246
|
+
click.echo()
|
|
247
|
+
click.echo(click.style("Dry run - job not submitted (REMOTE plan)", fg="yellow"))
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
if not click.confirm("\nSubmit job?", default=True):
|
|
251
|
+
click.echo("Cancelled.")
|
|
252
|
+
raise SystemExit(0)
|
|
253
|
+
click.echo()
|
|
254
|
+
|
|
255
|
+
client = BatchClient()
|
|
256
|
+
|
|
257
|
+
response = client.batch.describe_job_definitions(jobDefinitionName=DEFAULT_JOB_DEFINITION, status="ACTIVE")
|
|
258
|
+
if not response.get("jobDefinitions"):
|
|
259
|
+
raise click.ClickException("rfd3 job definition not yet deployed; apply Terraform in environments/dev first")
|
|
260
|
+
|
|
261
|
+
job_dir = create_job_directory(job_id, base_path)
|
|
262
|
+
click.echo(f"Created job directory: {job_dir}")
|
|
263
|
+
|
|
264
|
+
click.echo("Copying input files...")
|
|
265
|
+
copied = _copy_inputs_to_job_dir(input_path, job_dir)
|
|
266
|
+
click.echo(f"Copied {copied} YAML files")
|
|
267
|
+
|
|
268
|
+
manifest = JobManifest(
|
|
269
|
+
job_id=job_id,
|
|
270
|
+
user=job_id.split("-")[0],
|
|
271
|
+
pipeline="rfd3",
|
|
272
|
+
status=JobStatus.PENDING,
|
|
273
|
+
image_uri=DEFAULT_IMAGE_URI,
|
|
274
|
+
input=InputConfig(
|
|
275
|
+
source=str(input_path),
|
|
276
|
+
num_sequences=num_files,
|
|
277
|
+
num_chunks=array_size,
|
|
278
|
+
),
|
|
279
|
+
batch=BatchConfig(
|
|
280
|
+
queue=queue,
|
|
281
|
+
job_definition=DEFAULT_JOB_DEFINITION,
|
|
282
|
+
array_size=array_size,
|
|
283
|
+
),
|
|
284
|
+
output=OutputConfig(
|
|
285
|
+
destination=None,
|
|
286
|
+
finalized=False,
|
|
287
|
+
),
|
|
288
|
+
depends_on=list(after) if after else None,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
save_manifest(manifest, base_path)
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
resolved = [resolve_dependency(jid, base_path) for jid in after]
|
|
295
|
+
depends_on = [{"jobId": aws_id} for aws_id in resolved if aws_id is not None] or None
|
|
296
|
+
|
|
297
|
+
environment = {
|
|
298
|
+
"JOB_DIR": str(job_dir),
|
|
299
|
+
"JOB_ID": job_id,
|
|
300
|
+
"BATCH_ARRAY_SIZE": str(array_size),
|
|
301
|
+
"BATCH_NUM_FILES": str(num_files),
|
|
302
|
+
"FOUNDRY_CHECKPOINT_DIRS": CHECKPOINT_SUBTREE,
|
|
303
|
+
OFFLINE_ENV_VAR: "1",
|
|
304
|
+
"NUM_DESIGNS": str(num_designs),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
batch_job_id = client.submit_job(
|
|
308
|
+
job_name=job_id,
|
|
309
|
+
job_definition=DEFAULT_JOB_DEFINITION,
|
|
310
|
+
job_queue=queue,
|
|
311
|
+
array_size=array_size,
|
|
312
|
+
environment=environment,
|
|
313
|
+
timeout_seconds=12 * 3600,
|
|
314
|
+
retry_attempts=2,
|
|
315
|
+
depends_on=depends_on,
|
|
316
|
+
share_identifier=get_aws_username(),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
manifest.status = JobStatus.SUBMITTED
|
|
320
|
+
manifest.batch.job_id = batch_job_id
|
|
321
|
+
save_manifest(manifest, base_path)
|
|
322
|
+
|
|
323
|
+
click.echo()
|
|
324
|
+
click.echo(click.style("Job submitted successfully!", fg="green"))
|
|
325
|
+
click.echo()
|
|
326
|
+
click.echo(f"AWS Batch Job ID: {batch_job_id}")
|
|
327
|
+
if depends_on:
|
|
328
|
+
click.echo(f"Waiting on: {', '.join(after)}")
|
|
329
|
+
click.echo()
|
|
330
|
+
click.echo("Next steps:")
|
|
331
|
+
click.echo(f" Check status: dh batch status {job_id}")
|
|
332
|
+
click.echo(f" View logs: dh batch logs {job_id}")
|
|
333
|
+
click.echo(f" Cancel: dh batch cancel {job_id}")
|
|
334
|
+
click.echo()
|
|
335
|
+
click.echo("After completion:")
|
|
336
|
+
click.echo(f" Finalize: dh batch finalize {job_id} --output ./results/")
|
|
337
|
+
|
|
338
|
+
except BatchError as e:
|
|
339
|
+
manifest.status = JobStatus.FAILED
|
|
340
|
+
manifest.error_message = str(e)
|
|
341
|
+
save_manifest(manifest, base_path)
|
|
342
|
+
click.echo(click.style(f"Failed to submit job: {e}", fg="red"), err=True)
|
|
343
|
+
raise SystemExit(1)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _run_local_mode(input_path: Path, base_path: str = BATCH_JOBS_BASE):
|
|
347
|
+
"""Run RFD3 locally in a Docker container after host-side fast-fail checks."""
|
|
348
|
+
machine = platform.machine()
|
|
349
|
+
if machine not in {"x86_64", "amd64"}:
|
|
350
|
+
raise click.ClickException(
|
|
351
|
+
f"Local RFD3 requires an x86_64 host (got {machine}); "
|
|
352
|
+
"no local image exists for this architecture. Use --remote instead."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if not PRIMORDIAL_CHECKPOINT_DIR.exists() or not any(PRIMORDIAL_CHECKPOINT_DIR.iterdir()):
|
|
356
|
+
raise click.ClickException(
|
|
357
|
+
f"Foundry checkpoints not found at {PRIMORDIAL_CHECKPOINT_DIR}; "
|
|
358
|
+
"this host cannot run RFD3 locally. Use --remote instead."
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if not _has_nvidia_runtime():
|
|
362
|
+
raise click.ClickException(
|
|
363
|
+
"NVIDIA container runtime not available to Docker; cannot run RFD3 locally. Use --remote instead."
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
configs = _validate_inputs(input_path)
|
|
367
|
+
|
|
368
|
+
temp_job_dir = input_path / ".local_rfd3_job"
|
|
369
|
+
if temp_job_dir.exists():
|
|
370
|
+
shutil.rmtree(temp_job_dir)
|
|
371
|
+
(temp_job_dir / "output").mkdir(parents=True)
|
|
372
|
+
_copy_inputs_to_job_dir(input_path, temp_job_dir)
|
|
373
|
+
|
|
374
|
+
click.echo(f"Running RFD3 locally on {len(configs)} config(s)...")
|
|
375
|
+
|
|
376
|
+
cmd = [
|
|
377
|
+
"docker",
|
|
378
|
+
"run",
|
|
379
|
+
"--rm",
|
|
380
|
+
"--gpus",
|
|
381
|
+
"all",
|
|
382
|
+
"-v",
|
|
383
|
+
"/primordial:/primordial",
|
|
384
|
+
"-v",
|
|
385
|
+
f"{temp_job_dir}:{temp_job_dir}",
|
|
386
|
+
"-e",
|
|
387
|
+
f"JOB_DIR={temp_job_dir}",
|
|
388
|
+
"-e",
|
|
389
|
+
"AWS_BATCH_JOB_ARRAY_INDEX=0",
|
|
390
|
+
"-e",
|
|
391
|
+
"BATCH_ARRAY_SIZE=1",
|
|
392
|
+
"-e",
|
|
393
|
+
f"BATCH_NUM_FILES={len(configs)}",
|
|
394
|
+
"-e",
|
|
395
|
+
f"FOUNDRY_CHECKPOINT_DIRS={CHECKPOINT_SUBTREE}",
|
|
396
|
+
"-e",
|
|
397
|
+
f"{OFFLINE_ENV_VAR}=1",
|
|
398
|
+
DEFAULT_IMAGE_URI,
|
|
399
|
+
]
|
|
400
|
+
|
|
401
|
+
click.echo(f"Running: {' '.join(cmd)}")
|
|
402
|
+
subprocess.run(cmd)
|
|
@@ -231,14 +231,11 @@ def bedrock_rotate(handle: str, region: str):
|
|
|
231
231
|
)
|
|
232
232
|
|
|
233
233
|
prior_active_ids = [
|
|
234
|
-
meta["AccessKeyId"]
|
|
235
|
-
for meta in resp.get("AccessKeyMetadata", [])
|
|
236
|
-
if meta.get("Status") == "Active"
|
|
234
|
+
meta["AccessKeyId"] for meta in resp.get("AccessKeyMetadata", []) if meta.get("Status") == "Active"
|
|
237
235
|
]
|
|
238
236
|
if len(prior_active_ids) >= 2:
|
|
239
237
|
raise click.ClickException(
|
|
240
|
-
f"`{user_name}` already has 2 active keys (the IAM per-user limit). "
|
|
241
|
-
f"Delete one before rotating."
|
|
238
|
+
f"`{user_name}` already has 2 active keys (the IAM per-user limit). Delete one before rotating."
|
|
242
239
|
)
|
|
243
240
|
|
|
244
241
|
# Transaction: create → put → delete prior.
|
|
@@ -59,12 +59,8 @@ def _build_iam_stub(
|
|
|
59
59
|
iam.list_access_keys.side_effect = list_keys_error
|
|
60
60
|
else:
|
|
61
61
|
ids = existing_key_ids if existing_key_ids is not None else []
|
|
62
|
-
iam.list_access_keys.return_value = {
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
iam.create_access_key.return_value = {
|
|
66
|
-
"AccessKey": {"AccessKeyId": new_akid, "SecretAccessKey": new_secret}
|
|
67
|
-
}
|
|
62
|
+
iam.list_access_keys.return_value = {"AccessKeyMetadata": [{"AccessKeyId": k, "Status": "Active"} for k in ids]}
|
|
63
|
+
iam.create_access_key.return_value = {"AccessKey": {"AccessKeyId": new_akid, "SecretAccessKey": new_secret}}
|
|
68
64
|
return iam
|
|
69
65
|
|
|
70
66
|
|
|
@@ -30,7 +30,7 @@ import pytest
|
|
|
30
30
|
DEV_ARN = "arn:aws:sts::074735440724:assumed-role/AWSReservedSSO_DeveloperAccess_abcdef1234567890/dma"
|
|
31
31
|
HANDLE = "dma"
|
|
32
32
|
PAT_VALUE = "ghp_REALTOKEN1234567890ABCDEF"
|
|
33
|
-
PAT_CREATED = dt.datetime(
|
|
33
|
+
PAT_CREATED = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=10)
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def _build_sm_stub(
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Tests for RFD3 backbone-dir collation in _finalize_rfd3()."""
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from dh_cli.batch.commands.finalize import _finalize_rfd3
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def temp_dir():
|
|
14
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
15
|
+
yield Path(tmpdir)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _create_rfd3_output(
|
|
19
|
+
output_dir: Path,
|
|
20
|
+
completed: list[str],
|
|
21
|
+
incomplete: list[str] | None = None,
|
|
22
|
+
num_designs: int = 2,
|
|
23
|
+
):
|
|
24
|
+
"""Build a mock RFD3 worker output directory.
|
|
25
|
+
|
|
26
|
+
For each completed config ``stem``, creates:
|
|
27
|
+
- ``output/<stem>/`` holding ``num_designs`` ``.cif.gz`` + ``.json`` files
|
|
28
|
+
(the flat per-design files RFD3 emits, promoted by the worker).
|
|
29
|
+
- a loose ``output/rfd3_<stem>.done`` marker.
|
|
30
|
+
|
|
31
|
+
Incomplete configs get their ``<stem>/`` dir but NO ``.done`` marker,
|
|
32
|
+
mirroring a config that failed output validation and was never promoted.
|
|
33
|
+
Also drops a worker-level ``rfd3_0.done`` marker (array-index marker),
|
|
34
|
+
which finalize must not treat as a config.
|
|
35
|
+
"""
|
|
36
|
+
incomplete = incomplete or []
|
|
37
|
+
|
|
38
|
+
def _write_designs(stem: str):
|
|
39
|
+
config_dir = output_dir / stem
|
|
40
|
+
config_dir.mkdir(parents=True)
|
|
41
|
+
for i in range(num_designs):
|
|
42
|
+
cif = config_dir / f"{stem}_motif_0_model_{i}.cif.gz"
|
|
43
|
+
with gzip.open(cif, "wt") as f:
|
|
44
|
+
f.write("data_design\n_cell.length_a 1.0\n")
|
|
45
|
+
(config_dir / f"{stem}_motif_0_model_{i}.json").write_text('{"metrics": {"num_residues": 150}}')
|
|
46
|
+
|
|
47
|
+
for stem in completed:
|
|
48
|
+
_write_designs(stem)
|
|
49
|
+
(output_dir / f"rfd3_{stem}.done").touch()
|
|
50
|
+
|
|
51
|
+
for stem in incomplete:
|
|
52
|
+
_write_designs(stem)
|
|
53
|
+
|
|
54
|
+
# Worker array-index marker (not a config); finalize must ignore it.
|
|
55
|
+
(output_dir / "rfd3_0.done").touch()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestFinalizeRfd3CollatesBackboneDirs:
|
|
59
|
+
def test_finalize_collates_backbone_dirs(self, temp_dir):
|
|
60
|
+
output_dir = temp_dir / "output"
|
|
61
|
+
output_dir.mkdir()
|
|
62
|
+
dest = temp_dir / "final"
|
|
63
|
+
|
|
64
|
+
_create_rfd3_output(
|
|
65
|
+
output_dir,
|
|
66
|
+
completed=["sortase_a", "sortase_b"],
|
|
67
|
+
num_designs=3,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_finalize_rfd3(output_dir, dest)
|
|
71
|
+
|
|
72
|
+
# Each completed config becomes a per-config backbone dir in the output.
|
|
73
|
+
assert (dest / "sortase_a").is_dir()
|
|
74
|
+
assert (dest / "sortase_b").is_dir()
|
|
75
|
+
|
|
76
|
+
# The flat per-design .cif.gz + .json files are carried through.
|
|
77
|
+
a_cifs = sorted((dest / "sortase_a").glob("*.cif.gz"))
|
|
78
|
+
a_jsons = sorted((dest / "sortase_a").glob("*.json"))
|
|
79
|
+
assert len(a_cifs) == 3
|
|
80
|
+
assert len(a_jsons) == 3
|
|
81
|
+
|
|
82
|
+
# Loose .done markers are not copied into the finalized output.
|
|
83
|
+
assert not list(dest.glob("*.done"))
|
|
84
|
+
|
|
85
|
+
def test_no_done_markers_in_finalized_output(self, temp_dir):
|
|
86
|
+
output_dir = temp_dir / "output"
|
|
87
|
+
output_dir.mkdir()
|
|
88
|
+
dest = temp_dir / "final"
|
|
89
|
+
|
|
90
|
+
_create_rfd3_output(output_dir, completed=["cfg"], num_designs=1)
|
|
91
|
+
|
|
92
|
+
_finalize_rfd3(output_dir, dest)
|
|
93
|
+
|
|
94
|
+
assert not (dest / "rfd3_cfg.done").exists()
|
|
95
|
+
assert not (dest / "rfd3_0.done").exists()
|
|
96
|
+
assert (dest / "cfg").is_dir()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestFinalizeRfd3SkipsIncomplete:
|
|
100
|
+
def test_finalize_skips_incomplete(self, temp_dir):
|
|
101
|
+
output_dir = temp_dir / "output"
|
|
102
|
+
output_dir.mkdir()
|
|
103
|
+
dest = temp_dir / "final"
|
|
104
|
+
|
|
105
|
+
_create_rfd3_output(
|
|
106
|
+
output_dir,
|
|
107
|
+
completed=["good"],
|
|
108
|
+
incomplete=["bad"],
|
|
109
|
+
num_designs=2,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
_finalize_rfd3(output_dir, dest)
|
|
113
|
+
|
|
114
|
+
# The config with a .done marker is collated.
|
|
115
|
+
assert (dest / "good").is_dir()
|
|
116
|
+
# The config missing its .done marker is excluded.
|
|
117
|
+
assert not (dest / "bad").exists()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestFinalizeRfd3Empty:
|
|
121
|
+
def test_no_completed_configs_errors(self, temp_dir):
|
|
122
|
+
output_dir = temp_dir / "output"
|
|
123
|
+
output_dir.mkdir()
|
|
124
|
+
dest = temp_dir / "final"
|
|
125
|
+
|
|
126
|
+
# Only a worker-index marker, no per-config .done markers.
|
|
127
|
+
(output_dir / "rfd3_0.done").touch()
|
|
128
|
+
|
|
129
|
+
with pytest.raises(SystemExit):
|
|
130
|
+
_finalize_rfd3(output_dir, dest)
|