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,441 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""SURFY surfaceome filter.
|
|
4
|
+
|
|
5
|
+
SURFY is a curated list of ~2,886 human surface proteins published by
|
|
6
|
+
Bausch-Fluck et al. (PNAS 2018). It's a high-confidence subset of the
|
|
7
|
+
predicted human surfaceome based on a machine-learned classifier trained on
|
|
8
|
+
mass-spec evidence.
|
|
9
|
+
|
|
10
|
+
Distribution: the full list (its source states no licence; see LICENSING.md)
|
|
11
|
+
is **vendored** in
|
|
12
|
+
``bindsight/surfaceome/data/surfy_v1.uniprot.txt`` (2,886 accessions) and is
|
|
13
|
+
the authoritative source at query time. Discovery therefore needs no network
|
|
14
|
+
access for the surfaceome at all.
|
|
15
|
+
|
|
16
|
+
That is a deliberate change from lazy-fetching it on first use. The upstream
|
|
17
|
+
spreadsheet is no longer retrievable: ``wlab.ethz.ch`` serves an HTML landing
|
|
18
|
+
page (so ``pandas.read_excel`` died with ``BadZipFile``), and the relocated
|
|
19
|
+
``wollscheidlab.org`` serves a 132-byte Git-LFS pointer. Every fresh install
|
|
20
|
+
was therefore either hard-failing or silently degrading to a ten-protein list
|
|
21
|
+
and surfacing almost no candidates. Regenerate the vendored list with
|
|
22
|
+
``scripts/build_surfy_list.py``.
|
|
23
|
+
|
|
24
|
+
Resolution order at query time:
|
|
25
|
+
|
|
26
|
+
1. ``<user_cache>/bindsight/surfy/surfy_v1.uniprot.txt`` — a user-refreshed
|
|
27
|
+
cache, if one exists, so a newer release can be adopted without upgrading.
|
|
28
|
+
2. The vendored full list — the normal path.
|
|
29
|
+
3. A ten-protein bundled fallback, used only when the first two are missing
|
|
30
|
+
*and* the caller explicitly allows degraded results.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import logging
|
|
36
|
+
from importlib import resources
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
import requests
|
|
40
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
41
|
+
|
|
42
|
+
from bindsight.io.paths import cache_dir
|
|
43
|
+
|
|
44
|
+
LOG = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# Number of proteins in the canonical SURFY list (Bausch-Fluck et al. 2018).
|
|
47
|
+
# Used as a sanity check after parsing.
|
|
48
|
+
SURFY_PROTEIN_COUNT = 2886
|
|
49
|
+
|
|
50
|
+
# Canonical SURFY surfaceome table, Bausch-Fluck et al. PNAS 2018. The site
|
|
51
|
+
# states no licence; the article is CC BY-NC-ND 4.0 (see LICENSING.md).
|
|
52
|
+
#
|
|
53
|
+
# Retained for `populate_surfy_cache`, which is now an explicit opt-in refresh
|
|
54
|
+
# path rather than something the pipeline depends on. As of 2026-07 neither
|
|
55
|
+
# host serves the spreadsheet: the original returns an HTML landing page and
|
|
56
|
+
# the mirror returns a Git-LFS pointer. `load_surfy` uses the vendored list.
|
|
57
|
+
SURFY_XLSX_URL = "https://wollscheidlab.org/SURFY/table_S3_surfaceome.xlsx"
|
|
58
|
+
|
|
59
|
+
# The full vendored list, generated by scripts/build_surfy_list.py.
|
|
60
|
+
VENDORED_LIST_FILENAME = "surfy_v1.uniprot.txt"
|
|
61
|
+
|
|
62
|
+
# Bundled fallback for offline tests — a small set of well-characterized
|
|
63
|
+
# surface antigens. NOT a substitute for the full SURFY list in production.
|
|
64
|
+
_OFFLINE_FALLBACK_UNIPROT = frozenset(
|
|
65
|
+
{
|
|
66
|
+
"P04626", # ERBB2 / HER2
|
|
67
|
+
"P00533", # EGFR
|
|
68
|
+
"P56747", # CLDN6
|
|
69
|
+
"Q13421", # MSLN (mesothelin)
|
|
70
|
+
"P10747", # CD28
|
|
71
|
+
"P16410", # CTLA-4
|
|
72
|
+
"Q9NZQ7", # PD-L1 / CD274
|
|
73
|
+
"P09038", # FGF2
|
|
74
|
+
"P10275", # AR
|
|
75
|
+
"P19838", # NF-kB1
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_accessions(text: str) -> frozenset[str]:
|
|
81
|
+
"""Parse one-accession-per-line text, ignoring comments and blanks."""
|
|
82
|
+
return frozenset(
|
|
83
|
+
line.strip() for line in text.splitlines() if line.strip() and not line.startswith("#")
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _read_packaged(filename: str) -> str | None:
|
|
88
|
+
"""Read a data file shipped inside the package, or ``None`` if absent."""
|
|
89
|
+
try:
|
|
90
|
+
return (
|
|
91
|
+
resources.files("bindsight.surfaceome")
|
|
92
|
+
.joinpath("data", filename)
|
|
93
|
+
.read_text(encoding="utf-8")
|
|
94
|
+
)
|
|
95
|
+
except (FileNotFoundError, ModuleNotFoundError, OSError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _bundled_uniprot_set() -> frozenset[str]:
|
|
100
|
+
"""Return the small offline-test surface-protein set bundled with bindsight."""
|
|
101
|
+
text = _read_packaged("surfy_offline.txt")
|
|
102
|
+
if text is None:
|
|
103
|
+
return _OFFLINE_FALLBACK_UNIPROT
|
|
104
|
+
return _parse_accessions(text)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_vendored_surfy() -> frozenset[str] | None:
|
|
108
|
+
"""Return the full vendored SURFY accession set, or ``None`` if unavailable.
|
|
109
|
+
|
|
110
|
+
This is the canonical list shipped with the package — not a degraded
|
|
111
|
+
fallback. It exists because the upstream spreadsheet is no longer
|
|
112
|
+
retrievable; see the module docstring.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The full accession set, or ``None`` when the data file is missing (for
|
|
116
|
+
example in a trimmed-down install).
|
|
117
|
+
"""
|
|
118
|
+
text = _read_packaged(VENDORED_LIST_FILENAME)
|
|
119
|
+
if text is None:
|
|
120
|
+
return None
|
|
121
|
+
accessions = _parse_accessions(text)
|
|
122
|
+
return accessions or None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load_surfaceome_extension() -> dict[str, tuple[str, str]]:
|
|
126
|
+
"""Accession -> (source, semicolon-joined Ensembl gene ids), extended reference.
|
|
127
|
+
|
|
128
|
+
The SURFY list is prediction-based and demonstrably incomplete: the
|
|
129
|
+
rediscovery study measured CA9 at a log2 fold change of 9.58 in clear-cell
|
|
130
|
+
kidney, the largest effect in the whole panel, and could not surface it at any
|
|
131
|
+
expression level because the accession is absent. STEAP1 is in the same
|
|
132
|
+
position. Those are instrument-coverage failures, and the fix is a better
|
|
133
|
+
instrument.
|
|
134
|
+
|
|
135
|
+
The extension adds reviewed human proteins UniProt curates as located at the
|
|
136
|
+
cell membrane — a curated experimental call rather than a second prediction.
|
|
137
|
+
It is additive: SURFY membership is preserved in the ``source`` field, so a
|
|
138
|
+
result can be reported against the original list, the extension, or both, and
|
|
139
|
+
an earlier result stays reproducible.
|
|
140
|
+
|
|
141
|
+
Regenerate with ``scripts/build_surfaceome_extension.py``.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The mapping, or an empty dict if the file is not packaged.
|
|
145
|
+
"""
|
|
146
|
+
text = _read_packaged("surfaceome_extended.tsv")
|
|
147
|
+
if not text:
|
|
148
|
+
LOG.warning("surfaceome_extended.tsv is not packaged; falling back to the SURFY list alone")
|
|
149
|
+
return {}
|
|
150
|
+
out: dict[str, tuple[str, str]] = {}
|
|
151
|
+
for line in text.splitlines():
|
|
152
|
+
line = line.rstrip()
|
|
153
|
+
if not line or line.startswith("#") or line.startswith("accession\t"):
|
|
154
|
+
continue
|
|
155
|
+
parts = line.split("\t")
|
|
156
|
+
if len(parts) < 4:
|
|
157
|
+
continue
|
|
158
|
+
accession, _symbol, genes, source = parts[0], parts[1], parts[2], parts[3]
|
|
159
|
+
if accession:
|
|
160
|
+
out[accession] = (source, genes)
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def load_surfaceome(*, extended: bool = True) -> frozenset[str]:
|
|
165
|
+
"""The surfaceome accessions to filter on.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
extended: include the UniProt cell-membrane extension alongside SURFY.
|
|
169
|
+
``False`` reproduces a SURFY-only run exactly.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The accession set. Falls back to SURFY alone if the extension is absent,
|
|
173
|
+
with a warning, rather than silently narrowing what the tool can see.
|
|
174
|
+
"""
|
|
175
|
+
core = load_surfy(allow_offline_fallback=False)
|
|
176
|
+
if not extended:
|
|
177
|
+
return core
|
|
178
|
+
extension = load_surfaceome_extension()
|
|
179
|
+
if not extension:
|
|
180
|
+
return core
|
|
181
|
+
return frozenset(core | set(extension))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def load_surfaceome_gene_map(*, extended: bool = True) -> dict[str, str]:
|
|
185
|
+
"""Ensembl gene id -> accession, over the chosen surfaceome.
|
|
186
|
+
|
|
187
|
+
Merges the vendored SURFY gene map with the extension's own gene ids, so a
|
|
188
|
+
gene reachable through either reference can be pre-filtered before the
|
|
189
|
+
enrichment cut.
|
|
190
|
+
"""
|
|
191
|
+
mapping = dict(load_surfy_gene_map())
|
|
192
|
+
if not extended:
|
|
193
|
+
return mapping
|
|
194
|
+
for accession, (_source, genes) in load_surfaceome_extension().items():
|
|
195
|
+
for gene in genes.split(";"):
|
|
196
|
+
if gene:
|
|
197
|
+
mapping.setdefault(gene, accession)
|
|
198
|
+
return mapping
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def load_surfy_gene_map() -> dict[str, str]:
|
|
202
|
+
"""Ensembl gene id -> UniProt accession, for the surfaceome.
|
|
203
|
+
|
|
204
|
+
A differential-expression table is keyed by gene id and the surfaceome
|
|
205
|
+
reference is a list of accessions, so without this mapping "the eligible
|
|
206
|
+
surfaceome" can only be approximated by whichever genes a run happened to
|
|
207
|
+
resolve — its enriched subset. A rank taken within that subset is a rank
|
|
208
|
+
within the pipeline's own output rather than within the set it should be
|
|
209
|
+
judged against, which defeats the purpose of a counterfactual rank.
|
|
210
|
+
|
|
211
|
+
Vendored, so no run needs the network. Regenerate with
|
|
212
|
+
``scripts/build_surfy_gene_map.py``.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
The mapping, or an empty dict if the file is not packaged.
|
|
216
|
+
"""
|
|
217
|
+
text = _read_packaged("surfy_v1.ensembl.tsv")
|
|
218
|
+
if not text:
|
|
219
|
+
LOG.warning(
|
|
220
|
+
"surfy_v1.ensembl.tsv is not packaged; the eligible surfaceome cannot be "
|
|
221
|
+
"resolved and any rank taken within it would be misleading"
|
|
222
|
+
)
|
|
223
|
+
return {}
|
|
224
|
+
mapping: dict[str, str] = {}
|
|
225
|
+
for line in text.splitlines():
|
|
226
|
+
line = line.strip()
|
|
227
|
+
if not line or line.startswith("#"):
|
|
228
|
+
continue
|
|
229
|
+
gene, _, accession = line.partition(" ")
|
|
230
|
+
if gene and accession:
|
|
231
|
+
mapping[gene] = accession
|
|
232
|
+
return mapping
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def surfaceome_source() -> str:
|
|
236
|
+
"""Which list ``load_surfy`` would resolve, as a recordable label.
|
|
237
|
+
|
|
238
|
+
The choice between a user-refreshed cache and the vendored list is made
|
|
239
|
+
silently inside ``load_surfy``. Two runs on two machines could therefore use
|
|
240
|
+
different surfaceome lists -- different eligible denominators, different
|
|
241
|
+
counterfactual ranks -- and nothing in either manifest would say so. This
|
|
242
|
+
reports the decision so the discover stage can record it.
|
|
243
|
+
"""
|
|
244
|
+
cache_path = _surfy_cache_path()
|
|
245
|
+
if cache_path.exists() and _parse_accessions(cache_path.read_text(encoding="utf-8")):
|
|
246
|
+
return f"user cache ({cache_path})"
|
|
247
|
+
if load_vendored_surfy() is not None:
|
|
248
|
+
return "vendored"
|
|
249
|
+
return "bundled fallback (ten proteins)"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def load_surfy(*, allow_offline_fallback: bool = True) -> frozenset[str]:
|
|
253
|
+
"""Return the set of UniProt accessions classified as surface by SURFY.
|
|
254
|
+
|
|
255
|
+
Resolution order:
|
|
256
|
+
|
|
257
|
+
1. ``<user_cache>/bindsight/surfy/surfy_v1.uniprot.txt`` — a refreshed cache,
|
|
258
|
+
if the user populated one.
|
|
259
|
+
2. The full vendored list shipped with the package (the normal path).
|
|
260
|
+
3. The ten-protein bundled fallback.
|
|
261
|
+
|
|
262
|
+
``allow_offline_fallback`` gates **step 3 only**. Its purpose is to stop a
|
|
263
|
+
run silently producing near-empty results from a ten-protein list; the
|
|
264
|
+
vendored list is the real SURFY set and does not degrade anything, so it is
|
|
265
|
+
always acceptable.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
allow_offline_fallback: Permit the tiny bundled list when no full list
|
|
269
|
+
is available.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
The set of surface-protein UniProt accessions.
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
FileNotFoundError: No full list is available and the tiny fallback is
|
|
276
|
+
not permitted.
|
|
277
|
+
"""
|
|
278
|
+
cache_path = _surfy_cache_path()
|
|
279
|
+
if cache_path.exists():
|
|
280
|
+
cached = _parse_accessions(cache_path.read_text(encoding="utf-8"))
|
|
281
|
+
if cached:
|
|
282
|
+
return cached
|
|
283
|
+
LOG.warning("SURFY cache at %s is empty; falling back to the vendored list", cache_path)
|
|
284
|
+
|
|
285
|
+
vendored = load_vendored_surfy()
|
|
286
|
+
if vendored is not None:
|
|
287
|
+
LOG.debug("using vendored SURFY list (%d accessions)", len(vendored))
|
|
288
|
+
return vendored
|
|
289
|
+
|
|
290
|
+
if not allow_offline_fallback:
|
|
291
|
+
raise FileNotFoundError(
|
|
292
|
+
f"No full SURFY list available (cache: {cache_path}, vendored: "
|
|
293
|
+
f"{VENDORED_LIST_FILENAME}). Reinstall bindsight, run "
|
|
294
|
+
"scripts/build_surfy_list.py, or set allow_offline_fallback=True "
|
|
295
|
+
"to accept the small bundled set."
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
LOG.warning(
|
|
299
|
+
"Using small bundled offline SURFY fallback (%d proteins) — the full "
|
|
300
|
+
"vendored list is missing from this install. Real discovery runs will "
|
|
301
|
+
"surface almost nothing.",
|
|
302
|
+
len(_bundled_uniprot_set()),
|
|
303
|
+
)
|
|
304
|
+
return _bundled_uniprot_set()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@retry(
|
|
308
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
309
|
+
wait=wait_exponential(multiplier=1, min=2, max=30),
|
|
310
|
+
stop=stop_after_attempt(5),
|
|
311
|
+
reraise=True,
|
|
312
|
+
)
|
|
313
|
+
def _download_xlsx(url: str) -> bytes:
|
|
314
|
+
"""Fetch the SURFY spreadsheet, verifying it really is one.
|
|
315
|
+
|
|
316
|
+
Every other network client in the package retries; this one did not, and it
|
|
317
|
+
also trusted a 200 response blindly. Both hosts now answer 200 with
|
|
318
|
+
something that is not a spreadsheet — an HTML landing page and a Git-LFS
|
|
319
|
+
pointer respectively — so the old code handed those bytes to openpyxl and
|
|
320
|
+
surfaced ``BadZipFile: File is not a zip file``, which tells the user
|
|
321
|
+
nothing about what actually went wrong.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
url: Spreadsheet URL.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
The raw ``.xlsx`` bytes.
|
|
328
|
+
|
|
329
|
+
Raises:
|
|
330
|
+
RuntimeError: The response was not a spreadsheet.
|
|
331
|
+
"""
|
|
332
|
+
resp = requests.get(url, timeout=120)
|
|
333
|
+
resp.raise_for_status()
|
|
334
|
+
content = resp.content
|
|
335
|
+
|
|
336
|
+
# .xlsx is a zip container; anything else is an error page or a pointer.
|
|
337
|
+
if not content.startswith(b"PK"):
|
|
338
|
+
content_type = resp.headers.get("content-type", "unknown")
|
|
339
|
+
if content.startswith(b"version https://git-lfs"):
|
|
340
|
+
detail = "a Git-LFS pointer file, not the tracked content"
|
|
341
|
+
elif b"<html" in content[:512].lower():
|
|
342
|
+
detail = "an HTML page, not a spreadsheet"
|
|
343
|
+
else:
|
|
344
|
+
detail = f"{len(content)} bytes that are not a zip/xlsx container"
|
|
345
|
+
raise RuntimeError(
|
|
346
|
+
f"SURFY download from {url} returned {detail} "
|
|
347
|
+
f"(content-type: {content_type}). Upstream has moved or is serving "
|
|
348
|
+
"a placeholder. bindsight ships the full list already — "
|
|
349
|
+
"load_surfy() does not need this download."
|
|
350
|
+
)
|
|
351
|
+
return content
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def populate_surfy_cache(*, url: str = SURFY_XLSX_URL, force: bool = False) -> Path:
|
|
355
|
+
"""Refresh the on-disk SURFY cache from the upstream spreadsheet.
|
|
356
|
+
|
|
357
|
+
**This is an opt-in refresh path, not a prerequisite.** :func:`load_surfy`
|
|
358
|
+
reads the full vendored list and needs no network; call this only to adopt
|
|
359
|
+
a newer upstream release without upgrading bindsight.
|
|
360
|
+
|
|
361
|
+
As of 2026-07 neither known host serves the spreadsheet (one returns an
|
|
362
|
+
HTML page, the other a Git-LFS pointer), so this will raise a descriptive
|
|
363
|
+
``RuntimeError``. Regenerating the vendored list with
|
|
364
|
+
``scripts/build_surfy_list.py`` is the supported route.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
url: Spreadsheet URL to fetch.
|
|
368
|
+
force: Re-download even when a cache already exists.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Path to the populated cache file.
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
RuntimeError: The download did not yield a spreadsheet, the ``discover``
|
|
375
|
+
extra is missing, or the table parsed to zero surface proteins.
|
|
376
|
+
"""
|
|
377
|
+
cache_path = _surfy_cache_path()
|
|
378
|
+
if cache_path.exists() and not force:
|
|
379
|
+
return cache_path
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
import pandas as pd
|
|
383
|
+
except ImportError as e: # pragma: no cover - import guard
|
|
384
|
+
raise RuntimeError(
|
|
385
|
+
"populate_surfy_cache needs the 'discover' extra (pandas + openpyxl). "
|
|
386
|
+
'Install with: pip install -e ".[discover]"'
|
|
387
|
+
) from e
|
|
388
|
+
|
|
389
|
+
LOG.info("downloading SURFY surfaceome table from %s …", url)
|
|
390
|
+
content = _download_xlsx(url)
|
|
391
|
+
|
|
392
|
+
import io
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
df = pd.read_excel(
|
|
396
|
+
io.BytesIO(content),
|
|
397
|
+
sheet_name="SurfaceomeMasterTable",
|
|
398
|
+
header=1,
|
|
399
|
+
engine="openpyxl",
|
|
400
|
+
)
|
|
401
|
+
except ImportError as e: # pragma: no cover - import guard
|
|
402
|
+
raise RuntimeError(
|
|
403
|
+
"reading the SURFY .xlsx needs openpyxl (in the 'discover' extra). "
|
|
404
|
+
'Install with: pip install -e ".[discover]"'
|
|
405
|
+
) from e
|
|
406
|
+
|
|
407
|
+
label = df["Surfaceome Label"].astype(str).str.strip().str.lower()
|
|
408
|
+
accessions = df.loc[label == "surface", "UniProt accession"].dropna().astype(str).str.strip()
|
|
409
|
+
accs = sorted({a for a in accessions if a})
|
|
410
|
+
if not accs:
|
|
411
|
+
raise RuntimeError("parsed 0 surface proteins from SURFY table; format changed?")
|
|
412
|
+
if len(accs) != SURFY_PROTEIN_COUNT:
|
|
413
|
+
LOG.warning(
|
|
414
|
+
"SURFY surface count %d != expected %d (upstream table may have been revised)",
|
|
415
|
+
len(accs),
|
|
416
|
+
SURFY_PROTEIN_COUNT,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
420
|
+
cache_path.write_text(
|
|
421
|
+
"# SURFY surfaceome (Bausch-Fluck et al., PNAS 2018; no licence stated by\n"
|
|
422
|
+
"# the source, article CC BY-NC-ND 4.0; see LICENSING.md).\n"
|
|
423
|
+
f"# {len(accs)} UniProt accessions labelled 'surface'. Source: {url}\n"
|
|
424
|
+
+ "\n".join(accs)
|
|
425
|
+
+ "\n",
|
|
426
|
+
encoding="utf-8",
|
|
427
|
+
newline="\n",
|
|
428
|
+
)
|
|
429
|
+
LOG.info("wrote %d surface accessions to %s", len(accs), cache_path)
|
|
430
|
+
return cache_path
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def is_surface_protein(uniprot_id: str, *, surfy: frozenset[str] | None = None) -> bool:
|
|
434
|
+
"""Return True if the UniProt accession is in the loaded SURFY set."""
|
|
435
|
+
if surfy is None:
|
|
436
|
+
surfy = load_surfy()
|
|
437
|
+
return uniprot_id in surfy
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _surfy_cache_path() -> Path:
|
|
441
|
+
return cache_dir("surfy") / "surfy_v1.uniprot.txt"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Target evidence: Open Targets enrichment + a bundled ENSG→UniProt fallback."""
|
|
4
|
+
|
|
5
|
+
from bindsight.targets.open_targets import OpenTargetsClient, TargetEvidence
|
|
6
|
+
|
|
7
|
+
__all__ = ["OpenTargetsClient", "TargetEvidence"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Bundled Ensembl gene ID → UniProt accession map for offline / demo runs.
|
|
4
|
+
|
|
5
|
+
Used as a fallback when ``use_open_targets`` is disabled (e.g. the demo) or
|
|
6
|
+
when Open Targets is unreachable. The full pipeline always prefers Open
|
|
7
|
+
Targets' authoritative mapping; this is a small curated set of well-known
|
|
8
|
+
human surface antigens + driver oncoproteins kept in sync with the bundled
|
|
9
|
+
``examples/demo`` and the SURFY offline fallback.
|
|
10
|
+
|
|
11
|
+
Source: UniProt Swiss-Prot canonical IDs as of 2026-05.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
# (gene_id, symbol, uniprot_id)
|
|
17
|
+
_BUNDLED: dict[str, tuple[str, str]] = {
|
|
18
|
+
# ---- Surface antigens (also in the SURFY offline fallback) ----
|
|
19
|
+
"ENSG00000141736": ("ERBB2", "P04626"), # HER2
|
|
20
|
+
"ENSG00000146648": ("EGFR", "P00533"),
|
|
21
|
+
"ENSG00000184697": (
|
|
22
|
+
"CLDN6",
|
|
23
|
+
"P56747",
|
|
24
|
+
), # (was wrongly ENSG00000111799/Q14953 = COL12A1/KIR2DS5)
|
|
25
|
+
"ENSG00000102854": ("MSLN", "Q13421"), # (was wrongly ENSG00000133110 = POSTN)
|
|
26
|
+
"ENSG00000086205": ("FOLH1", "Q04609"), # PSMA — prostate (TCGA-PRAD) target
|
|
27
|
+
"ENSG00000105383": ("CD33", "P20138"), # SIGLEC3 — AML target
|
|
28
|
+
"ENSG00000185291": ("IL3RA", "P26951"), # CD123 — AML target
|
|
29
|
+
"ENSG00000178562": ("CD28", "P10747"),
|
|
30
|
+
"ENSG00000163599": ("CTLA4", "P16410"),
|
|
31
|
+
"ENSG00000120217": ("CD274", "Q9NZQ7"), # PD-L1
|
|
32
|
+
"ENSG00000091831": ("ESR1", "P03372"),
|
|
33
|
+
# ---- Driver oncoproteins (not surface; used in demo for the "stable / down" rows) ----
|
|
34
|
+
"ENSG00000142208": ("AKT1", "P31749"),
|
|
35
|
+
"ENSG00000118260": ("CREB1", "P16220"),
|
|
36
|
+
"ENSG00000074706": ("PTEN", "P60484"),
|
|
37
|
+
"ENSG00000196712": ("NF1", "P21359"),
|
|
38
|
+
"ENSG00000133703": ("KRAS", "P01116"),
|
|
39
|
+
"ENSG00000174775": ("HRAS", "P01112"),
|
|
40
|
+
"ENSG00000129965": ("INS-IGF2", "P01308"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def lookup(gene_id: str) -> tuple[str | None, str | None]:
|
|
45
|
+
"""Return ``(symbol, uniprot_id)`` for a gene_id, or ``(None, None)`` if unknown."""
|
|
46
|
+
if gene_id in _BUNDLED:
|
|
47
|
+
return _BUNDLED[gene_id]
|
|
48
|
+
return (None, None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_known(gene_id: str) -> bool:
|
|
52
|
+
"""Return True if the bundled map has an entry for ``gene_id``."""
|
|
53
|
+
return gene_id in _BUNDLED
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["is_known", "lookup"]
|