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.
Files changed (99) hide show
  1. {dh_cli-0.8.8 → dh_cli-0.9.0}/PKG-INFO +1 -1
  2. {dh_cli-0.8.8 → dh_cli-0.9.0}/pyproject.toml +1 -1
  3. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/__init__.py +2 -0
  4. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/finalize.py +36 -0
  5. dh_cli-0.9.0/src/dh_cli/batch/commands/rfd3.py +402 -0
  6. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/bedrock/commands.py +2 -5
  7. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_rotate.py +2 -6
  8. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/conftest.py +1 -1
  9. dh_cli-0.9.0/tests/test_finalize_rfd3.py +130 -0
  10. dh_cli-0.9.0/tests/test_rfd3_command.py +456 -0
  11. dh_cli-0.8.8/tests/bedrock/test_cursor_bedrock_isolation.py +0 -271
  12. {dh_cli-0.8.8 → dh_cli-0.9.0}/.gitignore +0 -0
  13. {dh_cli-0.8.8 → dh_cli-0.9.0}/LICENSE +0 -0
  14. {dh_cli-0.8.8 → dh_cli-0.9.0}/README.md +0 -0
  15. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/__init__.py +0 -0
  16. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/_identity.py +0 -0
  17. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/aws_batch.py +0 -0
  18. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/__init__.py +0 -0
  19. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/boltz.py +0 -0
  20. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/cancel.py +0 -0
  21. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/clean.py +0 -0
  22. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  23. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  24. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/local.py +0 -0
  25. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/logs.py +0 -0
  26. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/orca.py +0 -0
  27. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  28. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  29. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/retry.py +0 -0
  30. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/status.py +0 -0
  31. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/submit.py +0 -0
  32. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/train.py +0 -0
  33. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
  34. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/fasta_utils.py +0 -0
  35. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/h5_utils.py +0 -0
  36. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/job_id.py +0 -0
  37. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/manifest.py +0 -0
  38. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/batch/s3_transport.py +0 -0
  39. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/bedrock/__init__.py +0 -0
  40. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/bedrock/cost_report.py +0 -0
  41. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/bedrock/pricing.yaml +0 -0
  42. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/cloud_commands.py +0 -0
  43. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/codeartifact.py +0 -0
  44. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/__init__.py +0 -0
  45. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/api_client.py +0 -0
  46. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/auth.py +0 -0
  47. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
  48. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/progress.py +0 -0
  49. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  50. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  51. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/github_commands.py +0 -0
  52. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/__init__.py +0 -0
  53. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/deploy.py +0 -0
  54. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/local.py +0 -0
  55. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/test.py +0 -0
  56. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/tf.py +0 -0
  57. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/hz/users.py +0 -0
  58. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/main.py +0 -0
  59. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/utility_commands.py +0 -0
  60. {dh_cli-0.8.8 → dh_cli-0.9.0}/src/dh_cli/warehouse.py +0 -0
  61. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/__init__.py +0 -0
  62. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/test_aws_batch_resources.py +0 -0
  63. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/test_image_override.py +0 -0
  64. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/test_submit_cpu_only.py +0 -0
  65. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/test_submit_image_validation.py +0 -0
  66. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/batch/test_submit_merge.py +0 -0
  67. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/conftest.py +0 -0
  68. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/A_cache_write.json +0 -0
  69. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/B_cache_read.json +0 -0
  70. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/C_plain.json +0 -0
  71. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
  72. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/E_service_role.json +0 -0
  73. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
  74. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
  75. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_build_report.py +0 -0
  76. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_classify_arn.py +0 -0
  77. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_cli_exit_codes.py +0 -0
  78. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_cost_calc.py +0 -0
  79. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_cost_command.py +0 -0
  80. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_cur_reconciliation.py +0 -0
  81. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_key_command.py +0 -0
  82. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_render_formats.py +0 -0
  83. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_resolve_base_model.py +0 -0
  84. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/bedrock/test_s3_walker.py +0 -0
  85. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/__init__.py +0 -0
  86. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
  87. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_identity.py +0 -0
  88. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_login.py +0 -0
  89. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_login_error_paths.py +0 -0
  90. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_login_security.py +0 -0
  91. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_logout.py +0 -0
  92. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_rotate.py +0 -0
  93. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/github/test_status.py +0 -0
  94. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/hz/test_init.py +0 -0
  95. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/hz/test_suites.py +0 -0
  96. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/hz/test_users.py +0 -0
  97. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/test_cloud_gcp.py +0 -0
  98. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/test_finalize_boltz_tar.py +0 -0
  99. {dh_cli-0.8.8 → dh_cli-0.9.0}/tests/test_finalize_protmpnn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dh-cli
3
- Version: 0.8.8
3
+ Version: 0.9.0
4
4
  Summary: Dayhoff Labs developer CLI
5
5
  Author-email: Dayhoff Labs <dev@dayhofflabs.com>
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dh-cli"
7
- version = "0.8.8"
7
+ version = "0.9.0"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -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
- "AccessKeyMetadata": [{"AccessKeyId": k, "Status": "Active"} for k in ids]
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(2026, 4, 1, 12, 0, tzinfo=dt.timezone.utc)
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)