structpick 0.1.0__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.
structpick/__init__.py ADDED
@@ -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"
structpick/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from structpick.cli import main
2
+
3
+ raise SystemExit(main())
structpick/api.py ADDED
@@ -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}"
structpick/cli.py ADDED
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import csv
5
+ import json
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from dataclasses import asdict, replace
9
+ from pathlib import Path
10
+ from typing import TextIO
11
+
12
+ import httpx
13
+
14
+ from structpick import __version__
15
+ from structpick.api import Client, TargetNotFoundError, entry_url
16
+ from structpick.models import AlphaFoldModel, Structure, Target
17
+ from structpick.select import Filters, select_structures
18
+
19
+ COLUMNS = ["rank", "pdb_id", "chain", "method", "resolution", "coverage", "residues", "ligands"]
20
+
21
+
22
+ def _row(rank: int, s: Structure) -> dict[str, str]:
23
+ return {
24
+ "rank": str(rank),
25
+ "pdb_id": s.pdb_id,
26
+ "chain": s.chain,
27
+ "method": s.method,
28
+ "resolution": f"{s.resolution:.2f}" if s.resolution is not None else "",
29
+ "coverage": f"{s.coverage:.0%}",
30
+ "residues": f"{s.unp_start}-{s.unp_end}",
31
+ "ligands": ",".join(sorted({lig.code for lig in s.bound_ligands})),
32
+ }
33
+
34
+
35
+ def _print_table(
36
+ target: Target, rows: list[dict[str, str]], af: AlphaFoldModel | None, out: TextIO
37
+ ) -> None:
38
+ gene = f" ({target.gene})" if target.gene else ""
39
+ print(
40
+ f"{target.accession}{gene}: {target.name} | {target.organism} | {target.length} aa",
41
+ file=out,
42
+ )
43
+ if rows:
44
+ widths = {c: max(len(c), *(len(r[c]) for r in rows)) for c in COLUMNS}
45
+ print(" ".join(c.ljust(widths[c]) for c in COLUMNS).rstrip(), file=out)
46
+ for r in rows:
47
+ print(" ".join(r[c].ljust(widths[c]) for c in COLUMNS).rstrip(), file=out)
48
+ else:
49
+ print("No experimental structure matches the filters.", file=out)
50
+ if af is not None:
51
+ print(
52
+ f"AlphaFold: AF-{af.accession}-F1 v{af.version}, mean pLDDT {af.mean_plddt:.1f}, "
53
+ f"{af.fraction_very_high:.0%} of residues very high confidence",
54
+ file=out,
55
+ )
56
+
57
+
58
+ def _filters(args: argparse.Namespace) -> Filters:
59
+ return Filters(
60
+ max_resolution=args.max_resolution,
61
+ min_coverage=args.min_coverage,
62
+ method=args.method,
63
+ ligand=args.ligand,
64
+ require_ligand=args.holo,
65
+ )
66
+
67
+
68
+ def rank(
69
+ client: Client, accession: str, filters: Filters, top: int | None = None
70
+ ) -> list[Structure]:
71
+ """Filter on the cheap fields first, then look up ligands only for what's left."""
72
+ ligand_free = replace(filters, ligand=None, require_ligand=False)
73
+ ranked = select_structures(client.structures(accession), ligand_free)
74
+ if filters.ligand or filters.require_ligand:
75
+ ranked = select_structures(client.with_ligands(ranked), filters)
76
+ return ranked[:top]
77
+ return client.with_ligands(ranked[:top])
78
+
79
+
80
+ def cmd_list(client: Client, args: argparse.Namespace, out: TextIO) -> int:
81
+ target = client.target(args.accession)
82
+ ranked = rank(client, target.accession, _filters(args), top=args.top)
83
+ af = client.alphafold(target.accession)
84
+ rows = [_row(i, s) for i, s in enumerate(ranked, 1)]
85
+
86
+ if args.format == "json":
87
+ payload = {
88
+ "target": asdict(target),
89
+ "structures": [
90
+ {**asdict(s), "bound_ligands": [lig.code for lig in s.bound_ligands]}
91
+ for s in ranked
92
+ ],
93
+ "alphafold": asdict(af) if af else None,
94
+ }
95
+ json.dump(payload, out, indent=2)
96
+ out.write("\n")
97
+ elif args.format == "csv":
98
+ writer = csv.DictWriter(out, fieldnames=COLUMNS, lineterminator="\n")
99
+ writer.writeheader()
100
+ writer.writerows(rows)
101
+ else:
102
+ _print_table(target, rows, af, out)
103
+ return 0
104
+
105
+
106
+ def cmd_fetch(client: Client, args: argparse.Namespace, out: TextIO) -> int:
107
+ target = client.target(args.accession)
108
+ ranked = rank(client, target.accession, _filters(args), top=1)
109
+ args.output.mkdir(parents=True, exist_ok=True)
110
+
111
+ if ranked and not args.alphafold:
112
+ best = ranked[0]
113
+ path = args.output / f"{best.pdb_id}.cif"
114
+ path.write_bytes(client.download(entry_url(best.pdb_id)))
115
+ print(f"{path} (PDB {best.pdb_id} chain {best.chain}, {best.method})", file=out)
116
+ return 0
117
+
118
+ af = client.alphafold(target.accession)
119
+ if af is None:
120
+ print(f"No structure or AlphaFold model found for {target.accession}.", file=sys.stderr)
121
+ return 1
122
+ path = args.output / f"AF-{af.accession}-F1-model_v{af.version}.cif"
123
+ path.write_bytes(client.download(af.cif_url))
124
+ print(f"{path} (AlphaFold, mean pLDDT {af.mean_plddt:.1f})", file=out)
125
+ return 0
126
+
127
+
128
+ def build_parser() -> argparse.ArgumentParser:
129
+ parser = argparse.ArgumentParser(
130
+ prog="structpick",
131
+ description=(
132
+ "Rank PDB structures of a UniProt target for docking, with AlphaFold as fallback."
133
+ ),
134
+ )
135
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
136
+ sub = parser.add_subparsers(dest="command", required=True)
137
+
138
+ common = argparse.ArgumentParser(add_help=False)
139
+ common.add_argument("accession", help="UniProt accession, e.g. P69905")
140
+ common.add_argument(
141
+ "--max-resolution", type=float, metavar="Å", help="drop entries worse than this"
142
+ )
143
+ common.add_argument("--min-coverage", type=float, default=0.0, metavar="FRACTION",
144
+ help="minimum fraction of the UniProt sequence covered (0-1)") # fmt: skip
145
+ common.add_argument("--method", help="xray, em, nmr (or the full PDBe method name)")
146
+ common.add_argument(
147
+ "--ligand", metavar="CODE", help="only entries with this ligand bound, e.g. HEM"
148
+ )
149
+ common.add_argument(
150
+ "--holo", action="store_true", help="only entries with any non-additive ligand"
151
+ )
152
+
153
+ p_list = sub.add_parser("list", parents=[common], help="show ranked structures")
154
+ p_list.add_argument("--top", type=int, default=10, help="how many to show (default 10)")
155
+ p_list.add_argument("--format", choices=["table", "csv", "json"], default="table")
156
+ p_list.set_defaults(func=cmd_list)
157
+
158
+ p_fetch = sub.add_parser("fetch", parents=[common], help="download the best structure as mmCIF")
159
+ p_fetch.add_argument("-o", "--output", type=Path, default=Path("."), help="output directory")
160
+ p_fetch.add_argument(
161
+ "--alphafold", action="store_true", help="download the AlphaFold model instead"
162
+ )
163
+ p_fetch.set_defaults(func=cmd_fetch)
164
+ return parser
165
+
166
+
167
+ def main(
168
+ argv: Sequence[str] | None = None, client: Client | None = None, out: TextIO | None = None
169
+ ) -> int:
170
+ args = build_parser().parse_args(argv)
171
+ out = out or sys.stdout
172
+ try:
173
+ if client is not None:
174
+ return int(args.func(client, args, out))
175
+ with Client() as owned:
176
+ return int(args.func(owned, args, out))
177
+ except TargetNotFoundError as exc:
178
+ print(f"structpick: {exc}", file=sys.stderr)
179
+ return 2
180
+ except httpx.HTTPError as exc:
181
+ print(f"structpick: request failed: {exc}", file=sys.stderr)
182
+ return 3
183
+
184
+
185
+ if __name__ == "__main__":
186
+ raise SystemExit(main())
structpick/models.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ # Things that show up in crystal structures but aren't what you'd dock against:
6
+ # solvents, cryoprotectants, buffers, common ions and detergents.
7
+ ADDITIVES = frozenset(
8
+ {
9
+ # solvents / cryoprotectants
10
+ "HOH", "DOD", "GOL", "EDO", "PEG", "PGE", "PG4", "1PE", "P6G", "MPD", "DMS", "IPA",
11
+ "EOH", "MOH", "ACT", "ACE", "FMT", "BME", "DTT", "TRS", "MES", "EPE", "CIT", "FLC",
12
+ "TAR", "MLI", "IMD", "BTB", "NHE", "CAPS", "PO4", "SO4", "NO3", "SCN", "AZI",
13
+ # ions
14
+ "NA", "K", "LI", "CL", "BR", "IOD", "F", "MG", "CA", "MN", "ZN", "FE", "FE2", "CO",
15
+ "NI", "CU", "CU1", "CD", "HG", "SR", "BA", "CS", "RB", "NH4", "UNX",
16
+ # detergents
17
+ "BOG", "LDA", "SDS", "LMT", "DDM", "OLC",
18
+ }
19
+ ) # fmt: skip
20
+
21
+
22
+ def is_additive(code: str) -> bool:
23
+ return code.upper() in ADDITIVES
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Ligand:
28
+ code: str
29
+ name: str
30
+ chain: str
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Structure:
35
+ pdb_id: str
36
+ chain: str
37
+ method: str
38
+ resolution: float | None
39
+ coverage: float
40
+ unp_start: int
41
+ unp_end: int
42
+ ligands: tuple[Ligand, ...] = field(default=())
43
+
44
+ @property
45
+ def bound_ligands(self) -> tuple[Ligand, ...]:
46
+ """Ligands in this chain that aren't crystallisation additives or ions."""
47
+ return tuple(
48
+ lig for lig in self.ligands if lig.chain == self.chain and not is_additive(lig.code)
49
+ )
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class AlphaFoldModel:
54
+ accession: str
55
+ version: int
56
+ mean_plddt: float
57
+ fraction_very_high: float
58
+ start: int
59
+ end: int
60
+ cif_url: str
61
+ pdb_url: str
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Target:
66
+ accession: str
67
+ name: str
68
+ gene: str | None
69
+ organism: str
70
+ length: int
structpick/py.typed ADDED
File without changes
structpick/select.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass
5
+
6
+ from structpick.models import ADDITIVES, Structure, is_additive
7
+
8
+ __all__ = ["ADDITIVES", "Filters", "is_additive", "rank_key", "select_structures"]
9
+
10
+
11
+ METHOD_ALIASES = {
12
+ "xray": "x-ray diffraction",
13
+ "em": "electron microscopy",
14
+ "nmr": "solution nmr",
15
+ }
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Filters:
20
+ max_resolution: float | None = None
21
+ min_coverage: float = 0.0
22
+ method: str | None = None
23
+ ligand: str | None = None
24
+ require_ligand: bool = False
25
+
26
+
27
+ def _method_matches(structure: Structure, method: str) -> bool:
28
+ wanted = METHOD_ALIASES.get(method.lower(), method.lower())
29
+ return structure.method.lower() == wanted
30
+
31
+
32
+ def _keep(structure: Structure, filters: Filters) -> bool:
33
+ if filters.method and not _method_matches(structure, filters.method):
34
+ return False
35
+ if structure.coverage < filters.min_coverage:
36
+ return False
37
+ if filters.max_resolution is not None and (
38
+ structure.resolution is None or structure.resolution > filters.max_resolution
39
+ ):
40
+ return False
41
+ bound = {lig.code.upper() for lig in structure.bound_ligands}
42
+ if filters.require_ligand and not bound:
43
+ return False
44
+ return not (filters.ligand and filters.ligand.upper() not in bound)
45
+
46
+
47
+ def rank_key(structure: Structure) -> tuple[bool, float, float, str, str]:
48
+ """Coverage first (in 10% steps, so 99% and 100% tie), then resolution.
49
+
50
+ Entries without a resolution (NMR) go last.
51
+ """
52
+ no_resolution = structure.resolution is None
53
+ resolution = structure.resolution if structure.resolution is not None else 0.0
54
+ return (
55
+ no_resolution,
56
+ -round(structure.coverage, 1),
57
+ resolution,
58
+ structure.pdb_id,
59
+ structure.chain,
60
+ )
61
+
62
+
63
+ def select_structures(
64
+ structures: Iterable[Structure], filters: Filters | None = None
65
+ ) -> list[Structure]:
66
+ filters = filters or Filters()
67
+ return sorted((s for s in structures if _keep(s, filters)), key=rank_key)
@@ -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,12 @@
1
+ structpick/__init__.py,sha256=v9iN15CHLe4zOvCMSxvOSldZfvoLZSvCI_POZ-iB73I,388
2
+ structpick/__main__.py,sha256=wpqwM22i9cQhHCXX4PqAXu3e7-5FzPX07QCemFSrKvQ,58
3
+ structpick/api.py,sha256=CmCHc0mhQSIu5Q8co1TjQ73W0AJ6W59RPbgugxVozUM,5785
4
+ structpick/cli.py,sha256=-Aq7wm8p0WKApYQm6hYL_GSxMCAhDm6_svk7CaoOzps,7012
5
+ structpick/models.py,sha256=ccpE0wbtpyWCL4WK9T00JbclXnv08MBtUD5kRlOx65Q,1825
6
+ structpick/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ structpick/select.py,sha256=RP90VD7aeSQCXUz_mO8MEmZbVItFyQdTIkLc4y7JLgw,2067
8
+ structpick-0.1.0.dist-info/METADATA,sha256=CXGTVhuQl--BikPr33FQUUJlpIyIF54lqx_3SKNGPEA,6083
9
+ structpick-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ structpick-0.1.0.dist-info/entry_points.txt,sha256=nCFdZiYLQ8WFJOqHAXbv-cEKfMI7RHhC5JjCsmuImLU,51
11
+ structpick-0.1.0.dist-info/licenses/LICENSE,sha256=J95RrHtdu6mePrURHCupySJNkJQDz0r6FCNeEMJ1F-U,1081
12
+ structpick-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ structpick = structpick.cli:main
@@ -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.