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,7 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Protein structure retrieval — AlphaFoldDB (RCSB/PDBe clients are planned)."""
4
+
5
+ from bindsight.structures.alphafolddb import AlphaFoldDBClient
6
+
7
+ __all__ = ["AlphaFoldDBClient"]
@@ -0,0 +1,93 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """AlphaFoldDB structure fetcher.
4
+
5
+ Pulls predicted structures by UniProt ID from EBI's AlphaFold Database.
6
+ All AlphaFoldDB models are CC BY 4.0; we cache them locally to avoid
7
+ hammering the public API.
8
+
9
+ Endpoint pattern (mmCIF):
10
+ https://alphafold.ebi.ac.uk/files/AF-{uniprot_id}-F1-model_v6.cif
11
+
12
+ API docs: https://alphafold.ebi.ac.uk/api-docs
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from pathlib import Path
19
+
20
+ import requests
21
+ from tenacity import (
22
+ retry,
23
+ retry_if_exception_type,
24
+ stop_after_attempt,
25
+ wait_exponential,
26
+ )
27
+
28
+ from bindsight.io.paths import cache_dir
29
+
30
+ LOG = logging.getLogger(__name__)
31
+
32
+ ALPHAFOLDDB_BASE = "https://alphafold.ebi.ac.uk/files"
33
+ # AlphaFoldDB bumps the model version periodically (v4 → v6 as of 2026); the old
34
+ # URLs 404. Track the current release.
35
+ DEFAULT_MODEL_VERSION = "v6"
36
+
37
+
38
+ class AlphaFoldDBClient:
39
+ """Cached AlphaFoldDB mmCIF downloader."""
40
+
41
+ def __init__(
42
+ self,
43
+ cache_subdir: str = "alphafolddb",
44
+ timeout: float = 60.0,
45
+ session: requests.Session | None = None,
46
+ model_version: str = DEFAULT_MODEL_VERSION,
47
+ ) -> None:
48
+ self.cache = cache_dir(cache_subdir)
49
+ self.timeout = timeout
50
+ self.session = session or requests.Session()
51
+ self.session.headers.setdefault(
52
+ "User-Agent", "bindsight/0.0.1 (+https://github.com/mikhaeelatefrizk/bindsight)"
53
+ )
54
+ self.model_version = model_version
55
+
56
+ def _url(self, uniprot_id: str) -> str:
57
+ return f"{ALPHAFOLDDB_BASE}/AF-{uniprot_id}-F1-model_{self.model_version}.cif"
58
+
59
+ def _cache_path(self, uniprot_id: str) -> Path:
60
+ return self.cache / f"AF-{uniprot_id}-F1-model_{self.model_version}.cif"
61
+
62
+ @retry(
63
+ retry=retry_if_exception_type(requests.RequestException),
64
+ wait=wait_exponential(multiplier=1, min=2, max=30),
65
+ stop=stop_after_attempt(5),
66
+ reraise=True,
67
+ )
68
+ def _download(self, url: str) -> bytes:
69
+ resp = self.session.get(url, timeout=self.timeout)
70
+ resp.raise_for_status()
71
+ return resp.content
72
+
73
+ def fetch(self, uniprot_id: str, *, force: bool = False) -> Path | None:
74
+ """Download the AlphaFoldDB mmCIF for a UniProt ID.
75
+
76
+ Returns the path to the cached mmCIF file, or ``None`` if AlphaFoldDB
77
+ has no model for the given UniProt accession (HTTP 404).
78
+ """
79
+ cached = self._cache_path(uniprot_id)
80
+ if cached.exists() and not force:
81
+ return cached
82
+
83
+ url = self._url(uniprot_id)
84
+ try:
85
+ payload = self._download(url)
86
+ except requests.HTTPError as e:
87
+ if e.response is not None and e.response.status_code == 404:
88
+ LOG.warning("AlphaFoldDB has no model for %s", uniprot_id)
89
+ return None
90
+ raise
91
+
92
+ cached.write_bytes(payload)
93
+ return cached
@@ -0,0 +1,92 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Per-residue confidence (pLDDT) from AlphaFold models.
4
+
5
+ AlphaFold stores its per-residue confidence estimate (pLDDT, 0–100) in the
6
+ B-factor column of every atom. pLDDT doubles as a practical disorder signal:
7
+ regions below ~50 are very likely intrinsically disordered, and 50–70 is low
8
+ confidence. Designing a binder against a disordered or low-confidence region
9
+ wastes GPU time, so bindsight reads pLDDT straight from the cached AlphaFoldDB
10
+ mmCIF and uses it to flag/avoid those regions.
11
+
12
+ Pure parsing on top of Biopython's ``MMCIF2Dict`` — no network, no heavy
13
+ structure objects. All functions degrade gracefully (return ``None``/empty) on a
14
+ missing or unparseable file so discovery never crashes on a bad model.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from pathlib import Path
21
+
22
+ LOG = logging.getLogger(__name__)
23
+
24
+
25
+ def _as_list(value: object) -> list[str]:
26
+ """``MMCIF2Dict`` returns a scalar for single-row loops and a list otherwise."""
27
+ if value is None:
28
+ return []
29
+ if isinstance(value, list):
30
+ return [str(v) for v in value]
31
+ return [str(value)]
32
+
33
+
34
+ def per_residue_plddt(cif_path: Path | str | None) -> dict[int, float]:
35
+ """Map residue number → pLDDT for an AlphaFold mmCIF.
36
+
37
+ Uses the representative (Cα) atom's B-factor per residue. Returns an empty
38
+ dict if the file is missing or cannot be parsed.
39
+ """
40
+ if not cif_path:
41
+ return {}
42
+ p = Path(cif_path)
43
+ if not p.exists():
44
+ return {}
45
+ try:
46
+ from Bio.PDB.MMCIF2Dict import MMCIF2Dict
47
+
48
+ d = MMCIF2Dict(str(p))
49
+ except Exception as e: # malformed file, missing biopython, etc.
50
+ LOG.warning("pLDDT parse failed for %s: %s", p, e)
51
+ return {}
52
+
53
+ atom_ids = _as_list(d.get("_atom_site.label_atom_id"))
54
+ seq_ids = _as_list(d.get("_atom_site.label_seq_id"))
55
+ bfactors = _as_list(d.get("_atom_site.B_iso_or_equiv"))
56
+ if not (atom_ids and seq_ids and bfactors):
57
+ return {}
58
+
59
+ out: dict[int, float] = {}
60
+ for atom, seq, bfac in zip(atom_ids, seq_ids, bfactors, strict=False):
61
+ if atom != "CA":
62
+ continue
63
+ try:
64
+ out[int(seq)] = float(bfac)
65
+ except (ValueError, TypeError):
66
+ continue
67
+ return out
68
+
69
+
70
+ def mean_plddt(cif_path: Path | str | None) -> float | None:
71
+ """Mean per-residue pLDDT over the whole model, or ``None`` if unavailable."""
72
+ vals = per_residue_plddt(cif_path)
73
+ if not vals:
74
+ return None
75
+ return sum(vals.values()) / len(vals)
76
+
77
+
78
+ def region_plddt(cif_path: Path | str | None, residues: list[int]) -> float | None:
79
+ """Mean pLDDT over a specific set of residue numbers (e.g. an epitope).
80
+
81
+ Falls back to the whole-model mean when ``residues`` is empty (the
82
+ whole-surface design case). Returns ``None`` if no pLDDT is available.
83
+ """
84
+ vals = per_residue_plddt(cif_path)
85
+ if not vals:
86
+ return None
87
+ if not residues:
88
+ return sum(vals.values()) / len(vals)
89
+ picked = [vals[r] for r in residues if r in vals]
90
+ if not picked:
91
+ return None
92
+ return sum(picked) / len(picked)
@@ -0,0 +1,227 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Membrane topology from UniProt (extracellular-domain awareness).
4
+
5
+ An antibody or a de novo mini-binder can only reach the **extracellular** part of
6
+ a cell-surface protein. Designing against the whole chain — which usually includes
7
+ a transmembrane helix and a cytoplasmic tail — wastes effort on regions a binder
8
+ can never touch. UniProt curates per-residue membrane topology; this module pulls
9
+ it and exposes the extracellular ranges so discovery can target the ECD.
10
+
11
+ Endpoint (JSON):
12
+ https://rest.uniprot.org/uniprotkb/{acc}.json?fields=ft_transmem,ft_topo_dom,ft_signal
13
+
14
+ Cached on disk like the AlphaFoldDB client. Degrades gracefully: a network/parse
15
+ failure or an un-annotated entry returns ``None`` so discovery falls back to
16
+ whole-surface design rather than crashing.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import requests
28
+ from tenacity import (
29
+ retry,
30
+ retry_if_exception_type,
31
+ stop_after_attempt,
32
+ wait_exponential,
33
+ )
34
+
35
+ from bindsight.io.paths import cache_dir
36
+
37
+ LOG = logging.getLogger(__name__)
38
+
39
+ UNIPROT_BASE = "https://rest.uniprot.org/uniprotkb"
40
+ _FIELDS = "ft_transmem,ft_topo_dom,ft_signal"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Topology:
45
+ """Parsed membrane topology for one UniProt accession (1-based residue ranges)."""
46
+
47
+ uniprot_id: str
48
+ extracellular_ranges: tuple[tuple[int, int], ...]
49
+ transmembrane_ranges: tuple[tuple[int, int], ...]
50
+ signal_peptide: tuple[int, int] | None
51
+ #: Omega-site residue of a GPI anchor, when UniProt annotates one.
52
+ #:
53
+ #: A GPI-anchored protein is tethered to the outer leaflet by a lipid rather
54
+ #: than by a transmembrane helix, so it has no transmembrane segment — and
55
+ #: UniProt annotates topological domains *relative to* transmembrane
56
+ #: segments, so it has none of those either. Reading extracellular extent
57
+ #: from topological domains alone therefore classified an entire class of
58
+ #: cell-surface protein as having nothing a binder can reach. MSLN
59
+ #: (Q13421) is the clearest case: no Transmembrane feature, no Topological
60
+ #: domain, one Lipidation reading "GPI-anchor amidated serine" — and it is
61
+ #: the target of multiple clinical antibody-drug conjugates and CAR-T
62
+ #: programmes. CEACAM5, FOLR1 and CD59 have the same shape.
63
+ gpi_anchor: int | None = None
64
+
65
+ @property
66
+ def is_gpi_anchored(self) -> bool:
67
+ """True if UniProt annotates a GPI anchor and no transmembrane segment."""
68
+ return self.gpi_anchor is not None and not self.transmembrane_ranges
69
+
70
+ @property
71
+ def has_extracellular(self) -> bool:
72
+ """True if any part of the chain is annotated as reachable from outside.
73
+
74
+ A GPI-anchored protein counts: its whole mature chain sits outside the
75
+ membrane. This returned ``False`` for every one of them, and with
76
+ ``require_extracellular_domain`` enabled that dropped them from design
77
+ carry-forward as "not antibody-accessible" — the opposite of the truth
78
+ for some of the best-validated antibody targets there are.
79
+ """
80
+ return bool(self.extracellular_ranges)
81
+
82
+ def extracellular_residues(self) -> set[int]:
83
+ """All residue numbers inside extracellular topological domains."""
84
+ out: set[int] = set()
85
+ for s, e in self.extracellular_ranges:
86
+ out.update(range(s, e + 1))
87
+ return out
88
+
89
+ def fraction_extracellular(self, residues: list[int]) -> float | None:
90
+ """Fraction of ``residues`` that fall in an extracellular domain.
91
+
92
+ ``None`` if ``residues`` is empty; ``0.0`` if the protein has no annotated
93
+ extracellular domain (nothing a binder can reach).
94
+ """
95
+ if not residues:
96
+ return None
97
+ ecd = self.extracellular_residues()
98
+ if not ecd:
99
+ return 0.0
100
+ return sum(1 for r in residues if r in ecd) / len(residues)
101
+
102
+
103
+ def parse_topology(uniprot_id: str, data: dict[str, Any]) -> Topology:
104
+ """Parse a UniProt entry JSON into a :class:`Topology` (pure; no I/O)."""
105
+ extracellular: list[tuple[int, int]] = []
106
+ transmembrane: list[tuple[int, int]] = []
107
+ signal: tuple[int, int] | None = None
108
+ gpi: int | None = None
109
+ propeptide: tuple[int, int] | None = None
110
+ for feat in data.get("features", []):
111
+ loc = feat.get("location", {})
112
+ start = loc.get("start", {}).get("value")
113
+ end = loc.get("end", {}).get("value")
114
+ if start is None or end is None:
115
+ continue # uncertain/fuzzy location — skip
116
+ rng = (int(start), int(end))
117
+ ftype = feat.get("type")
118
+ desc = (feat.get("description") or "").strip().lower()
119
+ if ftype == "Topological domain" and desc.startswith("extracellular"):
120
+ extracellular.append(rng)
121
+ elif ftype == "Transmembrane":
122
+ transmembrane.append(rng)
123
+ elif ftype == "Signal" and signal is None:
124
+ signal = rng
125
+ elif ftype == "Lipidation" and "gpi-anchor" in desc and gpi is None:
126
+ # The omega site: the residue the glycolipid is amidated onto.
127
+ gpi = rng[0]
128
+ elif ftype == "Propeptide" and propeptide is None:
129
+ propeptide = rng
130
+
131
+ if gpi is not None and not transmembrane and not extracellular:
132
+ # A GPI-anchored protein has no transmembrane helix, so UniProt gives it
133
+ # no topological domains either — the annotation is defined relative to
134
+ # a membrane-spanning segment. Its mature chain nonetheless sits wholly
135
+ # outside the cell, which is why MSLN and CEACAM5 are antibody targets.
136
+ #
137
+ # The reachable region is the mature chain: everything after the signal
138
+ # peptide, up to and including the omega site. Residues beyond it are
139
+ # the propeptide UniProt marks "Removed in mature form" — they are gone
140
+ # before the protein reaches the surface, so designing against them
141
+ # would target a sequence that does not exist on a cell. For MSLN that
142
+ # is 37-598 of 622: signal 1-36, omega site 598, propeptide 599-622.
143
+ start = (signal[1] + 1) if signal else 1
144
+ end = gpi
145
+ if propeptide is not None and propeptide[0] <= gpi:
146
+ # An omega site inside the cleaved region would make the mature
147
+ # chain end before it begins; trust the propeptide boundary.
148
+ end = propeptide[0] - 1
149
+ if end >= start:
150
+ extracellular.append((start, end))
151
+
152
+ return Topology(
153
+ uniprot_id=uniprot_id,
154
+ extracellular_ranges=tuple(extracellular),
155
+ transmembrane_ranges=tuple(transmembrane),
156
+ signal_peptide=signal,
157
+ gpi_anchor=gpi,
158
+ )
159
+
160
+
161
+ class UniProtTopologyClient:
162
+ """Cached UniProt membrane-topology fetcher."""
163
+
164
+ def __init__(
165
+ self,
166
+ cache_subdir: str = "uniprot_topology",
167
+ timeout: float = 60.0,
168
+ session: requests.Session | None = None,
169
+ ) -> None:
170
+ self.cache = cache_dir(cache_subdir)
171
+ self.timeout = timeout
172
+ self.session = session or requests.Session()
173
+ self.session.headers.setdefault(
174
+ "User-Agent", "bindsight/0.0.1 (+https://github.com/mikhaeelatefrizk/bindsight)"
175
+ )
176
+
177
+ def _url(self, acc: str) -> str:
178
+ return f"{UNIPROT_BASE}/{acc}.json?fields={_FIELDS}"
179
+
180
+ def _cache_path(self, acc: str) -> Path:
181
+ return self.cache / f"{acc}.topology.json"
182
+
183
+ @retry(
184
+ retry=retry_if_exception_type(requests.RequestException),
185
+ wait=wait_exponential(multiplier=1, min=2, max=30),
186
+ stop=stop_after_attempt(5),
187
+ reraise=True,
188
+ )
189
+ def _download(self, url: str) -> bytes:
190
+ resp = self.session.get(url, timeout=self.timeout)
191
+ resp.raise_for_status()
192
+ return resp.content
193
+
194
+ def fetch(self, uniprot_id: str, *, force: bool = False) -> Topology | None:
195
+ """Fetch + parse topology for a UniProt accession.
196
+
197
+ Returns ``None`` on a 404, a network failure, or unparseable JSON, so the
198
+ caller falls back to whole-surface design.
199
+ """
200
+ if not uniprot_id:
201
+ return None
202
+ cached = self._cache_path(uniprot_id)
203
+ if cached.exists() and not force:
204
+ try:
205
+ return parse_topology(uniprot_id, json.loads(cached.read_text(encoding="utf-8")))
206
+ except Exception as e:
207
+ LOG.warning("cached topology unreadable for %s (%s); refetching", uniprot_id, e)
208
+
209
+ try:
210
+ payload = self._download(self._url(uniprot_id))
211
+ except requests.HTTPError as e:
212
+ if e.response is not None and e.response.status_code == 404:
213
+ LOG.warning("UniProt has no entry for %s", uniprot_id)
214
+ return None
215
+ LOG.warning("UniProt topology fetch failed for %s: %s", uniprot_id, e)
216
+ return None
217
+ except requests.RequestException as e:
218
+ LOG.warning("UniProt topology fetch failed for %s: %s", uniprot_id, e)
219
+ return None
220
+
221
+ try:
222
+ data = json.loads(payload)
223
+ except json.JSONDecodeError as e:
224
+ LOG.warning("UniProt returned non-JSON for %s: %s", uniprot_id, e)
225
+ return None
226
+ cached.write_bytes(payload)
227
+ return parse_topology(uniprot_id, data)
@@ -0,0 +1,42 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Surfaceome membership — is this protein on the cell surface?
4
+
5
+ Two sources, unioned: the SURFY machine-learning surfaceome (2,886
6
+ accessions) and a UniProt cell-membrane extension built from the curated
7
+ SL-0039 annotation (1,915 further accessions, 4,801 in total). The extension
8
+ exists because SURFY omits CA9 and STEAP1, both established antigens, which
9
+ made them unreachable at any expression level.
10
+
11
+ Those three figures are checked against the lists themselves by
12
+ ``tests/test_docs_claims.py``; they appear in several documents, and a number
13
+ restated in five places with nothing comparing it to the data is a number that
14
+ drifts.
15
+
16
+ Targetable-site lookup is a different question and lives in
17
+ ``bindsight.epitopes``; this package answers membership only.
18
+ """
19
+
20
+ from bindsight.surfaceome.surfy import (
21
+ SURFY_PROTEIN_COUNT,
22
+ is_surface_protein,
23
+ load_surfaceome,
24
+ load_surfaceome_extension,
25
+ load_surfaceome_gene_map,
26
+ load_surfy,
27
+ load_surfy_gene_map,
28
+ populate_surfy_cache,
29
+ surfaceome_source,
30
+ )
31
+
32
+ __all__ = [
33
+ "SURFY_PROTEIN_COUNT",
34
+ "is_surface_protein",
35
+ "load_surfaceome",
36
+ "load_surfaceome_extension",
37
+ "load_surfaceome_gene_map",
38
+ "load_surfy",
39
+ "load_surfy_gene_map",
40
+ "populate_surfy_cache",
41
+ "surfaceome_source",
42
+ ]