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.
Files changed (106) hide show
  1. bindsight/README.md +44 -0
  2. bindsight/__init__.py +15 -0
  3. bindsight/benchmark/__init__.py +71 -0
  4. bindsight/benchmark/core.py +497 -0
  5. bindsight/benchmark/defaults.py +28 -0
  6. bindsight/benchmark/designer_bench.py +899 -0
  7. bindsight/benchmark/outcomes.py +508 -0
  8. bindsight/benchmark/panel.py +621 -0
  9. bindsight/benchmark/rediscovery.py +1153 -0
  10. bindsight/benchmark/statistics.py +622 -0
  11. bindsight/benchmark/study.py +1153 -0
  12. bindsight/benchmark/study_figures.py +164 -0
  13. bindsight/benchmark/study_report.py +607 -0
  14. bindsight/cli.py +2429 -0
  15. bindsight/config.py +451 -0
  16. bindsight/cost.py +258 -0
  17. bindsight/deg/__init__.py +7 -0
  18. bindsight/deg/pydeseq2_runner.py +285 -0
  19. bindsight/design/__init__.py +23 -0
  20. bindsight/design/_common.py +501 -0
  21. bindsight/design/bindcraft.py +78 -0
  22. bindsight/design/boltzgen.py +79 -0
  23. bindsight/design/developability.py +140 -0
  24. bindsight/design/embeddings.py +165 -0
  25. bindsight/design/prescreen.py +169 -0
  26. bindsight/design/protocol.py +111 -0
  27. bindsight/design/rfdiff_mpnn.py +93 -0
  28. bindsight/epitopes/__init__.py +7 -0
  29. bindsight/epitopes/surface_bind.py +134 -0
  30. bindsight/export/__init__.py +7 -0
  31. bindsight/export/ro_crate.py +595 -0
  32. bindsight/io/__init__.py +19 -0
  33. bindsight/io/cbioportal.py +118 -0
  34. bindsight/io/gdc.py +343 -0
  35. bindsight/io/paths.py +124 -0
  36. bindsight/pipelines/__init__.py +9 -0
  37. bindsight/pipelines/caveats.py +60 -0
  38. bindsight/pipelines/discover.py +1443 -0
  39. bindsight/pipelines/full_run.py +410 -0
  40. bindsight/plugins.py +335 -0
  41. bindsight/provenance/__init__.py +34 -0
  42. bindsight/provenance/append.py +199 -0
  43. bindsight/provenance/fragments.py +216 -0
  44. bindsight/provenance/manifest.py +506 -0
  45. bindsight/rank/__init__.py +7 -0
  46. bindsight/rank/scoring.py +355 -0
  47. bindsight/report/__init__.py +39 -0
  48. bindsight/report/format.py +100 -0
  49. bindsight/report/html.py +645 -0
  50. bindsight/report/showcase.py +653 -0
  51. bindsight/report/templates/report.css +67 -0
  52. bindsight/report/templates/report.html.j2 +420 -0
  53. bindsight/report/theme.py +231 -0
  54. bindsight/report/web/__init__.py +35 -0
  55. bindsight/report/web/app.py +624 -0
  56. bindsight/report/web/static/binder_viewer.js +245 -0
  57. bindsight/report/web/static/bindsight.css +455 -0
  58. bindsight/report/web/static/charts.js +425 -0
  59. bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
  60. bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
  61. bindsight/report/web/static/your_data_check.js +370 -0
  62. bindsight/report/web/templates/base.html.j2 +48 -0
  63. bindsight/report/web/templates/evidence.html.j2 +341 -0
  64. bindsight/report/web/templates/overview.html.j2 +140 -0
  65. bindsight/report/web/templates/runs.html.j2 +125 -0
  66. bindsight/report/web/templates/try.html.j2 +155 -0
  67. bindsight/report/web/templates/your_data.html.j2 +46 -0
  68. bindsight/runners/__init__.py +7 -0
  69. bindsight/runners/colab.py +158 -0
  70. bindsight/runners/job_exec.py +1110 -0
  71. bindsight/runners/kaggle.py +425 -0
  72. bindsight/runners/kaggle_kernel.py +489 -0
  73. bindsight/runners/local_docker.py +154 -0
  74. bindsight/runners/mock.py +167 -0
  75. bindsight/runners/modal_runner.py +199 -0
  76. bindsight/runners/notebook.py +116 -0
  77. bindsight/runners/notebook_content.py +217 -0
  78. bindsight/runners/protocol.py +81 -0
  79. bindsight/runners/source_wheel.py +104 -0
  80. bindsight/runners/tools.py +997 -0
  81. bindsight/structures/__init__.py +7 -0
  82. bindsight/structures/alphafolddb.py +93 -0
  83. bindsight/structures/plddt.py +92 -0
  84. bindsight/structures/topology.py +227 -0
  85. bindsight/surfaceome/__init__.py +42 -0
  86. bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
  87. bindsight/surfaceome/data/surfy_offline.txt +17 -0
  88. bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
  89. bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
  90. bindsight/surfaceome/surfy.py +441 -0
  91. bindsight/targets/__init__.py +7 -0
  92. bindsight/targets/ensembl_uniprot.py +56 -0
  93. bindsight/targets/gtex.py +250 -0
  94. bindsight/targets/open_targets.py +239 -0
  95. bindsight/validate/__init__.py +7 -0
  96. bindsight/validate/af2_ig.py +49 -0
  97. bindsight/validate/boltz2.py +256 -0
  98. bindsight/validate/chai1r.py +42 -0
  99. bindsight/validate/protocol.py +188 -0
  100. bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
  101. bindsight-0.3.5.dist-info/METADATA +495 -0
  102. bindsight-0.3.5.dist-info/RECORD +106 -0
  103. bindsight-0.3.5.dist-info/WHEEL +4 -0
  104. bindsight-0.3.5.dist-info/entry_points.txt +20 -0
  105. bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
  106. 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)