structpick 0.1.0__tar.gz

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.
@@ -0,0 +1,30 @@
1
+ name: publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: astral-sh/setup-uv@v6
13
+ - run: uv build
14
+ - uses: actions/upload-artifact@v4
15
+ with:
16
+ name: dist
17
+ path: dist/
18
+
19
+ pypi:
20
+ needs: build
21
+ runs-on: ubuntu-latest
22
+ environment: pypi
23
+ permissions:
24
+ id-token: write # trusted publishing, no API token stored in the repo
25
+ steps:
26
+ - uses: actions/download-artifact@v4
27
+ with:
28
+ name: dist
29
+ path: dist/
30
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,40 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v6
14
+ - run: uv sync
15
+ - run: uv run ruff format --check .
16
+ - run: uv run ruff check .
17
+ - run: uv run mypy
18
+
19
+ test:
20
+ runs-on: ubuntu-latest
21
+ strategy:
22
+ matrix:
23
+ python: ["3.10", "3.11", "3.12", "3.13"]
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: astral-sh/setup-uv@v6
27
+ with:
28
+ python-version: ${{ matrix.python }}
29
+ - run: uv sync
30
+ - run: uv run pytest --cov=structpick --cov-report=term-missing
31
+
32
+ live:
33
+ # The real APIs, once per run. Allowed to fail: EBI being down shouldn't block a PR.
34
+ runs-on: ubuntu-latest
35
+ continue-on-error: true
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+ - uses: astral-sh/setup-uv@v6
39
+ - run: uv sync
40
+ - run: uv run pytest -m network
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .coverage
6
+ coverage.*
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luís Felipe de Oliveira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.5
2
+ Name: structpick
3
+ Version: 0.1.0
4
+ Summary: Pick the best protein structure for docking: ranks PDB entries for a UniProt target, flags bound ligands and falls back to AlphaFold.
5
+ Project-URL: Homepage, https://github.com/felipsoliveira/structpick
6
+ Project-URL: Issues, https://github.com/felipsoliveira/structpick/issues
7
+ Author: Luís Felipe de Oliveira
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: alphafold,bioinformatics,docking,drug-discovery,pdb,structural-biology,uniprot
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx>=0.27
24
+ Description-Content-Type: text/markdown
25
+
26
+ # structpick
27
+
28
+ [![tests](https://github.com/felipsoliveira/structpick/actions/workflows/tests.yml/badge.svg)](https://github.com/felipsoliveira/structpick/actions/workflows/tests.yml)
29
+ [![PyPI](https://img.shields.io/pypi/v/structpick)](https://pypi.org/project/structpick/)
30
+ [![Python](https://img.shields.io/pypi/pyversions/structpick)](https://pypi.org/project/structpick/)
31
+
32
+ Pick a protein structure to dock against.
33
+
34
+ Given a UniProt accession, `structpick` lists the experimental structures from the PDB ranked by sequence coverage and resolution, shows which ones have a real ligand bound (ignoring glycerol, sulfate, ions and other crystallisation leftovers) and reports the AlphaFold model as a fallback. It's the first step of most structure-based drug design projects, and usually it means clicking through a dozen PDB pages.
35
+
36
+ ```console
37
+ $ structpick list Q27743 --holo --max-resolution 2.0 --top 5
38
+ Q27743: L-lactate dehydrogenase | Plasmodium falciparum (isolate CDC / Honduras) | 316 aa
39
+ rank pdb_id chain method resolution coverage residues ligands
40
+ 1 4plz A X-ray diffraction 1.05 100% 1-316 NAI,OXM
41
+ 2 1t2d A X-ray diffraction 1.10 100% 1-316 NAD,OXL
42
+ 3 2a94 A X-ray diffraction 1.50 100% 2-316 AP0
43
+ 4 1t24 A X-ray diffraction 1.70 100% 1-316 NAD,OXQ
44
+ 5 1u4o A X-ray diffraction 1.70 100% 2-316 NDD
45
+ AlphaFold: AF-Q27743-F1 v6, mean pLDDT 97.4, 98% of residues very high confidence
46
+ ```
47
+
48
+ Looking for a structure of EGFR with erlotinib (`AQ4`) bound:
49
+
50
+ ```console
51
+ $ structpick list P00533 --method xray --ligand AQ4 --top 3
52
+ P00533 (EGFR): Epidermal growth factor receptor | Homo sapiens | 1210 aa
53
+ rank pdb_id chain method resolution coverage residues ligands
54
+ 1 1m17 A X-ray diffraction 2.60 28% 695-1022 AQ4
55
+ 2 4hjo A X-ray diffraction 2.75 28% 696-1022 AQ4
56
+ AlphaFold: AF-P00533-F1 v6, mean pLDDT 75.9, 47% of residues very high confidence
57
+ ```
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ pip install structpick
63
+ ```
64
+
65
+ Python 3.10 or newer. The only dependency is [httpx](https://www.python-httpx.org/).
66
+
67
+ ## Usage
68
+
69
+ ```bash
70
+ structpick list <accession> [filters] [--top N] [--format table|csv|json]
71
+ structpick fetch <accession> [filters] [-o DIR] [--alphafold]
72
+ ```
73
+
74
+ | Option | Meaning |
75
+ |---|---|
76
+ | `--max-resolution 2.0` | drop entries worse than 2.0 Å (also drops NMR, which has no resolution) |
77
+ | `--min-coverage 0.8` | keep chains covering at least 80% of the UniProt sequence |
78
+ | `--method xray` | `xray`, `em`, `nmr` or the full PDBe method name |
79
+ | `--ligand HEM` | only chains with this ligand bound |
80
+ | `--holo` | only chains with any ligand that isn't an additive or ion |
81
+
82
+ `fetch` downloads the best match as mmCIF from RCSB. If nothing matches the filters, or with `--alphafold`, it downloads the AlphaFold model instead.
83
+
84
+ `--format csv` and `--format json` are meant for scripts and notebooks:
85
+
86
+ ```bash
87
+ structpick list P00533 --holo --format csv > egfr_structures.csv
88
+ ```
89
+
90
+ ### From Python
91
+
92
+ ```python
93
+ from structpick import Filters, select_structures
94
+ from structpick.api import Client
95
+
96
+ with Client() as client:
97
+ structures = client.with_ligands(client.structures("P00533"))
98
+ best = select_structures(structures, Filters(max_resolution=2.5, require_ligand=True))
99
+ for s in best[:3]:
100
+ print(s.pdb_id, s.chain, s.resolution, [lig.code for lig in s.bound_ligands])
101
+ ```
102
+
103
+ ## How ranking works
104
+
105
+ 1. **Coverage**, compared in 10% steps, so a chain covering 99% and one covering 100% count as equal.
106
+ 2. **Resolution**, best first. Entries without a resolution (NMR) go last.
107
+
108
+ Ligands only count if they sit on the same chain and aren't in the [additive list](src/structpick/models.py) (water, glycerol, ethylene glycol, PEG, sulfate, phosphate, common buffers, ions, detergents). Metal ions are on that list too, so check catalytic metals yourself when they matter.
109
+
110
+ ## Data sources
111
+
112
+ - [UniProt REST API](https://rest.uniprot.org) for the target name, gene and length
113
+ - [PDBe best structures](https://www.ebi.ac.uk/pdbe/api/doc/sifts.html) (SIFTS mappings) for chain coverage, method and resolution
114
+ - [PDBe ligand monomers](https://www.ebi.ac.uk/pdbe/api/doc/pdb.html) for bound ligands
115
+ - [AlphaFold Protein Structure Database](https://alphafold.ebi.ac.uk/api-docs) for predicted models
116
+
117
+ Requests time out after 30 s and are retried with exponential backoff on 429 and 5xx responses. Ligands are only looked up for the chains left after the other filters, one request per PDB entry.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ git clone https://github.com/felipsoliveira/structpick
123
+ cd structpick
124
+ uv sync # or: pip install -e . pytest pytest-cov ruff mypy
125
+ uv run pytest # offline, uses recorded API responses in tests/fixtures
126
+ uv run pytest -m network # hits the real APIs
127
+ uv run ruff check . && uv run mypy
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,107 @@
1
+ # structpick
2
+
3
+ [![tests](https://github.com/felipsoliveira/structpick/actions/workflows/tests.yml/badge.svg)](https://github.com/felipsoliveira/structpick/actions/workflows/tests.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/structpick)](https://pypi.org/project/structpick/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/structpick)](https://pypi.org/project/structpick/)
6
+
7
+ Pick a protein structure to dock against.
8
+
9
+ Given a UniProt accession, `structpick` lists the experimental structures from the PDB ranked by sequence coverage and resolution, shows which ones have a real ligand bound (ignoring glycerol, sulfate, ions and other crystallisation leftovers) and reports the AlphaFold model as a fallback. It's the first step of most structure-based drug design projects, and usually it means clicking through a dozen PDB pages.
10
+
11
+ ```console
12
+ $ structpick list Q27743 --holo --max-resolution 2.0 --top 5
13
+ Q27743: L-lactate dehydrogenase | Plasmodium falciparum (isolate CDC / Honduras) | 316 aa
14
+ rank pdb_id chain method resolution coverage residues ligands
15
+ 1 4plz A X-ray diffraction 1.05 100% 1-316 NAI,OXM
16
+ 2 1t2d A X-ray diffraction 1.10 100% 1-316 NAD,OXL
17
+ 3 2a94 A X-ray diffraction 1.50 100% 2-316 AP0
18
+ 4 1t24 A X-ray diffraction 1.70 100% 1-316 NAD,OXQ
19
+ 5 1u4o A X-ray diffraction 1.70 100% 2-316 NDD
20
+ AlphaFold: AF-Q27743-F1 v6, mean pLDDT 97.4, 98% of residues very high confidence
21
+ ```
22
+
23
+ Looking for a structure of EGFR with erlotinib (`AQ4`) bound:
24
+
25
+ ```console
26
+ $ structpick list P00533 --method xray --ligand AQ4 --top 3
27
+ P00533 (EGFR): Epidermal growth factor receptor | Homo sapiens | 1210 aa
28
+ rank pdb_id chain method resolution coverage residues ligands
29
+ 1 1m17 A X-ray diffraction 2.60 28% 695-1022 AQ4
30
+ 2 4hjo A X-ray diffraction 2.75 28% 696-1022 AQ4
31
+ AlphaFold: AF-P00533-F1 v6, mean pLDDT 75.9, 47% of residues very high confidence
32
+ ```
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install structpick
38
+ ```
39
+
40
+ Python 3.10 or newer. The only dependency is [httpx](https://www.python-httpx.org/).
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ structpick list <accession> [filters] [--top N] [--format table|csv|json]
46
+ structpick fetch <accession> [filters] [-o DIR] [--alphafold]
47
+ ```
48
+
49
+ | Option | Meaning |
50
+ |---|---|
51
+ | `--max-resolution 2.0` | drop entries worse than 2.0 Å (also drops NMR, which has no resolution) |
52
+ | `--min-coverage 0.8` | keep chains covering at least 80% of the UniProt sequence |
53
+ | `--method xray` | `xray`, `em`, `nmr` or the full PDBe method name |
54
+ | `--ligand HEM` | only chains with this ligand bound |
55
+ | `--holo` | only chains with any ligand that isn't an additive or ion |
56
+
57
+ `fetch` downloads the best match as mmCIF from RCSB. If nothing matches the filters, or with `--alphafold`, it downloads the AlphaFold model instead.
58
+
59
+ `--format csv` and `--format json` are meant for scripts and notebooks:
60
+
61
+ ```bash
62
+ structpick list P00533 --holo --format csv > egfr_structures.csv
63
+ ```
64
+
65
+ ### From Python
66
+
67
+ ```python
68
+ from structpick import Filters, select_structures
69
+ from structpick.api import Client
70
+
71
+ with Client() as client:
72
+ structures = client.with_ligands(client.structures("P00533"))
73
+ best = select_structures(structures, Filters(max_resolution=2.5, require_ligand=True))
74
+ for s in best[:3]:
75
+ print(s.pdb_id, s.chain, s.resolution, [lig.code for lig in s.bound_ligands])
76
+ ```
77
+
78
+ ## How ranking works
79
+
80
+ 1. **Coverage**, compared in 10% steps, so a chain covering 99% and one covering 100% count as equal.
81
+ 2. **Resolution**, best first. Entries without a resolution (NMR) go last.
82
+
83
+ Ligands only count if they sit on the same chain and aren't in the [additive list](src/structpick/models.py) (water, glycerol, ethylene glycol, PEG, sulfate, phosphate, common buffers, ions, detergents). Metal ions are on that list too, so check catalytic metals yourself when they matter.
84
+
85
+ ## Data sources
86
+
87
+ - [UniProt REST API](https://rest.uniprot.org) for the target name, gene and length
88
+ - [PDBe best structures](https://www.ebi.ac.uk/pdbe/api/doc/sifts.html) (SIFTS mappings) for chain coverage, method and resolution
89
+ - [PDBe ligand monomers](https://www.ebi.ac.uk/pdbe/api/doc/pdb.html) for bound ligands
90
+ - [AlphaFold Protein Structure Database](https://alphafold.ebi.ac.uk/api-docs) for predicted models
91
+
92
+ Requests time out after 30 s and are retried with exponential backoff on 429 and 5xx responses. Ligands are only looked up for the chains left after the other filters, one request per PDB entry.
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ git clone https://github.com/felipsoliveira/structpick
98
+ cd structpick
99
+ uv sync # or: pip install -e . pytest pytest-cov ruff mypy
100
+ uv run pytest # offline, uses recorded API responses in tests/fixtures
101
+ uv run pytest -m network # hits the real APIs
102
+ uv run ruff check . && uv run mypy
103
+ ```
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "structpick"
7
+ version = "0.1.0"
8
+ description = "Pick the best protein structure for docking: ranks PDB entries for a UniProt target, flags bound ligands and falls back to AlphaFold."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Luís Felipe de Oliveira" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = ["httpx>=0.27"]
15
+ keywords = ["bioinformatics", "structural-biology", "docking", "pdb", "alphafold", "uniprot", "drug-discovery"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Science/Research",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
27
+ "Typing :: Typed",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/felipsoliveira/structpick"
32
+ Issues = "https://github.com/felipsoliveira/structpick/issues"
33
+
34
+ [project.scripts]
35
+ structpick = "structpick.cli:main"
36
+
37
+ [dependency-groups]
38
+ dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "mypy>=1.11"]
39
+
40
+ [tool.ruff]
41
+ line-length = 100
42
+ target-version = "py310"
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
46
+
47
+ [tool.mypy]
48
+ strict = true
49
+ files = ["src", "tests"]
50
+
51
+ [[tool.mypy.overrides]]
52
+ module = "tests.*"
53
+ disallow_untyped_defs = false
54
+ disallow_untyped_calls = false
55
+ disallow_incomplete_defs = false
56
+
57
+ [tool.pytest.ini_options]
58
+ addopts = "-m 'not network'"
59
+ markers = ["network: hits the real EBI/UniProt APIs (deselected by default)"]
@@ -0,0 +1,15 @@
1
+ """Rank experimental and predicted structures of a protein target for docking."""
2
+
3
+ from structpick.models import AlphaFoldModel, Ligand, Structure, Target, is_additive
4
+ from structpick.select import Filters, select_structures
5
+
6
+ __all__ = [
7
+ "AlphaFoldModel",
8
+ "Filters",
9
+ "Ligand",
10
+ "Structure",
11
+ "Target",
12
+ "is_additive",
13
+ "select_structures",
14
+ ]
15
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from structpick.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import replace
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from structpick import __version__
10
+ from structpick.models import AlphaFoldModel, Ligand, Structure, Target
11
+
12
+ PDBE = "https://www.ebi.ac.uk/pdbe/api"
13
+ ALPHAFOLD = "https://alphafold.ebi.ac.uk/api"
14
+ UNIPROT = "https://rest.uniprot.org"
15
+
16
+ RETRY_STATUS = {429, 500, 502, 503, 504}
17
+
18
+
19
+ class TargetNotFoundError(LookupError):
20
+ pass
21
+
22
+
23
+ class Client:
24
+ """Small wrapper around the UniProt, PDBe and AlphaFold DB REST APIs."""
25
+
26
+ def __init__(
27
+ self,
28
+ http: httpx.Client | None = None,
29
+ retries: int = 3,
30
+ backoff: float = 1.0,
31
+ ) -> None:
32
+ self._http = http or httpx.Client(
33
+ timeout=httpx.Timeout(30.0),
34
+ headers={"User-Agent": f"structpick/{__version__}"},
35
+ follow_redirects=True,
36
+ )
37
+ self._retries = retries
38
+ self._backoff = backoff
39
+
40
+ def __enter__(self) -> Client:
41
+ return self
42
+
43
+ def __exit__(self, *exc: object) -> None:
44
+ self.close()
45
+
46
+ def close(self) -> None:
47
+ self._http.close()
48
+
49
+ def _get(
50
+ self,
51
+ url: str,
52
+ params: dict[str, str] | None = None,
53
+ missing: frozenset[int] = frozenset({404}),
54
+ ) -> Any | None:
55
+ """GET JSON. Returns None for `missing` statuses; retries rate limits and server errors."""
56
+ for attempt in range(self._retries + 1):
57
+ try:
58
+ response = self._http.get(url, params=params)
59
+ except httpx.TransportError:
60
+ if attempt == self._retries:
61
+ raise
62
+ else:
63
+ if response.status_code in missing:
64
+ return None
65
+ if response.status_code not in RETRY_STATUS or attempt == self._retries:
66
+ response.raise_for_status()
67
+ return response.json()
68
+ time.sleep(self._backoff * 2**attempt)
69
+ raise AssertionError("unreachable")
70
+
71
+ def target(self, accession: str) -> Target:
72
+ data = self._get(
73
+ f"{UNIPROT}/uniprotkb/{accession}",
74
+ params={
75
+ "fields": "accession,protein_name,gene_names,organism_name,length",
76
+ "format": "json",
77
+ },
78
+ # UniProt answers 400 for strings that aren't valid accessions.
79
+ missing=frozenset({400, 404}),
80
+ )
81
+ if data is None:
82
+ raise TargetNotFoundError(f"UniProt has no entry {accession!r}")
83
+ description: dict[str, Any] = data.get("proteinDescription", {})
84
+ submissions: list[dict[str, Any]] = description.get("submissionNames", [])
85
+ name = (
86
+ description.get("recommendedName", {}).get("fullName", {}).get("value")
87
+ or (submissions[0].get("fullName", {}).get("value") if submissions else None)
88
+ or accession
89
+ )
90
+ genes = data.get("genes") or []
91
+ gene = genes[0].get("geneName", {}).get("value") if genes else None
92
+ return Target(
93
+ accession=data["primaryAccession"],
94
+ name=name,
95
+ gene=gene,
96
+ organism=data.get("organism", {}).get("scientificName", ""),
97
+ length=data.get("sequence", {}).get("length", 0),
98
+ )
99
+
100
+ def structures(self, accession: str) -> list[Structure]:
101
+ data = self._get(f"{PDBE}/mappings/best_structures/{accession}")
102
+ if not data:
103
+ return []
104
+ rows: list[dict[str, Any]] = data.get(accession) or next(iter(data.values()), [])
105
+ return [
106
+ Structure(
107
+ pdb_id=row["pdb_id"],
108
+ chain=row["chain_id"],
109
+ method=row.get("experimental_method", ""),
110
+ resolution=row.get("resolution"),
111
+ coverage=float(row.get("coverage", 0.0)),
112
+ unp_start=row.get("unp_start", 0),
113
+ unp_end=row.get("unp_end", 0),
114
+ )
115
+ for row in rows
116
+ ]
117
+
118
+ def ligands(self, pdb_id: str) -> tuple[Ligand, ...]:
119
+ data = self._get(f"{PDBE}/pdb/entry/ligand_monomers/{pdb_id}")
120
+ if not data:
121
+ return ()
122
+ return tuple(
123
+ Ligand(
124
+ code=row["chem_comp_id"], name=row.get("chem_comp_name", ""), chain=row["chain_id"]
125
+ )
126
+ for row in data.get(pdb_id, [])
127
+ )
128
+
129
+ def with_ligands(self, structures: list[Structure]) -> list[Structure]:
130
+ """Attach ligands, fetching each PDB entry once."""
131
+ cache: dict[str, tuple[Ligand, ...]] = {}
132
+ result = []
133
+ for structure in structures:
134
+ if structure.pdb_id not in cache:
135
+ cache[structure.pdb_id] = self.ligands(structure.pdb_id)
136
+ result.append(replace(structure, ligands=cache[structure.pdb_id]))
137
+ return result
138
+
139
+ def alphafold(self, accession: str) -> AlphaFoldModel | None:
140
+ data = self._get(f"{ALPHAFOLD}/prediction/{accession}")
141
+ if not data:
142
+ return None
143
+ row = next((r for r in data if r.get("uniprotAccession") == accession), data[0])
144
+ return AlphaFoldModel(
145
+ accession=row["uniprotAccession"],
146
+ version=row.get("latestVersion", 0),
147
+ mean_plddt=row.get("globalMetricValue", 0.0),
148
+ fraction_very_high=row.get("fractionPlddtVeryHigh", 0.0),
149
+ start=row.get("sequenceStart", 0),
150
+ end=row.get("sequenceEnd", 0),
151
+ cif_url=row["cifUrl"],
152
+ pdb_url=row["pdbUrl"],
153
+ )
154
+
155
+ def download(self, url: str) -> bytes:
156
+ response = self._http.get(url)
157
+ response.raise_for_status()
158
+ return response.content
159
+
160
+
161
+ def entry_url(pdb_id: str, fmt: str = "cif") -> str:
162
+ return f"https://files.rcsb.org/download/{pdb_id.upper()}.{fmt}"