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,595 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""RO-Crate exporter.
|
|
4
|
+
|
|
5
|
+
Bundles a finished bindsight run into a single ``.crate.zip`` that is
|
|
6
|
+
recognized by research data repositories, FAIR Digital Object Frameworks, and
|
|
7
|
+
the broader Research Object Crate ecosystem.
|
|
8
|
+
|
|
9
|
+
We emit the lightweight RO-Crate 1.1 metadata format
|
|
10
|
+
(https://www.researchobject.org/ro-crate/1.1/) — a single ``ro-crate-metadata.json``
|
|
11
|
+
at the crate root that catalogues every other file, plus a ``software.bib``
|
|
12
|
+
listing the upstream tools used. This is intentionally minimal: implementing
|
|
13
|
+
the full ``ro-crate-py`` library as a runtime dependency adds several
|
|
14
|
+
hundred KB of code for one JSON file.
|
|
15
|
+
|
|
16
|
+
Every payload file in the crate is byte-identical for runs with the same
|
|
17
|
+
manifest; the only varying field is ``datePublished`` in the metadata, which
|
|
18
|
+
records when the crate was packaged rather than anything about the run. So
|
|
19
|
+
this is a reproducibility primitive — depositing the crate in an archive that
|
|
20
|
+
mints identifiers gives one anybody can dereference to pull the exact artifacts
|
|
21
|
+
we packaged. Which archive is the depositor's choice; the crate names none.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import zipfile
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from pathlib import Path, PurePosixPath
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from bindsight import __version__
|
|
34
|
+
|
|
35
|
+
LOG = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
RO_CRATE_CONTEXT = "https://w3id.org/ro/crate/1.1/context"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def export_ro_crate(
|
|
41
|
+
run_dir: Path | str,
|
|
42
|
+
out_path: Path | str | None = None,
|
|
43
|
+
) -> Path:
|
|
44
|
+
"""Bundle ``run_dir`` into an RO-Crate zip suitable for deposit.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
run_dir: directory produced by ``bindsight discover`` (and optionally
|
|
48
|
+
``design``, ``validate``, ``rank``, ``report``).
|
|
49
|
+
out_path: destination zip. Defaults to ``<run_dir>.crate.zip``.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Path to the written zip.
|
|
53
|
+
"""
|
|
54
|
+
run = Path(run_dir)
|
|
55
|
+
if not run.exists():
|
|
56
|
+
raise FileNotFoundError(f"run directory not found: {run}")
|
|
57
|
+
out = Path(out_path) if out_path else run.parent / f"{run.name}.crate.zip"
|
|
58
|
+
|
|
59
|
+
files = _collect_files(run)
|
|
60
|
+
external = _external_inputs(run)
|
|
61
|
+
metadata = _build_metadata(run, files, external)
|
|
62
|
+
bibtex = _build_software_bib(run)
|
|
63
|
+
|
|
64
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
66
|
+
# Required RO-Crate metadata file
|
|
67
|
+
zf.writestr("ro-crate-metadata.json", json.dumps(metadata, indent=2))
|
|
68
|
+
# Citation BibTeX
|
|
69
|
+
zf.writestr("software.bib", bibtex)
|
|
70
|
+
# Every artifact, preserving relative paths under the run root
|
|
71
|
+
for f in files:
|
|
72
|
+
arcname = f.relative_to(run).as_posix()
|
|
73
|
+
zf.write(f, arcname)
|
|
74
|
+
# The cohort the run consumed, which lives outside the run directory.
|
|
75
|
+
for arcname, src in external:
|
|
76
|
+
zf.write(src, arcname)
|
|
77
|
+
|
|
78
|
+
LOG.info(
|
|
79
|
+
"wrote RO-Crate: %s (%d run artifacts, %d carried-in inputs)",
|
|
80
|
+
out,
|
|
81
|
+
len(files),
|
|
82
|
+
len(external),
|
|
83
|
+
)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Helpers
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
#: Single files, in canonical order.
|
|
91
|
+
_PIPELINE_FILES = (
|
|
92
|
+
"config.yaml",
|
|
93
|
+
# The cohort itself. These are the only artifacts carrying TCGA case and
|
|
94
|
+
# sample barcodes, so without them an exported crate cannot answer the one
|
|
95
|
+
# question the provenance chain exists to answer: which patients did this
|
|
96
|
+
# binder come from? A crate that stops at the DEG table documents an
|
|
97
|
+
# analysis, not its origin.
|
|
98
|
+
"counts.tsv.gz",
|
|
99
|
+
"counts.tsv",
|
|
100
|
+
"design.tsv",
|
|
101
|
+
"provenance.json",
|
|
102
|
+
"deg/results.parquet",
|
|
103
|
+
"targets/candidates.parquet",
|
|
104
|
+
"epitopes/epitopes.parquet",
|
|
105
|
+
"taxonomy/failure_taxonomy.parquet",
|
|
106
|
+
"design/results.tar.gz",
|
|
107
|
+
"design/metrics.jsonl",
|
|
108
|
+
# Whether the ESM-2 pre-screen actually applied. It fails open, so a run
|
|
109
|
+
# whose embedding died carries every design under a cache key that says it
|
|
110
|
+
# kept the top k; this file is the only thing that distinguishes the two.
|
|
111
|
+
"design/prescreen.txt",
|
|
112
|
+
"validate/validated.parquet",
|
|
113
|
+
"rank/ranking.parquet",
|
|
114
|
+
"report.html",
|
|
115
|
+
"run_manifest.jsonld",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
#: Directory trees included wholesale. ``structures/`` is what makes the crate
|
|
119
|
+
#: self-contained — candidate and epitope tables reference those files, and
|
|
120
|
+
#: without them every structure reference in an exported crate dangles.
|
|
121
|
+
#: ``design/`` and ``validate/`` subtrees carry the per-target archives and the
|
|
122
|
+
#: per-binder validator output that the flat allowlist silently dropped.
|
|
123
|
+
_PIPELINE_TREES = (
|
|
124
|
+
"structures",
|
|
125
|
+
"design/_targets",
|
|
126
|
+
"validate",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
#: Skipped inside `_PIPELINE_TREES`; already listed individually above.
|
|
130
|
+
_TREE_EXCLUDE = frozenset({"validated.parquet"})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _collect_files(run: Path) -> list[Path]:
|
|
134
|
+
"""Return the per-run artifacts to include in the crate, in canonical order.
|
|
135
|
+
|
|
136
|
+
Zero-byte files are skipped: several stages write empty placeholders, and a
|
|
137
|
+
crate should describe what a run produced, not what it reserved space for.
|
|
138
|
+
"""
|
|
139
|
+
files: list[Path] = []
|
|
140
|
+
seen: set[Path] = set()
|
|
141
|
+
|
|
142
|
+
def _add(p: Path) -> None:
|
|
143
|
+
if p in seen or not p.is_file():
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
if p.stat().st_size <= 0:
|
|
147
|
+
return
|
|
148
|
+
except OSError: # pragma: no cover - race with a concurrent write
|
|
149
|
+
return
|
|
150
|
+
seen.add(p)
|
|
151
|
+
files.append(p)
|
|
152
|
+
|
|
153
|
+
for rel in _PIPELINE_FILES:
|
|
154
|
+
_add(run / rel)
|
|
155
|
+
|
|
156
|
+
for tree in _PIPELINE_TREES:
|
|
157
|
+
base = run / tree
|
|
158
|
+
if not base.is_dir():
|
|
159
|
+
continue
|
|
160
|
+
for p in sorted(base.rglob("*")):
|
|
161
|
+
if p.name not in _TREE_EXCLUDE:
|
|
162
|
+
_add(p)
|
|
163
|
+
|
|
164
|
+
return files
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _resolve_recorded(path_str: str, run: Path) -> Path | None:
|
|
168
|
+
"""Resolve a manifest-recorded path to a file on this machine.
|
|
169
|
+
|
|
170
|
+
Recorded paths are relative to the working directory the run was launched
|
|
171
|
+
from, which is typically the repository root rather than the run directory,
|
|
172
|
+
and they are written in the launching platform's separator style. So try the
|
|
173
|
+
run directory first, then the working directory, then each ancestor. The run
|
|
174
|
+
comes first because a run-relative name like ``counts.tsv`` must not be
|
|
175
|
+
satisfied by an unrelated file of the same name sitting in the caller's
|
|
176
|
+
working directory.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
path_str: the path as the manifest recorded it.
|
|
180
|
+
run: the run directory, used as the base for the upward search.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The resolved file, or None if nothing matching exists.
|
|
184
|
+
"""
|
|
185
|
+
raw = Path(_posix_key(path_str))
|
|
186
|
+
if raw.is_absolute():
|
|
187
|
+
return raw if raw.is_file() else None
|
|
188
|
+
for base in (run, Path.cwd(), *run.parents):
|
|
189
|
+
candidate = base / raw
|
|
190
|
+
if candidate.is_file():
|
|
191
|
+
return candidate
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _external_inputs(run: Path) -> list[tuple[str, Path]]:
|
|
196
|
+
"""Cohort inputs the run consumed from outside its own directory.
|
|
197
|
+
|
|
198
|
+
The allowlist above names ``counts.tsv.gz`` and ``design.tsv`` because they
|
|
199
|
+
are the only artifacts carrying TCGA case and sample barcodes — without them
|
|
200
|
+
a crate cannot answer which patients a binder came from. But naming them was
|
|
201
|
+
not enough: a run configured with ``inputs.counts`` pointing anywhere else
|
|
202
|
+
keeps them outside the run directory, so the allowlist matched nothing and
|
|
203
|
+
the crate shipped without a cohort. That was true of the provenance run this
|
|
204
|
+
was written for; the exported crate stopped at the DEG table.
|
|
205
|
+
|
|
206
|
+
The manifest already records every stage input by path and by sha256, so it
|
|
207
|
+
is the authoritative list. Anything it names that resolves outside the run
|
|
208
|
+
is carried in under ``inputs/``, keeping its basename so the recorded digest
|
|
209
|
+
still identifies it. A ``provenance.json`` sitting beside a carried-in input
|
|
210
|
+
comes too: it is the cohort's own record of how the sample set was
|
|
211
|
+
assembled, and no stage declares it as an input.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
run: the run directory.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
``(arcname, source)`` pairs, in manifest order, deduplicated.
|
|
218
|
+
"""
|
|
219
|
+
manifest_path = run / "run_manifest.jsonld"
|
|
220
|
+
if not manifest_path.is_file():
|
|
221
|
+
return []
|
|
222
|
+
try:
|
|
223
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
224
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
225
|
+
LOG.warning("could not read %s (%s); the crate will carry no inputs", manifest_path, e)
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
run_resolved = run.resolve()
|
|
229
|
+
out: list[tuple[str, Path]] = []
|
|
230
|
+
taken: set[str] = set()
|
|
231
|
+
seen: set[Path] = set()
|
|
232
|
+
for stage in manifest.get("stages", []) or []:
|
|
233
|
+
for ref in stage.get("inputs") or []:
|
|
234
|
+
recorded = ref.get("path")
|
|
235
|
+
if not recorded:
|
|
236
|
+
continue
|
|
237
|
+
src = _resolve_recorded(str(recorded), run)
|
|
238
|
+
if src is None:
|
|
239
|
+
LOG.warning(
|
|
240
|
+
"input %s recorded by stage %s is not on this machine, so the "
|
|
241
|
+
"crate cannot carry it",
|
|
242
|
+
recorded,
|
|
243
|
+
stage.get("name", "?"),
|
|
244
|
+
)
|
|
245
|
+
continue
|
|
246
|
+
src = src.resolve()
|
|
247
|
+
if src in seen or src.is_relative_to(run_resolved):
|
|
248
|
+
continue # already inside the run, so the allowlist has it
|
|
249
|
+
seen.add(src)
|
|
250
|
+
arcname = f"inputs/{src.name}"
|
|
251
|
+
if arcname in taken: # two inputs sharing a basename
|
|
252
|
+
arcname = f"inputs/{stage.get('name', 'stage')}-{src.name}"
|
|
253
|
+
taken.add(arcname)
|
|
254
|
+
out.append((arcname, src))
|
|
255
|
+
|
|
256
|
+
# The cohort's own provenance document travels with the cohort. It records
|
|
257
|
+
# how the sample set was assembled — the GDC project, the workflow, the
|
|
258
|
+
# retrieval date, the cases requested against those actually obtained —
|
|
259
|
+
# which is what lets a reader rebuild the cohort rather than merely read it.
|
|
260
|
+
# It is not a stage input, so nothing in the manifest points at it.
|
|
261
|
+
for cohort_dir in dict.fromkeys(src.parent for _, src in list(out)):
|
|
262
|
+
doc = cohort_dir / "provenance.json"
|
|
263
|
+
if not doc.is_file() or doc.resolve() in seen:
|
|
264
|
+
continue
|
|
265
|
+
seen.add(doc.resolve())
|
|
266
|
+
arcname = "inputs/provenance.json"
|
|
267
|
+
if arcname in taken:
|
|
268
|
+
arcname = f"inputs/{cohort_dir.name}-provenance.json"
|
|
269
|
+
taken.add(arcname)
|
|
270
|
+
out.append((arcname, doc))
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _manifest_digests(manifest: dict[str, Any]) -> dict[str, str]:
|
|
275
|
+
"""Map run-relative artifact path -> sha256, as recorded in the manifest.
|
|
276
|
+
|
|
277
|
+
The manifest already carries a verified digest for every declared input and
|
|
278
|
+
output; the crate simply never carried them across, so a reader had no way
|
|
279
|
+
to check integrity. Paths are normalised to their run-relative form because
|
|
280
|
+
older manifests recorded absolute ones.
|
|
281
|
+
"""
|
|
282
|
+
digests: dict[str, str] = {}
|
|
283
|
+
for stage in manifest.get("stages", []) or []:
|
|
284
|
+
for ref in [*(stage.get("inputs") or []), *(stage.get("outputs") or [])]:
|
|
285
|
+
path = ref.get("path")
|
|
286
|
+
sha = ref.get("sha256")
|
|
287
|
+
if path and sha:
|
|
288
|
+
# Keyed by the run-relative path, which is what the docstring
|
|
289
|
+
# above promises. Keying on the basename made two files called
|
|
290
|
+
# ``metrics.jsonl`` in different stages one entry, and left every
|
|
291
|
+
# file whose basename the manifest did not record undigested.
|
|
292
|
+
#
|
|
293
|
+
# Separators are normalised the same way ``_resolve_recorded``
|
|
294
|
+
# normalises them. ``Path(path).as_posix()`` is not enough: on
|
|
295
|
+
# POSIX a backslash is an ordinary filename character, so a
|
|
296
|
+
# manifest written on Windows -- which records
|
|
297
|
+
# ``runs\join\deg\results.parquet`` -- produced a single-segment
|
|
298
|
+
# key that no lookup in ``_digest_for`` could match. Every
|
|
299
|
+
# ``sha256`` silently vanished from crates exported on Linux and
|
|
300
|
+
# macOS, leaving the archive deposited as a run's integrity
|
|
301
|
+
# record with no integrity record in it. The two functions
|
|
302
|
+
# disagreeing about this is the whole defect.
|
|
303
|
+
digests[_posix_key(path)] = sha
|
|
304
|
+
return digests
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _posix_key(path_str: str) -> str:
|
|
308
|
+
"""A recorded path as a POSIX key, whatever platform recorded it.
|
|
309
|
+
|
|
310
|
+
``_resolve_recorded`` and ``_manifest_digests`` both consume manifest paths
|
|
311
|
+
and must agree on what a separator is. They did not: one replaced
|
|
312
|
+
backslashes, the other relied on ``Path.as_posix()``, which only converts
|
|
313
|
+
separators on the platform whose separator they are. Both now call this.
|
|
314
|
+
"""
|
|
315
|
+
return PurePosixPath(path_str.replace("\\", "/")).as_posix()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _digest_for(digests: dict[str, str], wanted: str) -> str | None:
|
|
319
|
+
"""Find an artifact's recorded digest, precisely where possible.
|
|
320
|
+
|
|
321
|
+
Three steps, narrowing to broadening:
|
|
322
|
+
|
|
323
|
+
1. The exact run-relative path, which is what the map is keyed by.
|
|
324
|
+
2. A recorded path that ends with it — manifests written before paths were
|
|
325
|
+
normalised stored repository-relative ones such as
|
|
326
|
+
``runs/join/deg/results.parquet``.
|
|
327
|
+
3. The basename, but **only when exactly one recorded path has it**. This map
|
|
328
|
+
used to be keyed by basename outright, which silently merged two files
|
|
329
|
+
called ``metrics.jsonl`` into one entry and attached whichever digest won
|
|
330
|
+
to both. Requiring uniqueness keeps the convenience without the collision:
|
|
331
|
+
an ambiguous basename yields no digest rather than a wrong one.
|
|
332
|
+
"""
|
|
333
|
+
exact = digests.get(wanted)
|
|
334
|
+
if exact is not None:
|
|
335
|
+
return exact
|
|
336
|
+
|
|
337
|
+
suffix_matches = [v for k, v in digests.items() if k.endswith("/" + wanted)]
|
|
338
|
+
if len(suffix_matches) == 1:
|
|
339
|
+
return suffix_matches[0]
|
|
340
|
+
|
|
341
|
+
base = PurePosixPath(wanted).name
|
|
342
|
+
by_base = [v for k, v in digests.items() if PurePosixPath(k).name == base]
|
|
343
|
+
if len(by_base) == 1:
|
|
344
|
+
return by_base[0]
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _build_metadata(
|
|
349
|
+
run: Path, files: list[Path], external: list[tuple[str, Path]] | None = None
|
|
350
|
+
) -> dict[str, Any]:
|
|
351
|
+
"""Construct the RO-Crate 1.1 metadata document.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
run: the run directory.
|
|
355
|
+
files: artifacts inside the run, named by their run-relative path.
|
|
356
|
+
external: ``(arcname, source)`` inputs carried in from outside it.
|
|
357
|
+
"""
|
|
358
|
+
manifest_path = run / "run_manifest.jsonld"
|
|
359
|
+
manifest = {}
|
|
360
|
+
if manifest_path.exists():
|
|
361
|
+
try:
|
|
362
|
+
body = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
363
|
+
body.pop("@context", None)
|
|
364
|
+
manifest = body
|
|
365
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
366
|
+
# `_external_inputs` logs this exact failure; this swallowed it with
|
|
367
|
+
# a bare `pass`. The crate then shipped with an empty run id and no
|
|
368
|
+
# sha256 on any file, and a consumer preparing a deposit could not
|
|
369
|
+
# tell that from a run which genuinely recorded no digests.
|
|
370
|
+
LOG.warning(
|
|
371
|
+
"could not read %s (%s); the crate will carry no run identity and no file digests",
|
|
372
|
+
manifest_path,
|
|
373
|
+
e,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
digests = _manifest_digests(manifest)
|
|
377
|
+
file_entries: list[dict[str, object]] = []
|
|
378
|
+
for p in files:
|
|
379
|
+
entry: dict[str, object] = {
|
|
380
|
+
"@id": p.relative_to(run).as_posix(),
|
|
381
|
+
"@type": "File",
|
|
382
|
+
"name": p.name,
|
|
383
|
+
"contentSize": p.stat().st_size,
|
|
384
|
+
}
|
|
385
|
+
sha = _digest_for(digests, p.relative_to(run).as_posix())
|
|
386
|
+
if sha:
|
|
387
|
+
# schema.org has no sha256 term; this is the RO-Crate convention.
|
|
388
|
+
entry["sha256"] = sha
|
|
389
|
+
file_entries.append(entry)
|
|
390
|
+
|
|
391
|
+
for arcname, src in external or []:
|
|
392
|
+
input_entry: dict[str, object] = {
|
|
393
|
+
"@id": arcname,
|
|
394
|
+
"@type": "File",
|
|
395
|
+
"name": src.name,
|
|
396
|
+
"contentSize": src.stat().st_size,
|
|
397
|
+
"description": (
|
|
398
|
+
"Cohort input consumed by this run, carried into the crate from "
|
|
399
|
+
"outside the run directory so the provenance chain reaches the "
|
|
400
|
+
"patient barcodes it started from."
|
|
401
|
+
),
|
|
402
|
+
}
|
|
403
|
+
# A carried-in input lives outside the run, so it has no run-relative
|
|
404
|
+
# path; the manifest recorded it however the run was launched. The
|
|
405
|
+
# resolver's unambiguous-basename fallback is what matches it.
|
|
406
|
+
sha = _digest_for(digests, src.name)
|
|
407
|
+
if sha:
|
|
408
|
+
input_entry["sha256"] = sha
|
|
409
|
+
file_entries.append(input_entry)
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
"@context": RO_CRATE_CONTEXT,
|
|
413
|
+
"@graph": [
|
|
414
|
+
{
|
|
415
|
+
"@id": "ro-crate-metadata.json",
|
|
416
|
+
"@type": "CreativeWork",
|
|
417
|
+
"conformsTo": {"@id": "https://w3id.org/ro/crate/1.1"},
|
|
418
|
+
"about": {"@id": "./"},
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
"@id": "./",
|
|
422
|
+
"@type": "Dataset",
|
|
423
|
+
"name": manifest.get("name") or run.name,
|
|
424
|
+
"description": (
|
|
425
|
+
"bindsight run output: ranked de novo protein binder candidates "
|
|
426
|
+
"produced from RNA-seq counts, with full PROV-O provenance."
|
|
427
|
+
),
|
|
428
|
+
"datePublished": datetime.now(UTC).strftime("%Y-%m-%d"),
|
|
429
|
+
"creator": {
|
|
430
|
+
"@type": "SoftwareApplication",
|
|
431
|
+
"name": "bindsight",
|
|
432
|
+
"version": __version__,
|
|
433
|
+
"url": "https://github.com/mikhaeelatefrizk/bindsight",
|
|
434
|
+
"license": "AGPL-3.0-or-later",
|
|
435
|
+
},
|
|
436
|
+
# software.bib is packaged and described below, so it belongs
|
|
437
|
+
# here too: a conforming RO-Crate reader walks hasPart, and a
|
|
438
|
+
# file absent from it is a file the crate does not declare.
|
|
439
|
+
"hasPart": [{"@id": e["@id"]} for e in file_entries] + [{"@id": "software.bib"}],
|
|
440
|
+
"bindsight:run_id": manifest.get("run_id", ""),
|
|
441
|
+
},
|
|
442
|
+
*file_entries,
|
|
443
|
+
{
|
|
444
|
+
"@id": "software.bib",
|
|
445
|
+
"@type": "File",
|
|
446
|
+
"name": "software.bib",
|
|
447
|
+
"encodingFormat": "application/x-bibtex",
|
|
448
|
+
"description": "BibTeX for every upstream tool this run depended on.",
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
#: The upstream tools each bindsight wrapper actually runs, so the crate credits
|
|
455
|
+
#: them instead of the wrapper. Licences are the ones LICENSING.md records (its
|
|
456
|
+
#: header states the date the upstream LICENSE files were last re-verified);
|
|
457
|
+
#: repository URLs and pins come from :mod:`bindsight.runners.tools`, so a bumped
|
|
458
|
+
#: pin moves the citation with it rather than leaving it behind.
|
|
459
|
+
#:
|
|
460
|
+
#: This exists because the manifest records one ``ToolRef`` per stage and that
|
|
461
|
+
#: ref is the wrapper. Crediting BSD-3 RFdiffusion and MIT ProteinMPNN as
|
|
462
|
+
#: "bindsight.design.rfdiff_mpnn, AGPL-3.0-or-later" is wrong twice: it drops the
|
|
463
|
+
#: attribution those licences require, and it asserts terms over work that is not
|
|
464
|
+
#: bindsight's to license.
|
|
465
|
+
def _upstream_tools() -> dict[str, tuple[dict[str, str], ...]]:
|
|
466
|
+
"""Wrapper tool name -> the upstream tools it invokes."""
|
|
467
|
+
from bindsight.runners import tools as T
|
|
468
|
+
from bindsight.validate.boltz2 import PINNED_BOLTZ2_VERSION
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
"bindsight.design.rfdiff_mpnn": (
|
|
472
|
+
{
|
|
473
|
+
"name": "RFdiffusion",
|
|
474
|
+
"license": "BSD-3-Clause",
|
|
475
|
+
"url": T.RFDIFF_REPO,
|
|
476
|
+
"version": T.RFDIFF_COMMIT,
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
"name": "ProteinMPNN",
|
|
480
|
+
"license": "MIT",
|
|
481
|
+
"url": T.PROTEINMPNN_REPO,
|
|
482
|
+
"version": T.PROTEINMPNN_COMMIT,
|
|
483
|
+
},
|
|
484
|
+
),
|
|
485
|
+
"bindsight.design.bindcraft": (
|
|
486
|
+
{
|
|
487
|
+
"name": "BindCraft",
|
|
488
|
+
"license": "MIT",
|
|
489
|
+
"url": T.BINDCRAFT_REPO,
|
|
490
|
+
"version": T.BINDCRAFT_COMMIT,
|
|
491
|
+
},
|
|
492
|
+
),
|
|
493
|
+
"bindsight.design.boltzgen": (
|
|
494
|
+
{
|
|
495
|
+
"name": "BoltzGen",
|
|
496
|
+
"license": "MIT",
|
|
497
|
+
"url": T.BOLTZGEN_REPO,
|
|
498
|
+
"version": T.BOLTZGEN_COMMIT,
|
|
499
|
+
},
|
|
500
|
+
),
|
|
501
|
+
"bindsight.validate.boltz2": (
|
|
502
|
+
{
|
|
503
|
+
"name": "Boltz-2",
|
|
504
|
+
"license": "MIT",
|
|
505
|
+
"url": "https://github.com/jwohlwend/boltz",
|
|
506
|
+
"version": PINNED_BOLTZ2_VERSION,
|
|
507
|
+
},
|
|
508
|
+
),
|
|
509
|
+
"bindsight.validate.chai1r": (
|
|
510
|
+
{
|
|
511
|
+
"name": "Chai-1r",
|
|
512
|
+
"license": "Apache-2.0",
|
|
513
|
+
"url": "https://github.com/chaidiscovery/chai-lab",
|
|
514
|
+
"version": T.CHAI_PIP,
|
|
515
|
+
},
|
|
516
|
+
),
|
|
517
|
+
"bindsight.validate.af2_ig": (
|
|
518
|
+
{
|
|
519
|
+
"name": "dl_binder_design (AF2 initial guess)",
|
|
520
|
+
"license": "PyRosetta non-commercial; AF2 parameters CC BY 4.0; code MIT",
|
|
521
|
+
"url": T.DL_BINDER_DESIGN_REPO,
|
|
522
|
+
"version": T.DL_BINDER_DESIGN_COMMIT,
|
|
523
|
+
},
|
|
524
|
+
),
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _build_software_bib(run: Path) -> str:
|
|
529
|
+
"""Produce a BibTeX block citing every upstream tool used in the run.
|
|
530
|
+
|
|
531
|
+
Reads tool entries from the manifest's ``stages[*].tool`` entries and
|
|
532
|
+
emits a ``@software`` BibTeX entry per unique (tool, version, license).
|
|
533
|
+
"""
|
|
534
|
+
manifest_path = run / "run_manifest.jsonld"
|
|
535
|
+
if not manifest_path.exists():
|
|
536
|
+
return _DEFAULT_BIBTEX
|
|
537
|
+
try:
|
|
538
|
+
body = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
539
|
+
except json.JSONDecodeError:
|
|
540
|
+
return _DEFAULT_BIBTEX
|
|
541
|
+
|
|
542
|
+
seen: set[tuple[str, str]] = set()
|
|
543
|
+
entries: list[str] = [
|
|
544
|
+
"% Auto-generated by bindsight export. Cite the tools you actually used.",
|
|
545
|
+
"",
|
|
546
|
+
]
|
|
547
|
+
upstream = _upstream_tools()
|
|
548
|
+
cited: list[dict[str, str]] = []
|
|
549
|
+
for stage in body.get("stages", []):
|
|
550
|
+
tool = stage.get("tool", {})
|
|
551
|
+
name = tool.get("name", "")
|
|
552
|
+
version = tool.get("version", "")
|
|
553
|
+
# The tools the wrapper ran, credited under their own names and licences.
|
|
554
|
+
for entry in upstream.get(name, ()):
|
|
555
|
+
if (entry["name"], entry["version"]) not in seen:
|
|
556
|
+
seen.add((entry["name"], entry["version"]))
|
|
557
|
+
cited.append(entry)
|
|
558
|
+
if not name or (name, version) in seen:
|
|
559
|
+
continue
|
|
560
|
+
seen.add((name, version))
|
|
561
|
+
key = name.replace("/", "_").replace(".", "_") + version.replace(".", "_")
|
|
562
|
+
url = tool.get("repo_url", "")
|
|
563
|
+
license_ = tool.get("license", "")
|
|
564
|
+
cite = tool.get("citation", "")
|
|
565
|
+
entries.append(
|
|
566
|
+
f"@software{{{key},\n"
|
|
567
|
+
f" title = {{{name}}},\n"
|
|
568
|
+
f" version = {{{version}}},\n"
|
|
569
|
+
f" license = {{{license_}}},\n"
|
|
570
|
+
f" url = {{{url}}},\n" + (f" doi = {{{cite}}},\n" if cite else "") + "}\n"
|
|
571
|
+
)
|
|
572
|
+
for entry in cited:
|
|
573
|
+
key = (
|
|
574
|
+
entry["name"].replace("/", "_").replace(".", "_").replace(" ", "_").replace("-", "_")
|
|
575
|
+
+ "_"
|
|
576
|
+
+ entry["version"].replace(".", "_")[:12]
|
|
577
|
+
)
|
|
578
|
+
entries.append(
|
|
579
|
+
f"@software{{{key},\n"
|
|
580
|
+
f" title = {{{entry['name']}}},\n"
|
|
581
|
+
f" version = {{{entry['version']}}},\n"
|
|
582
|
+
f" license = {{{entry['license']}}},\n"
|
|
583
|
+
f" url = {{{entry['url']}}},\n"
|
|
584
|
+
"}\n"
|
|
585
|
+
)
|
|
586
|
+
return "\n".join(entries) if len(entries) > 2 else _DEFAULT_BIBTEX
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
_DEFAULT_BIBTEX = """% bindsight run with no manifest entries detected.
|
|
590
|
+
@software{bindsight,
|
|
591
|
+
title = {bindsight: a reproducible bridge from RNA-seq to de novo protein binder design},
|
|
592
|
+
url = {https://github.com/mikhaeelatefrizk/bindsight},
|
|
593
|
+
license = {AGPL-3.0-or-later}
|
|
594
|
+
}
|
|
595
|
+
"""
|
bindsight/io/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""I/O helpers — file readers, writers, cache directories."""
|
|
4
|
+
|
|
5
|
+
from bindsight.io.paths import (
|
|
6
|
+
adopt_structure,
|
|
7
|
+
cache_dir,
|
|
8
|
+
cache_root,
|
|
9
|
+
resolve_run_path,
|
|
10
|
+
run_dir,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"adopt_structure",
|
|
15
|
+
"cache_dir",
|
|
16
|
+
"cache_root",
|
|
17
|
+
"resolve_run_path",
|
|
18
|
+
"run_dir",
|
|
19
|
+
]
|