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,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"]