dh-cli 0.9.0__tar.gz → 0.10.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.9.0 → dh_cli-0.10.0}/PKG-INFO +1 -1
- {dh_cli-0.9.0 → dh_cli-0.10.0}/pyproject.toml +1 -1
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/__init__.py +2 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/__init__.py +0 -2
- dh_cli-0.10.0/src/dh_cli/batch/commands/equilibrator.py +319 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/finalize.py +31 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/manifest.py +2 -0
- dh_cli-0.10.0/tests/test_equilibrator_command.py +229 -0
- dh_cli-0.10.0/tests/test_finalize_equilibrator.py +191 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/.gitignore +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/LICENSE +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/README.md +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/_identity.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/rfd3.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/train.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/bedrock/__init__.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/bedrock/commands.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/bedrock/cost_report.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/bedrock/pricing.yaml +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/engine_commands.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/main.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/utility_commands.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/__init__.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/test_aws_batch_resources.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/test_image_override.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/test_submit_cpu_only.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/test_submit_image_validation.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/batch/test_submit_merge.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/conftest.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/A_cache_write.json +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/B_cache_read.json +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/C_plain.json +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/E_service_role.json +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_build_report.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_classify_arn.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_cli_exit_codes.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_cost_calc.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_cost_command.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_cur_reconciliation.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_key_command.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_render_formats.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_resolve_base_model.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_rotate.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/bedrock/test_s3_walker.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/__init__.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/conftest.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_identity.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_login.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_login_error_paths.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_login_security.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_logout.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_rotate.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/github/test_status.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/hz/test_init.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/hz/test_users.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/test_finalize_boltz_tar.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/test_finalize_protmpnn.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/test_finalize_rfd3.py +0 -0
- {dh_cli-0.9.0 → dh_cli-0.10.0}/tests/test_rfd3_command.py +0 -0
|
@@ -14,6 +14,7 @@ from .commands.boltz import boltz
|
|
|
14
14
|
from .commands.cancel import cancel
|
|
15
15
|
from .commands.clean import clean
|
|
16
16
|
from .commands.embed_t5 import embed_t5
|
|
17
|
+
from .commands.equilibrator import equilibrator
|
|
17
18
|
from .commands.finalize import finalize
|
|
18
19
|
from .commands.list_jobs import list_jobs
|
|
19
20
|
from .commands.local import local
|
|
@@ -99,6 +100,7 @@ batch_cli.add_command(wait_for, name="wait-for")
|
|
|
99
100
|
|
|
100
101
|
# Register pipeline commands
|
|
101
102
|
batch_cli.add_command(embed_t5, name="embed-t5")
|
|
103
|
+
batch_cli.add_command(equilibrator, name="equilibrator")
|
|
102
104
|
batch_cli.add_command(boltz)
|
|
103
105
|
batch_cli.add_command(protmpnn)
|
|
104
106
|
batch_cli.add_command(orca)
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from .boltz import boltz
|
|
4
4
|
from .cancel import cancel
|
|
5
5
|
from .embed_t5 import embed_t5
|
|
6
|
-
from .finalize import finalize
|
|
7
6
|
from .list_jobs import list_jobs
|
|
8
7
|
from .local import local
|
|
9
8
|
from .logs import logs
|
|
@@ -15,7 +14,6 @@ __all__ = [
|
|
|
15
14
|
"boltz",
|
|
16
15
|
"cancel",
|
|
17
16
|
"embed_t5",
|
|
18
|
-
"finalize",
|
|
19
17
|
"list_jobs",
|
|
20
18
|
"local",
|
|
21
19
|
"logs",
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""eQuilibrator reaction ΔG°' pipeline command."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import secrets
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..aws_batch import BatchClient, BatchError, resolve_dependency
|
|
12
|
+
from ..job_id import get_aws_username
|
|
13
|
+
from ..manifest import (
|
|
14
|
+
BATCH_JOBS_BASE,
|
|
15
|
+
BatchConfig,
|
|
16
|
+
InputConfig,
|
|
17
|
+
JobManifest,
|
|
18
|
+
JobStatus,
|
|
19
|
+
OutputConfig,
|
|
20
|
+
create_job_directory,
|
|
21
|
+
save_local_stub,
|
|
22
|
+
save_manifest,
|
|
23
|
+
save_manifest_s3,
|
|
24
|
+
)
|
|
25
|
+
from ..s3_transport import s3_job_prefix, upload_directory
|
|
26
|
+
|
|
27
|
+
DEFAULT_QUEUE = "cpu-spot"
|
|
28
|
+
DEFAULT_JOB_DEFINITION = "dayhoff-equilibrator"
|
|
29
|
+
DEFAULT_IMAGE_URI = "074735440724.dkr.ecr.us-east-1.amazonaws.com/dayhoff:equilibrator-latest"
|
|
30
|
+
REACTIONS_PER_WORKER = 1000
|
|
31
|
+
MAX_WORKERS = 50
|
|
32
|
+
|
|
33
|
+
ASSETS_REPO_PATH = "/primordial/equilibrator-assets"
|
|
34
|
+
# ~4s/reaction * REACTIONS_PER_WORKER + sqlite copy + headroom.
|
|
35
|
+
TIMEOUT_SECONDS = 3 * 3600
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _generate_job_id() -> str:
|
|
39
|
+
"""Job ID as ``{username}-equilibrator-{YYYYMMDD}-{random8}``.
|
|
40
|
+
|
|
41
|
+
Built here (not via ``generate_job_id``) so the full ``equilibrator``
|
|
42
|
+
pipeline name survives — the generic helper truncates to 10 chars.
|
|
43
|
+
"""
|
|
44
|
+
username = get_aws_username()
|
|
45
|
+
date_str = datetime.now().strftime("%Y%m%d")
|
|
46
|
+
return f"{username}-equilibrator-{date_str}-{secrets.token_hex(4)}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_primordial_path(path: Path) -> bool:
|
|
50
|
+
"""Check if a path is on Primordial (EFS)."""
|
|
51
|
+
return str(path).startswith("/primordial/")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolve_image_digest() -> str:
|
|
55
|
+
"""Best-effort resolution of the container image digest via ECR.
|
|
56
|
+
|
|
57
|
+
Patched out in tests. Never crashes the command.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
[
|
|
62
|
+
"aws",
|
|
63
|
+
"ecr",
|
|
64
|
+
"describe-images",
|
|
65
|
+
"--repository-name",
|
|
66
|
+
"dayhoff",
|
|
67
|
+
"--image-ids",
|
|
68
|
+
"imageTag=equilibrator-latest",
|
|
69
|
+
"--query",
|
|
70
|
+
"imageDetails[0].imageDigest",
|
|
71
|
+
"--output",
|
|
72
|
+
"text",
|
|
73
|
+
],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
timeout=15,
|
|
77
|
+
)
|
|
78
|
+
digest = result.stdout.strip()
|
|
79
|
+
if result.returncode == 0 and digest and digest != "None":
|
|
80
|
+
return digest
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
return DEFAULT_IMAGE_URI
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_assets_commit() -> str:
|
|
87
|
+
"""Best-effort current equilibrator-assets git commit short SHA.
|
|
88
|
+
|
|
89
|
+
Patched out in tests. Returns "unknown" on failure.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["git", "-C", ASSETS_REPO_PATH, "rev-parse", "--short", "HEAD"],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=10,
|
|
97
|
+
)
|
|
98
|
+
sha = result.stdout.strip()
|
|
99
|
+
if result.returncode == 0 and sha:
|
|
100
|
+
return sha
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
return "unknown"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_reactions(input_path: Path) -> list[str]:
|
|
107
|
+
"""Return non-blank reaction lines from the input file, in order."""
|
|
108
|
+
return [line for line in input_path.read_text().splitlines() if line.strip()]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _chunk_reactions(reactions: list[str], array_size: int) -> list[list[str]]:
|
|
112
|
+
"""Split reactions into ``array_size`` contiguous, balanced chunks.
|
|
113
|
+
|
|
114
|
+
First ``n % k`` chunks get ceil(n/k) lines, the rest get floor(n/k).
|
|
115
|
+
"""
|
|
116
|
+
n = len(reactions)
|
|
117
|
+
base = n // array_size
|
|
118
|
+
remainder = n % array_size
|
|
119
|
+
chunks: list[list[str]] = []
|
|
120
|
+
start = 0
|
|
121
|
+
for i in range(array_size):
|
|
122
|
+
size = base + (1 if i < remainder else 0)
|
|
123
|
+
chunks.append(reactions[start : start + size])
|
|
124
|
+
start += size
|
|
125
|
+
return chunks
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _write_chunks(input_dir: Path, chunks: list[list[str]]) -> None:
|
|
129
|
+
"""Write each chunk to ``input_dir/chunk_{i:03d}.txt``."""
|
|
130
|
+
input_dir.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
for i, chunk in enumerate(chunks):
|
|
132
|
+
(input_dir / f"chunk_{i:03d}.txt").write_text("\n".join(chunk) + "\n")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@click.command()
|
|
136
|
+
@click.argument("input_file", type=click.Path(exists=True))
|
|
137
|
+
@click.option(
|
|
138
|
+
"--workers",
|
|
139
|
+
default=None,
|
|
140
|
+
type=int,
|
|
141
|
+
help=f"Number of parallel workers [default: ~1 per {REACTIONS_PER_WORKER} reactions]",
|
|
142
|
+
)
|
|
143
|
+
@click.option("--queue", default=DEFAULT_QUEUE, help=f"Batch queue [default: {DEFAULT_QUEUE}]")
|
|
144
|
+
@click.option("--dry-run", is_flag=True, help="Show plan without submitting")
|
|
145
|
+
@click.option("--base-path", default=BATCH_JOBS_BASE, help="Base path for job data")
|
|
146
|
+
@click.option("--after", "after", multiple=True, help="Job ID(s) to wait for before starting")
|
|
147
|
+
def equilibrator(input_file, workers, queue, dry_run, base_path, after):
|
|
148
|
+
"""Compute reaction ΔG°' with eQuilibrator.
|
|
149
|
+
|
|
150
|
+
Splits an input file of reaction SMILES (one per line) into chunks and
|
|
151
|
+
processes them in parallel using AWS Batch array jobs.
|
|
152
|
+
|
|
153
|
+
\b
|
|
154
|
+
Examples:
|
|
155
|
+
# Submit to AWS Batch (auto-sized workers)
|
|
156
|
+
dh batch equilibrator /primordial/reactions.txt
|
|
157
|
+
|
|
158
|
+
# Force a specific worker count
|
|
159
|
+
dh batch equilibrator reactions.txt --workers 20
|
|
160
|
+
|
|
161
|
+
# Preview what would run
|
|
162
|
+
dh batch equilibrator reactions.txt --dry-run
|
|
163
|
+
"""
|
|
164
|
+
input_path = Path(input_file).resolve()
|
|
165
|
+
|
|
166
|
+
click.echo(f"Counting reactions in {input_path}...")
|
|
167
|
+
reactions = _read_reactions(input_path)
|
|
168
|
+
num_reactions = len(reactions)
|
|
169
|
+
click.echo(f"Found {num_reactions} reaction(s)")
|
|
170
|
+
|
|
171
|
+
if num_reactions == 0:
|
|
172
|
+
click.echo(click.style("Error: No reactions found in input file", fg="red"), err=True)
|
|
173
|
+
raise SystemExit(1)
|
|
174
|
+
|
|
175
|
+
if workers is None:
|
|
176
|
+
workers = min(math.ceil(num_reactions / REACTIONS_PER_WORKER), MAX_WORKERS)
|
|
177
|
+
array_size = min(workers, num_reactions)
|
|
178
|
+
|
|
179
|
+
use_s3 = not _is_primordial_path(input_path) and str(base_path) == BATCH_JOBS_BASE
|
|
180
|
+
|
|
181
|
+
job_id = _generate_job_id()
|
|
182
|
+
|
|
183
|
+
click.echo()
|
|
184
|
+
click.echo(f"Job ID: {job_id}")
|
|
185
|
+
click.echo(f"Input: {input_path}")
|
|
186
|
+
click.echo(f"Reactions: {num_reactions}")
|
|
187
|
+
click.echo(f"Workers: {array_size}")
|
|
188
|
+
click.echo(f"Queue: {queue}")
|
|
189
|
+
click.echo(f"Job definition: {DEFAULT_JOB_DEFINITION}")
|
|
190
|
+
click.echo(f"Storage: {'S3 (remote)' if use_s3 else 'Primordial'}")
|
|
191
|
+
|
|
192
|
+
if dry_run:
|
|
193
|
+
click.echo()
|
|
194
|
+
click.echo(click.style("Dry run - job not submitted", fg="yellow"))
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
if not click.confirm("\nSubmit job?", default=True):
|
|
198
|
+
click.echo("Cancelled.")
|
|
199
|
+
raise SystemExit(0)
|
|
200
|
+
click.echo()
|
|
201
|
+
|
|
202
|
+
client = BatchClient()
|
|
203
|
+
|
|
204
|
+
response = client.batch.describe_job_definitions(jobDefinitionName=DEFAULT_JOB_DEFINITION, status="ACTIVE")
|
|
205
|
+
if not response.get("jobDefinitions"):
|
|
206
|
+
raise click.ClickException(
|
|
207
|
+
"equilibrator job definition not yet deployed; apply Terraform in environments/dev first"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
chunks = _chunk_reactions(reactions, array_size)
|
|
211
|
+
image_digest = _resolve_image_digest()
|
|
212
|
+
source_commit = _resolve_assets_commit()
|
|
213
|
+
|
|
214
|
+
s3_prefix = s3_job_prefix(job_id) if use_s3 else None
|
|
215
|
+
job_dir_path = Path(base_path) / job_id
|
|
216
|
+
|
|
217
|
+
if use_s3:
|
|
218
|
+
import tempfile as _tempfile
|
|
219
|
+
|
|
220
|
+
with _tempfile.TemporaryDirectory() as tmpdir:
|
|
221
|
+
tmp_input_dir = Path(tmpdir) / "input"
|
|
222
|
+
_write_chunks(tmp_input_dir, chunks)
|
|
223
|
+
click.echo("Uploading chunks to S3...")
|
|
224
|
+
upload_directory(tmp_input_dir, f"{s3_prefix}input/", glob="*.txt")
|
|
225
|
+
click.echo(f"Uploaded {array_size} chunks to S3")
|
|
226
|
+
else:
|
|
227
|
+
job_dir_path = create_job_directory(job_id, base_path)
|
|
228
|
+
click.echo(f"Created job directory: {job_dir_path}")
|
|
229
|
+
_write_chunks(job_dir_path / "input", chunks)
|
|
230
|
+
click.echo(f"Created {array_size} chunks")
|
|
231
|
+
|
|
232
|
+
manifest = JobManifest(
|
|
233
|
+
job_id=job_id,
|
|
234
|
+
user=job_id.split("-")[0],
|
|
235
|
+
pipeline="equilibrator",
|
|
236
|
+
status=JobStatus.PENDING,
|
|
237
|
+
image_uri=DEFAULT_IMAGE_URI,
|
|
238
|
+
image_digest=image_digest,
|
|
239
|
+
source_commit=source_commit,
|
|
240
|
+
storage_mode="s3" if use_s3 else "primordial",
|
|
241
|
+
s3_prefix=s3_prefix,
|
|
242
|
+
input=InputConfig(
|
|
243
|
+
source=str(input_path),
|
|
244
|
+
num_sequences=num_reactions,
|
|
245
|
+
num_chunks=array_size,
|
|
246
|
+
),
|
|
247
|
+
batch=BatchConfig(
|
|
248
|
+
queue=queue,
|
|
249
|
+
job_definition=DEFAULT_JOB_DEFINITION,
|
|
250
|
+
array_size=array_size,
|
|
251
|
+
),
|
|
252
|
+
output=OutputConfig(
|
|
253
|
+
destination=None,
|
|
254
|
+
finalized=False,
|
|
255
|
+
),
|
|
256
|
+
depends_on=list(after) if after else None,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if use_s3:
|
|
260
|
+
save_manifest_s3(manifest)
|
|
261
|
+
save_local_stub(job_id, s3_prefix)
|
|
262
|
+
else:
|
|
263
|
+
save_manifest(manifest, base_path)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
resolved = [resolve_dependency(jid, base_path) for jid in after]
|
|
267
|
+
depends_on = [{"jobId": aws_id} for aws_id in resolved if aws_id is not None] or None
|
|
268
|
+
|
|
269
|
+
environment = {
|
|
270
|
+
"JOB_DIR": str(job_dir_path),
|
|
271
|
+
"JOB_ID": job_id,
|
|
272
|
+
}
|
|
273
|
+
if use_s3:
|
|
274
|
+
environment["STORAGE_MODE"] = "s3"
|
|
275
|
+
environment["S3_JOB_PREFIX"] = s3_prefix
|
|
276
|
+
|
|
277
|
+
batch_job_id = client.submit_job(
|
|
278
|
+
job_name=job_id,
|
|
279
|
+
job_definition=DEFAULT_JOB_DEFINITION,
|
|
280
|
+
job_queue=queue,
|
|
281
|
+
array_size=array_size,
|
|
282
|
+
environment=environment,
|
|
283
|
+
timeout_seconds=TIMEOUT_SECONDS,
|
|
284
|
+
retry_attempts=5,
|
|
285
|
+
depends_on=depends_on,
|
|
286
|
+
share_identifier=get_aws_username(),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
manifest.status = JobStatus.SUBMITTED
|
|
290
|
+
manifest.batch.job_id = batch_job_id
|
|
291
|
+
if use_s3:
|
|
292
|
+
save_manifest_s3(manifest)
|
|
293
|
+
else:
|
|
294
|
+
save_manifest(manifest, base_path)
|
|
295
|
+
|
|
296
|
+
click.echo()
|
|
297
|
+
click.echo(click.style("Job submitted successfully!", fg="green"))
|
|
298
|
+
click.echo()
|
|
299
|
+
click.echo(f"AWS Batch Job ID: {batch_job_id}")
|
|
300
|
+
if depends_on:
|
|
301
|
+
click.echo(f"Waiting on: {', '.join(after)}")
|
|
302
|
+
click.echo()
|
|
303
|
+
click.echo("Next steps:")
|
|
304
|
+
click.echo(f" Check status: dh batch status {job_id}")
|
|
305
|
+
click.echo(f" View logs: dh batch logs {job_id}")
|
|
306
|
+
click.echo(f" Cancel: dh batch cancel {job_id}")
|
|
307
|
+
click.echo()
|
|
308
|
+
click.echo("After completion:")
|
|
309
|
+
click.echo(f" Finalize: dh batch finalize {job_id} --output ./results/")
|
|
310
|
+
|
|
311
|
+
except BatchError as e:
|
|
312
|
+
manifest.status = JobStatus.FAILED
|
|
313
|
+
manifest.error_message = str(e)
|
|
314
|
+
if use_s3:
|
|
315
|
+
save_manifest_s3(manifest)
|
|
316
|
+
else:
|
|
317
|
+
save_manifest(manifest, base_path)
|
|
318
|
+
click.echo(click.style(f"Failed to submit job: {e}", fg="red"), err=True)
|
|
319
|
+
raise SystemExit(1)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Finalize command for combining results and cleaning up."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import shutil
|
|
4
5
|
import tarfile
|
|
5
6
|
import tempfile
|
|
@@ -145,6 +146,8 @@ def finalize(job_id, output, force, keep_intermediates, full_output, skip_dedup,
|
|
|
145
146
|
_finalize_protmpnn(output_dir, output_path)
|
|
146
147
|
elif manifest.pipeline == "rfd3":
|
|
147
148
|
_finalize_rfd3(output_dir, output_path)
|
|
149
|
+
elif manifest.pipeline == "equilibrator":
|
|
150
|
+
_finalize_equilibrator(output_dir, output_path, job_id=job_id)
|
|
148
151
|
else:
|
|
149
152
|
_finalize_generic(output_dir, output_path)
|
|
150
153
|
|
|
@@ -580,3 +583,31 @@ def _finalize_rfd3(output_dir: Path, output_path: Path):
|
|
|
580
583
|
|
|
581
584
|
click.echo(f"Collated {copied_count} RFD3 config(s)")
|
|
582
585
|
click.echo(click.style(f"Results: {output_path}", fg="green"))
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _finalize_equilibrator(output_dir: Path, output_path: Path, job_id: str | None = None) -> None:
|
|
589
|
+
"""Combine per-chunk JSONL worker outputs into one sorted JSONL file.
|
|
590
|
+
|
|
591
|
+
Reads every ``chunk_*.jsonl`` under ``output_dir``, sorts all records by
|
|
592
|
+
``input_index``, and writes them to the destination. A missing chunk
|
|
593
|
+
(gap in indices) is tolerated. If ``output_path`` is an existing
|
|
594
|
+
directory, the combined file is named ``{job_id}.jsonl`` inside it.
|
|
595
|
+
"""
|
|
596
|
+
if output_path.is_dir():
|
|
597
|
+
output_path = output_path / f"{job_id}.jsonl"
|
|
598
|
+
|
|
599
|
+
records: list[dict] = []
|
|
600
|
+
for chunk_file in sorted(output_dir.glob("chunk_*.jsonl")):
|
|
601
|
+
for line in chunk_file.read_text().splitlines():
|
|
602
|
+
if line.strip():
|
|
603
|
+
records.append(json.loads(line))
|
|
604
|
+
|
|
605
|
+
records.sort(key=lambda r: r["input_index"])
|
|
606
|
+
|
|
607
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
608
|
+
with open(output_path, "w") as f:
|
|
609
|
+
for rec in records:
|
|
610
|
+
f.write(json.dumps(rec) + "\n")
|
|
611
|
+
|
|
612
|
+
shutil.rmtree(output_dir, ignore_errors=True)
|
|
613
|
+
click.echo(f"Combined {len(records)} records into {output_path}")
|
|
@@ -90,6 +90,8 @@ class JobManifest(BaseModel):
|
|
|
90
90
|
|
|
91
91
|
# Additional metadata
|
|
92
92
|
image_uri: str | None = Field(None, description="Container image URI")
|
|
93
|
+
image_digest: str | None = Field(None, description="Resolved container image digest")
|
|
94
|
+
source_commit: str | None = Field(None, description="Assets/source repo commit for reproducibility")
|
|
93
95
|
command: str | None = Field(None, description="Command to run")
|
|
94
96
|
error_message: str | None = Field(None, description="Error message if failed")
|
|
95
97
|
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Tests for the `dh batch equilibrator` CLI command.
|
|
2
|
+
|
|
3
|
+
All AWS interactions are mocked; no real Batch calls. Uses Click's CliRunner
|
|
4
|
+
and unittest.mock, mirroring the existing dh-cli test conventions
|
|
5
|
+
(see test_rfd3_command.py).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import MagicMock, patch
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from click.testing import CliRunner
|
|
16
|
+
|
|
17
|
+
from dh_cli.batch.commands import equilibrator as eq_cmd
|
|
18
|
+
|
|
19
|
+
JOB_ID_RE = re.compile(r"^[a-z0-9]+-equilibrator-\d{8}-[0-9a-f]{8}$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def runner():
|
|
24
|
+
return CliRunner()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def input_dir():
|
|
29
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
30
|
+
yield Path(tmpdir)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write_reactions(input_dir: Path, n: int, name: str = "reactions.txt") -> Path:
|
|
34
|
+
"""Write ``n`` distinct reaction SMILES lines to an input file."""
|
|
35
|
+
lines = [f"CC{'C' * i}O.O>>CC{'C' * i}(=O)O" for i in range(n)]
|
|
36
|
+
p = input_dir / name
|
|
37
|
+
p.write_text("\n".join(lines) + "\n")
|
|
38
|
+
return p
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _submit_mock():
|
|
42
|
+
"""A BatchClient whose submit_job returns a fake AWS job id and whose
|
|
43
|
+
job-definition preflight reports an ACTIVE dayhoff-equilibrator."""
|
|
44
|
+
client = MagicMock()
|
|
45
|
+
client.submit_job.return_value = "aws-job-123"
|
|
46
|
+
client.batch.describe_job_definitions.return_value = {
|
|
47
|
+
"jobDefinitions": [{"jobDefinitionName": "dayhoff-equilibrator", "status": "ACTIVE"}]
|
|
48
|
+
}
|
|
49
|
+
return client
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _invoke_submit(runner, input_file, args=(), client=None, base=None):
|
|
53
|
+
"""Invoke the command on the submit path with AWS + metadata resolution mocked."""
|
|
54
|
+
client = client or _submit_mock()
|
|
55
|
+
if base is None:
|
|
56
|
+
base = Path(input_file).parent / "_jobs"
|
|
57
|
+
with (
|
|
58
|
+
patch.object(eq_cmd, "BatchClient", return_value=client),
|
|
59
|
+
patch.object(eq_cmd, "resolve_dependency", return_value="dep-aws-id"),
|
|
60
|
+
patch.object(eq_cmd, "_resolve_image_digest", return_value="sha256:deadbeef"),
|
|
61
|
+
patch.object(eq_cmd, "_resolve_assets_commit", return_value="abc1234"),
|
|
62
|
+
):
|
|
63
|
+
result = runner.invoke(
|
|
64
|
+
eq_cmd.equilibrator,
|
|
65
|
+
[str(input_file), "--base-path", str(base), *args],
|
|
66
|
+
input="y\n",
|
|
67
|
+
)
|
|
68
|
+
return result, client, base
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestJobIdAndDefaults:
|
|
72
|
+
def test_job_id_format(self, runner, input_dir):
|
|
73
|
+
rxns = _write_reactions(input_dir, 3)
|
|
74
|
+
result, client, _ = _invoke_submit(runner, rxns)
|
|
75
|
+
assert result.exit_code == 0, result.output
|
|
76
|
+
job_name = client.submit_job.call_args.kwargs["job_name"]
|
|
77
|
+
assert JOB_ID_RE.match(job_name), job_name
|
|
78
|
+
|
|
79
|
+
def test_default_queue_is_cpu(self, runner, input_dir):
|
|
80
|
+
rxns = _write_reactions(input_dir, 3)
|
|
81
|
+
result, client, _ = _invoke_submit(runner, rxns)
|
|
82
|
+
assert result.exit_code == 0, result.output
|
|
83
|
+
assert client.submit_job.call_args.kwargs["job_queue"] == "cpu-spot"
|
|
84
|
+
assert client.submit_job.call_args.kwargs["job_definition"] == "dayhoff-equilibrator"
|
|
85
|
+
assert eq_cmd.DEFAULT_QUEUE == "cpu-spot"
|
|
86
|
+
assert eq_cmd.DEFAULT_JOB_DEFINITION == "dayhoff-equilibrator"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestChunking:
|
|
90
|
+
def test_one_chunk_file_per_index(self, runner, input_dir):
|
|
91
|
+
n = 7
|
|
92
|
+
rxns = _write_reactions(input_dir, n)
|
|
93
|
+
base = input_dir / "_jobs"
|
|
94
|
+
result, client, base = _invoke_submit(runner, rxns, ["--workers", str(n)], base=base)
|
|
95
|
+
assert result.exit_code == 0, result.output
|
|
96
|
+
|
|
97
|
+
array_size = client.submit_job.call_args.kwargs["array_size"]
|
|
98
|
+
chunk_files = sorted(Path(base).rglob("input/chunk_*.txt"))
|
|
99
|
+
assert len(chunk_files) == array_size
|
|
100
|
+
|
|
101
|
+
# Index N's file is chunk_{N:03d}.txt.
|
|
102
|
+
names = {p.name for p in chunk_files}
|
|
103
|
+
for i in range(array_size):
|
|
104
|
+
assert f"chunk_{i:03d}.txt" in names
|
|
105
|
+
|
|
106
|
+
# Union of chunk lines equals the input reactions, in order.
|
|
107
|
+
original = [ln for ln in rxns.read_text().splitlines() if ln.strip()]
|
|
108
|
+
combined: list[str] = []
|
|
109
|
+
for p in chunk_files:
|
|
110
|
+
combined.extend(ln for ln in p.read_text().splitlines() if ln.strip())
|
|
111
|
+
assert combined == original
|
|
112
|
+
|
|
113
|
+
def test_chunking_default(self, runner, input_dir):
|
|
114
|
+
n = 5
|
|
115
|
+
rxns = _write_reactions(input_dir, n)
|
|
116
|
+
result, client, _ = _invoke_submit(runner, rxns)
|
|
117
|
+
assert result.exit_code == 0, result.output
|
|
118
|
+
expected = min(math.ceil(n / eq_cmd.REACTIONS_PER_WORKER), eq_cmd.MAX_WORKERS)
|
|
119
|
+
assert client.submit_job.call_args.kwargs["array_size"] == expected
|
|
120
|
+
|
|
121
|
+
def test_workers_override(self, runner, input_dir):
|
|
122
|
+
n = eq_cmd.REACTIONS_PER_WORKER * 10 # large input
|
|
123
|
+
rxns = _write_reactions(input_dir, n)
|
|
124
|
+
base = input_dir / "_jobs"
|
|
125
|
+
result, client, base = _invoke_submit(runner, rxns, ["--workers", "4"], base=base)
|
|
126
|
+
assert result.exit_code == 0, result.output
|
|
127
|
+
assert client.submit_job.call_args.kwargs["array_size"] == 4
|
|
128
|
+
|
|
129
|
+
chunk_files = sorted(Path(base).rglob("input/chunk_*.txt"))
|
|
130
|
+
assert len(chunk_files) == 4
|
|
131
|
+
counts = [len([ln for ln in p.read_text().splitlines() if ln.strip()]) for p in chunk_files]
|
|
132
|
+
# Balanced: chunk sizes differ by at most 1.
|
|
133
|
+
assert max(counts) - min(counts) <= 1
|
|
134
|
+
assert sum(counts) == n
|
|
135
|
+
|
|
136
|
+
def test_blank_lines_skipped(self, runner, input_dir):
|
|
137
|
+
p = input_dir / "reactions.txt"
|
|
138
|
+
p.write_text("CCO.O>>CC(=O)O\n\n \n CCCO.O>>CCC(=O)O \n\n")
|
|
139
|
+
result, client, _ = _invoke_submit(runner, p)
|
|
140
|
+
assert result.exit_code == 0, result.output
|
|
141
|
+
assert client.submit_job.call_args.kwargs["array_size"] == min(
|
|
142
|
+
math.ceil(2 / eq_cmd.REACTIONS_PER_WORKER), eq_cmd.MAX_WORKERS
|
|
143
|
+
)
|
|
144
|
+
# Only 2 non-blank reactions counted.
|
|
145
|
+
assert "2" in result.output
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestDryRun:
|
|
149
|
+
def test_dry_run_no_submit(self, runner, input_dir):
|
|
150
|
+
rxns = _write_reactions(input_dir, 4)
|
|
151
|
+
base = input_dir / "_jobs"
|
|
152
|
+
client = _submit_mock()
|
|
153
|
+
with (
|
|
154
|
+
patch.object(eq_cmd, "BatchClient", return_value=client),
|
|
155
|
+
patch.object(eq_cmd, "_resolve_image_digest", return_value="sha256:deadbeef"),
|
|
156
|
+
patch.object(eq_cmd, "_resolve_assets_commit", return_value="abc1234"),
|
|
157
|
+
):
|
|
158
|
+
result = runner.invoke(eq_cmd.equilibrator, [str(rxns), "--base-path", str(base), "--dry-run"])
|
|
159
|
+
assert result.exit_code == 0, result.output
|
|
160
|
+
client.submit_job.assert_not_called()
|
|
161
|
+
# No job directory created.
|
|
162
|
+
assert not list(Path(base).rglob("chunk_*.txt")) if base.exists() else True
|
|
163
|
+
# Plan is printed.
|
|
164
|
+
out = result.output
|
|
165
|
+
assert "4" in out # reaction count
|
|
166
|
+
assert eq_cmd.DEFAULT_QUEUE in out
|
|
167
|
+
assert eq_cmd.DEFAULT_JOB_DEFINITION in out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestEmptyInput:
|
|
171
|
+
def test_empty_input_errors(self, runner, input_dir):
|
|
172
|
+
p = input_dir / "empty.txt"
|
|
173
|
+
p.write_text("\n \n\n")
|
|
174
|
+
result, client, _ = _invoke_submit(runner, p)
|
|
175
|
+
assert result.exit_code != 0
|
|
176
|
+
client.submit_job.assert_not_called()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TestManifest:
|
|
180
|
+
def test_manifest_written(self, runner, input_dir):
|
|
181
|
+
import json
|
|
182
|
+
|
|
183
|
+
rxns = _write_reactions(input_dir, 3)
|
|
184
|
+
base = input_dir / "_jobs"
|
|
185
|
+
result, client, base = _invoke_submit(runner, rxns, base=base)
|
|
186
|
+
assert result.exit_code == 0, result.output
|
|
187
|
+
|
|
188
|
+
manifests = list(Path(base).rglob("manifest.json"))
|
|
189
|
+
assert len(manifests) == 1
|
|
190
|
+
data = json.loads(manifests[0].read_text())
|
|
191
|
+
assert data["pipeline"] == "equilibrator"
|
|
192
|
+
assert data["batch"]["queue"] == "cpu-spot"
|
|
193
|
+
assert data["batch"]["job_definition"] == "dayhoff-equilibrator"
|
|
194
|
+
assert data["batch"]["array_size"] >= 1
|
|
195
|
+
assert data["input"]["num_chunks"] == data["batch"]["array_size"]
|
|
196
|
+
# D-14: reproducibility — image digest + equilibrator-assets commit.
|
|
197
|
+
blob = json.dumps(data)
|
|
198
|
+
assert "sha256:deadbeef" in blob
|
|
199
|
+
assert "abc1234" in blob
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestSubmissionEnv:
|
|
203
|
+
def test_env_vars_passed(self, runner, input_dir):
|
|
204
|
+
rxns = _write_reactions(input_dir, 3)
|
|
205
|
+
result, client, _ = _invoke_submit(runner, rxns)
|
|
206
|
+
assert result.exit_code == 0, result.output
|
|
207
|
+
env = client.submit_job.call_args.kwargs["environment"]
|
|
208
|
+
assert "JOB_DIR" in env
|
|
209
|
+
assert "JOB_ID" in env
|
|
210
|
+
# D-10: worker derives its file from the array index, not a file count.
|
|
211
|
+
assert "BATCH_NUM_FILES" not in env
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class TestSubmissionParams:
|
|
215
|
+
def test_retry_and_share_identifier(self, runner, input_dir):
|
|
216
|
+
rxns = _write_reactions(input_dir, 3)
|
|
217
|
+
with patch.object(eq_cmd, "get_aws_username", return_value="tester"):
|
|
218
|
+
result, client, _ = _invoke_submit(runner, rxns)
|
|
219
|
+
assert result.exit_code == 0, result.output
|
|
220
|
+
kwargs = client.submit_job.call_args.kwargs
|
|
221
|
+
assert kwargs["retry_attempts"] == 5
|
|
222
|
+
assert kwargs["timeout_seconds"] > 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class TestRegistration:
|
|
226
|
+
def test_equilibrator_registered_on_group(self):
|
|
227
|
+
from dh_cli.batch import batch_cli
|
|
228
|
+
|
|
229
|
+
assert "equilibrator" in batch_cli.commands
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Tests for eQuilibrator JSON-lines chunk combining in finalize.
|
|
2
|
+
|
|
3
|
+
Covers the pure combiner (``_finalize_equilibrator``) and the S3
|
|
4
|
+
download-then-delete path via the finalize command dispatch.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest.mock import MagicMock, patch
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from dh_cli.batch.commands.finalize import _finalize_equilibrator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def temp_dir():
|
|
19
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
20
|
+
yield Path(tmpdir)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _record(input_index: int, dg: float | None = -10.5, error: str | None = None) -> dict:
|
|
24
|
+
"""A worker output record with the full fixed schema."""
|
|
25
|
+
return {
|
|
26
|
+
"reaction": f"R{input_index}",
|
|
27
|
+
"equation": f"eq{input_index}",
|
|
28
|
+
"updated_equation": f"ueq{input_index}",
|
|
29
|
+
"dg_prime": dg,
|
|
30
|
+
"dg_prime_stddev": 1.0 if error is None else None,
|
|
31
|
+
"units": "kJ/mol",
|
|
32
|
+
"compound_map": {"a": 1, "b": 2},
|
|
33
|
+
"compound_refs": {"a": {"id": "MNXM1"}},
|
|
34
|
+
"error": error,
|
|
35
|
+
"input_index": input_index,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _write_chunk(output_dir: Path, index: int, records: list[dict], done: bool = True) -> None:
|
|
40
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path = output_dir / f"chunk_{index:03d}.jsonl"
|
|
42
|
+
path.write_text("".join(json.dumps(r) + "\n" for r in records))
|
|
43
|
+
if done:
|
|
44
|
+
(output_dir / f"chunk_{index:03d}.done").touch()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_jsonl(path: Path) -> list[dict]:
|
|
48
|
+
return [json.loads(ln) for ln in path.read_text().splitlines() if ln.strip()]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestCombine:
|
|
52
|
+
def test_combine_multiple_chunks(self, temp_dir):
|
|
53
|
+
output_dir = temp_dir / "output"
|
|
54
|
+
_write_chunk(output_dir, 0, [_record(0), _record(1)])
|
|
55
|
+
_write_chunk(output_dir, 1, [_record(2), _record(3)])
|
|
56
|
+
_write_chunk(output_dir, 2, [_record(4)])
|
|
57
|
+
|
|
58
|
+
out = temp_dir / "combined.jsonl"
|
|
59
|
+
_finalize_equilibrator(output_dir, out)
|
|
60
|
+
|
|
61
|
+
recs = _read_jsonl(out)
|
|
62
|
+
assert len(recs) == 5
|
|
63
|
+
assert [r["input_index"] for r in recs] == [0, 1, 2, 3, 4]
|
|
64
|
+
|
|
65
|
+
def test_records_passthrough(self, temp_dir):
|
|
66
|
+
output_dir = temp_dir / "output"
|
|
67
|
+
rec = _record(0)
|
|
68
|
+
_write_chunk(output_dir, 0, [rec])
|
|
69
|
+
|
|
70
|
+
out = temp_dir / "combined.jsonl"
|
|
71
|
+
_finalize_equilibrator(output_dir, out)
|
|
72
|
+
|
|
73
|
+
got = _read_jsonl(out)[0]
|
|
74
|
+
assert got == rec
|
|
75
|
+
assert got["compound_map"] == {"a": 1, "b": 2}
|
|
76
|
+
assert got["compound_refs"] == {"a": {"id": "MNXM1"}}
|
|
77
|
+
|
|
78
|
+
def test_error_records_included(self, temp_dir):
|
|
79
|
+
output_dir = temp_dir / "output"
|
|
80
|
+
_write_chunk(output_dir, 0, [_record(0), _record(1, dg=None, error="bad reaction")])
|
|
81
|
+
|
|
82
|
+
out = temp_dir / "combined.jsonl"
|
|
83
|
+
_finalize_equilibrator(output_dir, out)
|
|
84
|
+
|
|
85
|
+
recs = _read_jsonl(out)
|
|
86
|
+
assert len(recs) == 2
|
|
87
|
+
errored = [r for r in recs if r["error"] is not None]
|
|
88
|
+
assert len(errored) == 1
|
|
89
|
+
assert errored[0]["error"] == "bad reaction"
|
|
90
|
+
|
|
91
|
+
def test_done_markers_dropped(self, temp_dir):
|
|
92
|
+
output_dir = temp_dir / "output"
|
|
93
|
+
_write_chunk(output_dir, 0, [_record(0)])
|
|
94
|
+
_write_chunk(output_dir, 1, [_record(1)])
|
|
95
|
+
|
|
96
|
+
out = temp_dir / "combined.jsonl"
|
|
97
|
+
_finalize_equilibrator(output_dir, out)
|
|
98
|
+
|
|
99
|
+
# The finalized artifact is exactly the combined file — no .done, no chunks.
|
|
100
|
+
siblings = list(out.parent.iterdir())
|
|
101
|
+
assert siblings == [out]
|
|
102
|
+
assert not list(out.parent.glob("*.done"))
|
|
103
|
+
assert not list(out.parent.glob("chunk_*.jsonl"))
|
|
104
|
+
|
|
105
|
+
def test_missing_chunk_tolerated(self, temp_dir):
|
|
106
|
+
output_dir = temp_dir / "output"
|
|
107
|
+
_write_chunk(output_dir, 0, [_record(0)])
|
|
108
|
+
# index 1 produced no output (missing chunk).
|
|
109
|
+
_write_chunk(output_dir, 2, [_record(2)])
|
|
110
|
+
|
|
111
|
+
out = temp_dir / "combined.jsonl"
|
|
112
|
+
_finalize_equilibrator(output_dir, out) # must not raise
|
|
113
|
+
|
|
114
|
+
recs = _read_jsonl(out)
|
|
115
|
+
assert [r["input_index"] for r in recs] == [0, 2]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestOutputAutoname:
|
|
119
|
+
def test_output_dir_autoname(self, temp_dir):
|
|
120
|
+
output_dir = temp_dir / "output"
|
|
121
|
+
_write_chunk(output_dir, 0, [_record(0)])
|
|
122
|
+
|
|
123
|
+
dest_dir = temp_dir / "results"
|
|
124
|
+
dest_dir.mkdir()
|
|
125
|
+
job_id = "tester-equilibrator-20260707-abcd1234"
|
|
126
|
+
|
|
127
|
+
_finalize_equilibrator(output_dir, dest_dir, job_id=job_id)
|
|
128
|
+
|
|
129
|
+
expected = dest_dir / f"{job_id}.jsonl"
|
|
130
|
+
assert expected.exists()
|
|
131
|
+
assert len(_read_jsonl(expected)) == 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestS3Finalize:
|
|
135
|
+
def test_s3_download_before_combine(self, temp_dir):
|
|
136
|
+
"""For an S3-mode job, finalize downloads the output prefix, combines,
|
|
137
|
+
then deletes the S3 prefix (existing convention)."""
|
|
138
|
+
from dh_cli.batch.commands import finalize as fin
|
|
139
|
+
|
|
140
|
+
base = temp_dir / "base"
|
|
141
|
+
base.mkdir()
|
|
142
|
+
# Job dir with an input/ so completion checks are inert.
|
|
143
|
+
job_id = "tester-equilibrator-20260707-abcd1234"
|
|
144
|
+
(base / job_id / "input").mkdir(parents=True)
|
|
145
|
+
|
|
146
|
+
manifest = MagicMock()
|
|
147
|
+
manifest.status = "submitted"
|
|
148
|
+
manifest.pipeline = "equilibrator"
|
|
149
|
+
manifest.storage_mode = "s3"
|
|
150
|
+
manifest.s3_prefix = "s3://dayhoff-atlas/ephemeral/batch-jobs/job/"
|
|
151
|
+
manifest.output = MagicMock()
|
|
152
|
+
|
|
153
|
+
downloaded = {}
|
|
154
|
+
|
|
155
|
+
def fake_download(prefix, local_dir):
|
|
156
|
+
# Simulate S3 output containing two chunks.
|
|
157
|
+
_write_chunk(Path(local_dir), 0, [_record(0)])
|
|
158
|
+
_write_chunk(Path(local_dir), 1, [_record(1)])
|
|
159
|
+
downloaded["prefix"] = prefix
|
|
160
|
+
|
|
161
|
+
deleted = {}
|
|
162
|
+
|
|
163
|
+
def fake_delete(prefix):
|
|
164
|
+
deleted["prefix"] = prefix
|
|
165
|
+
|
|
166
|
+
dest = temp_dir / "out"
|
|
167
|
+
dest.mkdir()
|
|
168
|
+
|
|
169
|
+
with (
|
|
170
|
+
patch.object(fin, "load_manifest", return_value=manifest),
|
|
171
|
+
patch.object(fin, "save_manifest_s3"),
|
|
172
|
+
patch("dh_cli.batch.s3_transport.download_directory", side_effect=fake_download),
|
|
173
|
+
patch("dh_cli.batch.s3_transport.delete_prefix", side_effect=fake_delete),
|
|
174
|
+
):
|
|
175
|
+
from click.testing import CliRunner
|
|
176
|
+
|
|
177
|
+
runner = CliRunner()
|
|
178
|
+
result = runner.invoke(
|
|
179
|
+
fin.finalize,
|
|
180
|
+
[job_id, "--output", str(dest), "--base-path", str(base)],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
assert result.exit_code == 0, result.output
|
|
184
|
+
# Downloaded from the manifest's output prefix.
|
|
185
|
+
assert downloaded["prefix"].startswith("s3://dayhoff-atlas/ephemeral/batch-jobs/job/")
|
|
186
|
+
# Deleted the S3 prefix after combining.
|
|
187
|
+
assert deleted["prefix"] == manifest.s3_prefix
|
|
188
|
+
# Combined output written.
|
|
189
|
+
out_file = dest / f"{job_id}.jsonl"
|
|
190
|
+
recs = _read_jsonl(out_file)
|
|
191
|
+
assert [r["input_index"] for r in recs] == [0, 1]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|