bindsight 0.3.5__py3-none-any.whl
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.
- bindsight/README.md +44 -0
- bindsight/__init__.py +15 -0
- bindsight/benchmark/__init__.py +71 -0
- bindsight/benchmark/core.py +497 -0
- bindsight/benchmark/defaults.py +28 -0
- bindsight/benchmark/designer_bench.py +899 -0
- bindsight/benchmark/outcomes.py +508 -0
- bindsight/benchmark/panel.py +621 -0
- bindsight/benchmark/rediscovery.py +1153 -0
- bindsight/benchmark/statistics.py +622 -0
- bindsight/benchmark/study.py +1153 -0
- bindsight/benchmark/study_figures.py +164 -0
- bindsight/benchmark/study_report.py +607 -0
- bindsight/cli.py +2429 -0
- bindsight/config.py +451 -0
- bindsight/cost.py +258 -0
- bindsight/deg/__init__.py +7 -0
- bindsight/deg/pydeseq2_runner.py +285 -0
- bindsight/design/__init__.py +23 -0
- bindsight/design/_common.py +501 -0
- bindsight/design/bindcraft.py +78 -0
- bindsight/design/boltzgen.py +79 -0
- bindsight/design/developability.py +140 -0
- bindsight/design/embeddings.py +165 -0
- bindsight/design/prescreen.py +169 -0
- bindsight/design/protocol.py +111 -0
- bindsight/design/rfdiff_mpnn.py +93 -0
- bindsight/epitopes/__init__.py +7 -0
- bindsight/epitopes/surface_bind.py +134 -0
- bindsight/export/__init__.py +7 -0
- bindsight/export/ro_crate.py +595 -0
- bindsight/io/__init__.py +19 -0
- bindsight/io/cbioportal.py +118 -0
- bindsight/io/gdc.py +343 -0
- bindsight/io/paths.py +124 -0
- bindsight/pipelines/__init__.py +9 -0
- bindsight/pipelines/caveats.py +60 -0
- bindsight/pipelines/discover.py +1443 -0
- bindsight/pipelines/full_run.py +410 -0
- bindsight/plugins.py +335 -0
- bindsight/provenance/__init__.py +34 -0
- bindsight/provenance/append.py +199 -0
- bindsight/provenance/fragments.py +216 -0
- bindsight/provenance/manifest.py +506 -0
- bindsight/rank/__init__.py +7 -0
- bindsight/rank/scoring.py +355 -0
- bindsight/report/__init__.py +39 -0
- bindsight/report/format.py +100 -0
- bindsight/report/html.py +645 -0
- bindsight/report/showcase.py +653 -0
- bindsight/report/templates/report.css +67 -0
- bindsight/report/templates/report.html.j2 +420 -0
- bindsight/report/theme.py +231 -0
- bindsight/report/web/__init__.py +35 -0
- bindsight/report/web/app.py +624 -0
- bindsight/report/web/static/binder_viewer.js +245 -0
- bindsight/report/web/static/bindsight.css +455 -0
- bindsight/report/web/static/charts.js +425 -0
- bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
- bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
- bindsight/report/web/static/your_data_check.js +370 -0
- bindsight/report/web/templates/base.html.j2 +48 -0
- bindsight/report/web/templates/evidence.html.j2 +341 -0
- bindsight/report/web/templates/overview.html.j2 +140 -0
- bindsight/report/web/templates/runs.html.j2 +125 -0
- bindsight/report/web/templates/try.html.j2 +155 -0
- bindsight/report/web/templates/your_data.html.j2 +46 -0
- bindsight/runners/__init__.py +7 -0
- bindsight/runners/colab.py +158 -0
- bindsight/runners/job_exec.py +1110 -0
- bindsight/runners/kaggle.py +425 -0
- bindsight/runners/kaggle_kernel.py +489 -0
- bindsight/runners/local_docker.py +154 -0
- bindsight/runners/mock.py +167 -0
- bindsight/runners/modal_runner.py +199 -0
- bindsight/runners/notebook.py +116 -0
- bindsight/runners/notebook_content.py +217 -0
- bindsight/runners/protocol.py +81 -0
- bindsight/runners/source_wheel.py +104 -0
- bindsight/runners/tools.py +997 -0
- bindsight/structures/__init__.py +7 -0
- bindsight/structures/alphafolddb.py +93 -0
- bindsight/structures/plddt.py +92 -0
- bindsight/structures/topology.py +227 -0
- bindsight/surfaceome/__init__.py +42 -0
- bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
- bindsight/surfaceome/data/surfy_offline.txt +17 -0
- bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
- bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
- bindsight/surfaceome/surfy.py +441 -0
- bindsight/targets/__init__.py +7 -0
- bindsight/targets/ensembl_uniprot.py +56 -0
- bindsight/targets/gtex.py +250 -0
- bindsight/targets/open_targets.py +239 -0
- bindsight/validate/__init__.py +7 -0
- bindsight/validate/af2_ig.py +49 -0
- bindsight/validate/boltz2.py +256 -0
- bindsight/validate/chai1r.py +42 -0
- bindsight/validate/protocol.py +188 -0
- bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
- bindsight-0.3.5.dist-info/METADATA +495 -0
- bindsight-0.3.5.dist-info/RECORD +106 -0
- bindsight-0.3.5.dist-info/WHEEL +4 -0
- bindsight-0.3.5.dist-info/entry_points.txt +20 -0
- bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
- bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Mock GPU runner for CI.
|
|
4
|
+
|
|
5
|
+
Returns canned-but-realistically-shaped results immediately so CI (and the
|
|
6
|
+
Snakemake DAG / ``bindsight run --backend mock``) can exercise the full
|
|
7
|
+
orchestration — design → validate → rank → report → export — end-to-end without
|
|
8
|
+
a real GPU. The numeric values are clearly labelled as synthetic.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import shutil
|
|
16
|
+
import tarfile
|
|
17
|
+
import tempfile
|
|
18
|
+
import uuid
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from bindsight.runners.protocol import CostEstimate, JobHandle, JobStatus
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ATEXIT_CLEANUP(path: Path) -> None:
|
|
26
|
+
"""Remove a scratch directory when the process ends.
|
|
27
|
+
|
|
28
|
+
These were created with ``mkdtemp`` and never removed. On a long-lived
|
|
29
|
+
process -- the hosted app is the obvious one -- that is one directory per
|
|
30
|
+
invocation, retained for the container's lifetime.
|
|
31
|
+
"""
|
|
32
|
+
import atexit
|
|
33
|
+
import shutil
|
|
34
|
+
|
|
35
|
+
atexit.register(shutil.rmtree, path, True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# A minimal valid PDB (3 CA atoms) so downstream parsers have something real.
|
|
39
|
+
LOG = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_MOCK_PDB = (
|
|
42
|
+
"ATOM 1 CA GLY A 1 0.000 0.000 0.000 1.00 0.00 C\n"
|
|
43
|
+
"ATOM 2 CA SER A 2 3.800 0.000 0.000 1.00 0.00 C\n"
|
|
44
|
+
"ATOM 3 CA HIS A 3 7.600 0.000 0.000 1.00 0.00 C\n"
|
|
45
|
+
"END\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MockRunner:
|
|
50
|
+
"""Returns a canned tarball of fake results."""
|
|
51
|
+
|
|
52
|
+
name = "mock"
|
|
53
|
+
|
|
54
|
+
def __init__(self, canned_archive: Path | None = None) -> None:
|
|
55
|
+
self.canned = canned_archive
|
|
56
|
+
# handle id -> target accession, so fetch() can namespace binder ids.
|
|
57
|
+
self._targets: dict[str, str] = {}
|
|
58
|
+
|
|
59
|
+
def estimate_cost(self, spec_size: int) -> CostEstimate:
|
|
60
|
+
"""Return a zero-cost estimate; the mock runner never spends money."""
|
|
61
|
+
return CostEstimate(
|
|
62
|
+
backend=self.name,
|
|
63
|
+
gpu_type="mock",
|
|
64
|
+
gpu_hours=0.0,
|
|
65
|
+
usd_estimate=0.0,
|
|
66
|
+
queue_minutes_estimate=0.0,
|
|
67
|
+
notes="Mock runner — no real compute.",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def submit(self, spec_path: Path, *, results_dir: Path) -> JobHandle:
|
|
71
|
+
"""Pretend to submit a job; returns a unique handle immediately.
|
|
72
|
+
|
|
73
|
+
The spec's target is recorded against the handle so :meth:`fetch` can
|
|
74
|
+
namespace the binder ids it invents. A mock that returns the same ids for
|
|
75
|
+
every target cannot exercise the invariant that binder ids are unique
|
|
76
|
+
across a run — and that is exactly the bug this mock is meant to catch,
|
|
77
|
+
since ``binder_id`` is the key the provenance chain is walked by.
|
|
78
|
+
"""
|
|
79
|
+
results_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
handle_id = str(uuid.uuid4())
|
|
81
|
+
target = "MOCK"
|
|
82
|
+
try:
|
|
83
|
+
spec = json.loads(Path(spec_path).read_text(encoding="utf-8"))
|
|
84
|
+
target = str(spec.get("target_uniprot") or "MOCK")
|
|
85
|
+
except (OSError, ValueError) as e: # a malformed spec must not break CI
|
|
86
|
+
LOG.warning("mock runner could not read %s: %s", spec_path, e)
|
|
87
|
+
self._targets[handle_id] = target
|
|
88
|
+
return JobHandle(
|
|
89
|
+
backend=self.name,
|
|
90
|
+
id=handle_id,
|
|
91
|
+
submitted_at=datetime.now(UTC).isoformat(timespec="seconds"),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def poll(self, handle: JobHandle) -> JobStatus:
|
|
95
|
+
"""Always report 'succeeded' immediately."""
|
|
96
|
+
return JobStatus(handle=handle, state="succeeded", progress=1.0, log_tail=None)
|
|
97
|
+
|
|
98
|
+
def fetch(self, handle: JobHandle) -> Path:
|
|
99
|
+
"""Return the canned archive, or build a realistically-shaped mock one.
|
|
100
|
+
|
|
101
|
+
The mock tarball mirrors what :mod:`bindsight.runners.job_exec` produces
|
|
102
|
+
(``metrics.jsonl`` + ``validate/<binder>/`` JSONs + ``design/<binder>``),
|
|
103
|
+
with clearly-synthetic numbers, so the whole orchestration runs E2E.
|
|
104
|
+
|
|
105
|
+
Binder ids carry the target accession, exactly as the real executor's do.
|
|
106
|
+
Without that, a multi-target run produces the same ids for every target,
|
|
107
|
+
the per-binder validation directories overwrite each other on disk, and
|
|
108
|
+
``validated.parquet`` acquires duplicate keys — a mock that permits the
|
|
109
|
+
bug it is supposed to guard against is worse than no mock.
|
|
110
|
+
"""
|
|
111
|
+
if self.canned is not None:
|
|
112
|
+
return self.canned
|
|
113
|
+
target = self._targets.get(handle.id, "MOCK")
|
|
114
|
+
root = Path(tempfile.mkdtemp(prefix="bindsight_mock_"))
|
|
115
|
+
_ATEXIT_CLEANUP(root)
|
|
116
|
+
work = root / "work"
|
|
117
|
+
design = work / "design"
|
|
118
|
+
validate = work / "validate"
|
|
119
|
+
design.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
|
|
121
|
+
metrics = []
|
|
122
|
+
for i in range(2):
|
|
123
|
+
bid = f"{target}_mock_binder_{i}"
|
|
124
|
+
(design / f"{bid}.pdb").write_text(_MOCK_PDB, encoding="utf-8", newline="\n")
|
|
125
|
+
(design / f"{bid}.fasta").write_text(
|
|
126
|
+
f">{bid}\nGSHMSLEQKKGADIISKIL\n", encoding="utf-8", newline="\n"
|
|
127
|
+
)
|
|
128
|
+
vdir = validate / bid
|
|
129
|
+
vdir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
(vdir / f"confidence_{bid}_model_0.json").write_text(
|
|
131
|
+
json.dumps({"iptm": 0.70 + 0.05 * i, "pae_interaction": 6.0 - i}),
|
|
132
|
+
encoding="utf-8",
|
|
133
|
+
newline="\n",
|
|
134
|
+
)
|
|
135
|
+
(vdir / f"affinity_{bid}.json").write_text(
|
|
136
|
+
json.dumps({"affinity_pred_value": -7.0 - i, "affinity_probability_binary": 0.80}),
|
|
137
|
+
encoding="utf-8",
|
|
138
|
+
newline="\n",
|
|
139
|
+
)
|
|
140
|
+
metrics.append(
|
|
141
|
+
{
|
|
142
|
+
"binder_id": bid,
|
|
143
|
+
"target_uniprot": target,
|
|
144
|
+
"iptm": 0.70 + 0.05 * i,
|
|
145
|
+
"pae_interaction": 6.0 - i,
|
|
146
|
+
"affinity_pred_value": -7.0 - i,
|
|
147
|
+
"affinity_probability_binary": 0.80,
|
|
148
|
+
"validator_name": "mock",
|
|
149
|
+
"validator_version": "0",
|
|
150
|
+
"notes": "mock runner — synthetic metrics for CI/orchestration only",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
(work / "metrics.jsonl").write_text(
|
|
154
|
+
"\n".join(json.dumps(m) for m in metrics) + "\n", encoding="utf-8", newline="\n"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
tarball = root / "results.tar.gz"
|
|
158
|
+
with tarfile.open(tarball, "w:gz", encoding="utf-8") as tf:
|
|
159
|
+
for sub in ("design", "validate", "metrics.jsonl"):
|
|
160
|
+
tf.add(work / sub, arcname=sub)
|
|
161
|
+
return tarball
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def cleanup(path: Path) -> None:
|
|
165
|
+
"""Remove a temp directory created by :meth:`fetch`."""
|
|
166
|
+
if path.parent.name.startswith("bindsight_mock_"):
|
|
167
|
+
shutil.rmtree(path.parent, ignore_errors=True)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Modal serverless-GPU runner.
|
|
4
|
+
|
|
5
|
+
`Modal <https://modal.com/>`_ provisions a GPU, runs
|
|
6
|
+
:mod:`bindsight.runners.job_exec` (the same executor as the local/Kaggle
|
|
7
|
+
paths), and tears down — per-second billing, A100 ≈ $3/hr. Requires the
|
|
8
|
+
``runners`` extra (``modal``) and a Modal token (``modal token new``).
|
|
9
|
+
|
|
10
|
+
``import modal`` happens lazily inside the methods via :func:`_require_modal`,
|
|
11
|
+
so ``import bindsight`` works without the extra installed. Execution is
|
|
12
|
+
synchronous from the caller's point of view (``submit`` runs the job and caches
|
|
13
|
+
the tarball; ``fetch`` returns it), matching the Designer ``submit→fetch``
|
|
14
|
+
contract.
|
|
15
|
+
|
|
16
|
+
**Verification status.** Modal is the paid escape hatch for the components free
|
|
17
|
+
hardware cannot reach: Chai-1r needs bfloat16 (compute capability 8.0+, which no
|
|
18
|
+
free-tier GPU has), and full-receptor BindCraft and BoltzGen need more VRAM than
|
|
19
|
+
a 16 GB free card. The image below is built to contain the whole design stack
|
|
20
|
+
rather than only the validator, but it has not been executed end-to-end — running
|
|
21
|
+
it costs money. Treat it as prepared, not proven, until a run is recorded.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import uuid
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from bindsight.cost import estimate
|
|
33
|
+
from bindsight.runners import tools
|
|
34
|
+
from bindsight.runners.protocol import CostEstimate, JobHandle, JobStatus
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ATEXIT_CLEANUP(path: Path) -> None:
|
|
38
|
+
"""Remove a scratch directory when the process ends.
|
|
39
|
+
|
|
40
|
+
These were created with ``mkdtemp`` and never removed. On a long-lived
|
|
41
|
+
process -- the hosted app is the obvious one -- that is one directory per
|
|
42
|
+
invocation, retained for the container's lifetime.
|
|
43
|
+
"""
|
|
44
|
+
import atexit
|
|
45
|
+
import shutil
|
|
46
|
+
|
|
47
|
+
atexit.register(shutil.rmtree, path, True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
LOG = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
#: Repository bindsight itself is installed from inside the remote image.
|
|
53
|
+
#: bindsight is not published to PyPI, so a bare ``pip install bindsight``
|
|
54
|
+
#: silently resolves to nothing.
|
|
55
|
+
_BINDSIGHT_REPO = "https://github.com/mikhaeelatefrizk/bindsight.git"
|
|
56
|
+
|
|
57
|
+
#: CUDA base image for the remote container. Devel rather than runtime because
|
|
58
|
+
#: RFdiffusion's SE3-Transformer builds CUDA extensions at install time.
|
|
59
|
+
_MODAL_CUDA_IMAGE = "nvidia/cuda:12.4.1-devel-ubuntu22.04"
|
|
60
|
+
|
|
61
|
+
# Map bindsight GPU names to Modal gpu= strings.
|
|
62
|
+
_MODAL_GPU = {
|
|
63
|
+
"A100-40GB": "A100",
|
|
64
|
+
"A100-80GB": "A100-80GB",
|
|
65
|
+
"T4": "T4",
|
|
66
|
+
"L4": "L4",
|
|
67
|
+
"A10G": "A10G",
|
|
68
|
+
"H100": "H100",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# handle id -> local tarball path (submit runs synchronously and caches here).
|
|
72
|
+
_RESULTS: dict[str, Path] = {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _require_modal() -> Any:
|
|
76
|
+
"""Import Modal with a clear, actionable error if the extra isn't installed."""
|
|
77
|
+
try:
|
|
78
|
+
import modal
|
|
79
|
+
except ImportError as e:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
'Modal runner needs the "runners" extra: pip install -e ".[runners]" '
|
|
82
|
+
"and a Modal token (run: modal token new)."
|
|
83
|
+
) from e
|
|
84
|
+
return modal
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ModalRunner:
|
|
88
|
+
"""Serverless-GPU runner that executes the design+validation job on Modal."""
|
|
89
|
+
|
|
90
|
+
name = "modal"
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
designer: str = "rfdiff_mpnn",
|
|
96
|
+
n_units_per_target: int = 50,
|
|
97
|
+
gpu_type: str = "A100-40GB",
|
|
98
|
+
app_name: str = "bindsight",
|
|
99
|
+
timeout_s: int = 3600,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.designer = designer
|
|
102
|
+
self.n_units_per_target = n_units_per_target
|
|
103
|
+
self.gpu_type = gpu_type
|
|
104
|
+
self.app_name = app_name
|
|
105
|
+
self.timeout_s = timeout_s
|
|
106
|
+
|
|
107
|
+
def estimate_cost(self, spec_size: int) -> CostEstimate:
|
|
108
|
+
"""Estimate cost for ``spec_size`` design trajectories on Modal."""
|
|
109
|
+
return estimate(
|
|
110
|
+
backend=self.name,
|
|
111
|
+
stage="design",
|
|
112
|
+
plugin=self.designer,
|
|
113
|
+
n_units=spec_size,
|
|
114
|
+
gpu_type=self.gpu_type,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _build_app(self) -> tuple[Any, Any]:
|
|
118
|
+
"""Define the Modal app + GPU function lazily."""
|
|
119
|
+
modal = _require_modal()
|
|
120
|
+
image = (
|
|
121
|
+
# A CUDA *devel* base, not debian_slim: RFdiffusion's SE3-Transformer
|
|
122
|
+
# compiles CUDA extensions during install, so a runtime-only image
|
|
123
|
+
# cannot build it. debian_slim additionally ships no CUDA at all, so
|
|
124
|
+
# the previous image could run the Boltz-2 validator and nothing else.
|
|
125
|
+
modal.Image.from_registry(_MODAL_CUDA_IMAGE, add_python="3.11")
|
|
126
|
+
.apt_install("git", "wget", "build-essential")
|
|
127
|
+
# bindsight is not on PyPI. `pip_install("bindsight")` resolved to
|
|
128
|
+
# nothing, so this image never contained the executor it exists to run.
|
|
129
|
+
.pip_install(
|
|
130
|
+
f"bindsight @ git+{_BINDSIGHT_REPO}",
|
|
131
|
+
tools.BOLTZ_PIP,
|
|
132
|
+
"pandas",
|
|
133
|
+
"pyarrow",
|
|
134
|
+
"biopython",
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
app = modal.App(self.app_name)
|
|
138
|
+
gpu = _MODAL_GPU.get(self.gpu_type, "A100")
|
|
139
|
+
|
|
140
|
+
@app.function(gpu=gpu, image=image, timeout=self.timeout_s) # type: ignore[untyped-decorator]
|
|
141
|
+
def _run_remote(spec_json: str, files: dict[str, bytes]) -> bytes:
|
|
142
|
+
import json as _json
|
|
143
|
+
import tempfile
|
|
144
|
+
from pathlib import Path as _P
|
|
145
|
+
|
|
146
|
+
from bindsight.runners import job_exec
|
|
147
|
+
|
|
148
|
+
work = _P(tempfile.mkdtemp(prefix="bindsight_modal_"))
|
|
149
|
+
_ATEXIT_CLEANUP(work)
|
|
150
|
+
spec_dir = work / "spec"
|
|
151
|
+
spec_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
(spec_dir / "spec.json").write_text(spec_json, encoding="utf-8", newline="\n")
|
|
153
|
+
for name, data in files.items():
|
|
154
|
+
(spec_dir / name).write_bytes(data)
|
|
155
|
+
spec = _json.loads(spec_json)
|
|
156
|
+
job_exec.materialise_target(spec, spec_dir, work / "run")
|
|
157
|
+
tarball = job_exec.run_job(spec, work / "run", tarball=work / "results.tar.gz")
|
|
158
|
+
return tarball.read_bytes()
|
|
159
|
+
|
|
160
|
+
return app, _run_remote
|
|
161
|
+
|
|
162
|
+
def submit(self, spec_path: Path, *, results_dir: Path) -> JobHandle:
|
|
163
|
+
"""Run the job on Modal (synchronously) and cache the tarball locally."""
|
|
164
|
+
results_dir.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
handle_id = str(uuid.uuid4())
|
|
166
|
+
tarball = results_dir / f"{handle_id}.tar.gz"
|
|
167
|
+
|
|
168
|
+
spec_json = spec_path.read_text(encoding="utf-8")
|
|
169
|
+
files = {
|
|
170
|
+
f.name: f.read_bytes()
|
|
171
|
+
for f in spec_path.parent.iterdir()
|
|
172
|
+
if f.is_file() and f.name != spec_path.name
|
|
173
|
+
}
|
|
174
|
+
app, run_remote = self._build_app()
|
|
175
|
+
LOG.info("modal submit: running job on %s (gpu=%s)", self.app_name, self.gpu_type)
|
|
176
|
+
with app.run():
|
|
177
|
+
data: bytes = run_remote.remote(spec_json, files)
|
|
178
|
+
tarball.write_bytes(data)
|
|
179
|
+
_RESULTS[handle_id] = tarball
|
|
180
|
+
return JobHandle(
|
|
181
|
+
backend=self.name,
|
|
182
|
+
id=handle_id,
|
|
183
|
+
submitted_at=datetime.now(UTC).isoformat(timespec="seconds"),
|
|
184
|
+
results_dir=str(results_dir),
|
|
185
|
+
tarball=str(tarball),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def poll(self, handle: JobHandle) -> JobStatus:
|
|
189
|
+
"""Report status (submit is synchronous, so a known handle is done)."""
|
|
190
|
+
tarball = _RESULTS.get(handle.id) or Path(getattr(handle, "tarball", ""))
|
|
191
|
+
state = "succeeded" if Path(tarball).exists() else "failed"
|
|
192
|
+
return JobStatus(handle=handle, state=state, progress=1.0 if state == "succeeded" else None)
|
|
193
|
+
|
|
194
|
+
def fetch(self, handle: JobHandle) -> Path:
|
|
195
|
+
"""Return the locally-cached results tarball written by :meth:`submit`."""
|
|
196
|
+
tarball = _RESULTS.get(handle.id) or Path(getattr(handle, "tarball", ""))
|
|
197
|
+
if not Path(tarball).exists():
|
|
198
|
+
raise RuntimeError(f"modal job {handle.id} produced no results at {tarball}")
|
|
199
|
+
return Path(tarball)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Helpers for building Colab/Kaggle notebooks programmatically.
|
|
4
|
+
|
|
5
|
+
The notebook format is just JSON; we construct it as Python dicts and serialize
|
|
6
|
+
with the standard library, instead of carrying a heavy ``nbformat`` dependency.
|
|
7
|
+
This keeps the install lean and CI fast.
|
|
8
|
+
|
|
9
|
+
A notebook has a small fixed envelope and a list of cells. Each cell is either:
|
|
10
|
+
|
|
11
|
+
- ``code`` — a list of source lines + execution metadata
|
|
12
|
+
- ``markdown`` — a list of source lines
|
|
13
|
+
|
|
14
|
+
We emit Colab-flavored notebooks (the ``colab`` metadata block tells Colab
|
|
15
|
+
which runtime to provision; e.g. ``GPU`` with ``T4`` or ``A100``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Literal
|
|
23
|
+
|
|
24
|
+
from jinja2 import BaseLoader, Environment, StrictUndefined
|
|
25
|
+
|
|
26
|
+
NotebookGPU = Literal["T4", "L4", "A100", "V100"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _code_cell(src: str | list[str]) -> dict[str, Any]:
|
|
30
|
+
if isinstance(src, str):
|
|
31
|
+
src = src.splitlines(keepends=True)
|
|
32
|
+
return {
|
|
33
|
+
"cell_type": "code",
|
|
34
|
+
"execution_count": None,
|
|
35
|
+
"metadata": {},
|
|
36
|
+
"outputs": [],
|
|
37
|
+
"source": src,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _markdown_cell(src: str | list[str]) -> dict[str, Any]:
|
|
42
|
+
if isinstance(src, str):
|
|
43
|
+
src = src.splitlines(keepends=True)
|
|
44
|
+
return {
|
|
45
|
+
"cell_type": "markdown",
|
|
46
|
+
"metadata": {},
|
|
47
|
+
"source": src,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_notebook(
|
|
52
|
+
*,
|
|
53
|
+
cells: list[dict[str, Any]],
|
|
54
|
+
gpu: NotebookGPU = "T4",
|
|
55
|
+
title: str = "bindsight job",
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""Return a Jupyter v4 notebook dict with the given cells and Colab GPU runtime.
|
|
58
|
+
|
|
59
|
+
Pass the result to :func:`json.dumps` (or use :func:`write_notebook`) to
|
|
60
|
+
save as ``.ipynb``.
|
|
61
|
+
"""
|
|
62
|
+
return {
|
|
63
|
+
"nbformat": 4,
|
|
64
|
+
"nbformat_minor": 0,
|
|
65
|
+
"metadata": {
|
|
66
|
+
"colab": {
|
|
67
|
+
"name": title,
|
|
68
|
+
"provenance": [],
|
|
69
|
+
"gpuType": gpu,
|
|
70
|
+
"machine_shape": "hm",
|
|
71
|
+
},
|
|
72
|
+
"kernelspec": {
|
|
73
|
+
"display_name": "Python 3",
|
|
74
|
+
"name": "python3",
|
|
75
|
+
"language": "python",
|
|
76
|
+
},
|
|
77
|
+
"accelerator": "GPU",
|
|
78
|
+
"language_info": {"name": "python"},
|
|
79
|
+
},
|
|
80
|
+
"cells": cells,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def write_notebook(notebook: dict[str, Any], path: Path) -> Path:
|
|
85
|
+
"""Write a notebook dict to ``path`` as JSON. Creates parent dirs."""
|
|
86
|
+
path = Path(path)
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
path.write_text(json.dumps(notebook, indent=1), encoding="utf-8", newline="\n")
|
|
89
|
+
return path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def render_template(template_str: str, context: dict[str, Any]) -> str:
|
|
93
|
+
"""Render a Jinja2 template string against ``context`` with strict undefined.
|
|
94
|
+
|
|
95
|
+
Strict-undefined means a missing variable raises immediately rather than
|
|
96
|
+
silently producing empty output — important when the template generates
|
|
97
|
+
code that will run on a remote GPU we can't easily debug.
|
|
98
|
+
"""
|
|
99
|
+
env = Environment(
|
|
100
|
+
loader=BaseLoader(),
|
|
101
|
+
undefined=StrictUndefined,
|
|
102
|
+
autoescape=False,
|
|
103
|
+
trim_blocks=True,
|
|
104
|
+
lstrip_blocks=True,
|
|
105
|
+
)
|
|
106
|
+
return env.from_string(template_str).render(**context)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def code_cell_from_template(template_str: str, context: dict[str, Any]) -> dict[str, Any]:
|
|
110
|
+
"""Convenience: render a Jinja template and wrap as a code cell."""
|
|
111
|
+
return _code_cell(render_template(template_str, context))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def markdown_cell(src: str | list[str]) -> dict[str, Any]:
|
|
115
|
+
"""Public alias for the markdown cell constructor."""
|
|
116
|
+
return _markdown_cell(src)
|