scigantic-bindingdb 0.2.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.
@@ -0,0 +1,41 @@
1
+ """Query BindingDB directly from a public S3 mirror. No download, no local database."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _version
4
+
5
+ from .cache import cache_dir, disable_cache, enable_cache, is_cache_enabled
6
+ from .cache import resolve as cache_resolve
7
+ from .chembl_bridge import chembl_bridge
8
+ from .connection import connect, query
9
+ from .dti_pairs import dti_pairs
10
+ from .measurements import measurements
11
+ from .releases import (
12
+ ReleaseCapabilityError,
13
+ ReleaseInfo,
14
+ UnknownReleaseError,
15
+ latest,
16
+ releases,
17
+ )
18
+
19
+ try:
20
+ __version__ = _version("scigantic-bindingdb")
21
+ except PackageNotFoundError:
22
+ # Running from a source checkout with no install (editable or not).
23
+ __version__ = "0.0.0"
24
+
25
+ __all__ = [
26
+ "chembl_bridge",
27
+ "connect",
28
+ "query",
29
+ "measurements",
30
+ "dti_pairs",
31
+ "releases",
32
+ "latest",
33
+ "enable_cache",
34
+ "disable_cache",
35
+ "is_cache_enabled",
36
+ "cache_dir",
37
+ "cache_resolve",
38
+ "ReleaseInfo",
39
+ "ReleaseCapabilityError",
40
+ "UnknownReleaseError",
41
+ ]
@@ -0,0 +1,8 @@
1
+ """Enables `python -m scigantic_bindingdb`, same commands as the `scigantic-bindingdb` console script."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,7 @@
1
+ """Shared constants: where the mirror lives."""
2
+
3
+ BUCKET = "scigantic-bindingdb"
4
+ REGION = "us-east-1"
5
+
6
+ # The scigantic-chembl mirror this package's chembl_bridge() cross-references.
7
+ CHEMBL_BUCKET = "scigantic-chembl"
@@ -0,0 +1,113 @@
1
+ """Optional local caching: download once, then work with no network.
2
+
3
+ Off by default. This package's whole pitch is zero setup, so caching stays
4
+ opt-in rather than something that changes the default behavior:
5
+
6
+ import scigantic_bindingdb as bindingdb
7
+ bindingdb.enable_cache()
8
+
9
+ chembl_bridge() then reads its one derived file from a local cache directory
10
+ instead of S3, downloading it the first time it's needed and reusing it
11
+ after that.
12
+
13
+ connect() / query() / measurements() deliberately do NOT use this:
14
+ connect() registers five core tables as views on every call, so caching
15
+ them there would mean any call eagerly downloads everything regardless of
16
+ what the query actually touches. Cache a specific table yourself if you
17
+ want it locally: cache_resolve("<release>/parquet/<table>.parquet")
18
+ downloads it and returns the local path, usable directly in
19
+ read_parquet(...).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import sys
26
+ import urllib.request
27
+ from pathlib import Path
28
+
29
+ from ._constants import BUCKET, REGION
30
+
31
+ _enabled = False
32
+ _cache_dir: Path | None = None
33
+
34
+ _CHUNK_BYTES = 1024 * 1024
35
+
36
+
37
+ def _default_cache_dir() -> Path:
38
+ if sys.platform == "win32":
39
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
40
+ elif sys.platform == "darwin":
41
+ base = str(Path.home() / "Library" / "Caches")
42
+ else:
43
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
44
+ return Path(base) / "scigantic-bindingdb"
45
+
46
+
47
+ def enable_cache(cache_dir: str | None = None) -> Path:
48
+ """Turn on local caching for every function in this package that uses it.
49
+
50
+ Cache location: `cache_dir` if given, else the
51
+ SCIGANTIC_BINDINGDB_CACHE environment variable, else a
52
+ platform-appropriate user cache directory. Returns the resolved
53
+ directory.
54
+ """
55
+ global _enabled, _cache_dir
56
+ if cache_dir is not None:
57
+ resolved = Path(cache_dir)
58
+ elif os.environ.get("SCIGANTIC_BINDINGDB_CACHE"):
59
+ resolved = Path(os.environ["SCIGANTIC_BINDINGDB_CACHE"])
60
+ else:
61
+ resolved = _default_cache_dir()
62
+ resolved.mkdir(parents=True, exist_ok=True)
63
+ _cache_dir = resolved
64
+ _enabled = True
65
+ return resolved
66
+
67
+
68
+ def disable_cache() -> None:
69
+ """Turn caching back off. Later calls go straight to S3 again.
70
+
71
+ Anything already downloaded stays on disk; this only stops using it.
72
+ """
73
+ global _enabled
74
+ _enabled = False
75
+
76
+
77
+ def is_cache_enabled() -> bool:
78
+ return _enabled
79
+
80
+
81
+ def cache_dir() -> Path | None:
82
+ """The resolved cache directory, or None if caching has never been enabled."""
83
+ return _cache_dir
84
+
85
+
86
+ def resolve(key: str) -> str:
87
+ """An S3 URL, or a local cached file path if caching is on.
88
+
89
+ `key` is a path relative to the bucket root, e.g.
90
+ "202608/derived/bindingdb_chembl_bridge.parquet". Downloads to the
91
+ cache on first access; later calls for the same key reuse the local
92
+ file without touching the network.
93
+ """
94
+ if not _enabled:
95
+ return f"s3://{BUCKET}/{key}"
96
+
97
+ assert _cache_dir is not None
98
+ local_path = _cache_dir / key
99
+ if local_path.exists():
100
+ return str(local_path)
101
+
102
+ local_path.parent.mkdir(parents=True, exist_ok=True)
103
+ url = f"https://{BUCKET}.s3.{REGION}.amazonaws.com/{key}"
104
+ # Download to a sibling temp file and rename into place atomically, so a
105
+ # download killed partway through never leaves a file that looks cached
106
+ # but isn't.
107
+ tmp_path = local_path.with_name(local_path.name + ".part")
108
+ print(f"scigantic-bindingdb: caching {key} ...", file=sys.stderr, flush=True)
109
+ with urllib.request.urlopen(url) as response, open(tmp_path, "wb") as fh:
110
+ while chunk := response.read(_CHUNK_BYTES):
111
+ fh.write(chunk)
112
+ os.replace(tmp_path, local_path)
113
+ return str(local_path)
@@ -0,0 +1,87 @@
1
+ """Cross-reference BindingDB measurements to ChEMBL, through the precomputed
2
+ bridge table rather than re-deriving the join yourself.
3
+
4
+ BindingDB ingests ChEMBL as one of its own curated source feeds (51.3% of
5
+ measurements in the 202608 release), and ChEMBL separately absorbs some
6
+ BindingDB patent-derived bioactivity data, so the two archives are not
7
+ independent corpora. derived/bindingdb_chembl_bridge.parquet, built once at
8
+ mirror time, joins measurements to s3://scigantic-chembl by BindingDB's own
9
+ chembl_id column where present (authoritative) and falls back to an exact
10
+ InChIKey match where it's missing. This module wraps that file, and
11
+ optionally reaches into the live scigantic-chembl mirror over the same
12
+ DuckDB connection for compound/target names: a read-only cross-bucket SQL
13
+ join at query time, not a mount-level dependency between the two archives.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import TYPE_CHECKING
19
+
20
+ from ._constants import CHEMBL_BUCKET
21
+ from .cache import resolve as _resolve
22
+ from .connection import connect
23
+ from .releases import _require, latest
24
+
25
+ if TYPE_CHECKING:
26
+ import pandas as pd
27
+
28
+
29
+ def chembl_bridge(
30
+ release: str | None = None,
31
+ chembl_release: str = "chembl_37",
32
+ reactant_set_id: str | None = None,
33
+ with_names: bool = True,
34
+ limit: int | None = None,
35
+ ) -> "pd.DataFrame":
36
+ """BindingDB measurements joined to their ChEMBL cross-reference.
37
+
38
+ with_names (default True) also pulls in compound_chembl_id's pref_name
39
+ from s3://scigantic-chembl/<chembl_release>, a second public bucket read
40
+ over the same connection. Set False to skip that join and get just the
41
+ bridge table's own columns (reactant_set_id, chembl_molregno, chembl_id,
42
+ match_method) plus the measurement's SMILES.
43
+
44
+ release defaults to the manifest's current latest(). Only that release
45
+ is guaranteed to carry the bridge table; call releases() to check.
46
+ """
47
+ release = release or latest()
48
+ _require(release, "chembl_bridge")
49
+ con = connect(release)
50
+ try:
51
+ bridge_path = _resolve(f"{release}/derived/bindingdb_chembl_bridge.parquet")
52
+ con.execute(
53
+ "CREATE OR REPLACE VIEW chembl_bridge AS "
54
+ f"SELECT * FROM read_parquet('{bridge_path}')"
55
+ )
56
+
57
+ where: list[str] = []
58
+ params: list[str | int] = []
59
+ if reactant_set_id is not None:
60
+ where.append("b.reactant_set_id = ?")
61
+ params.append(reactant_set_id)
62
+ clause = f"WHERE {' AND '.join(where)}" if where else ""
63
+
64
+ if with_names:
65
+ mol_dict = f"s3://{CHEMBL_BUCKET}/{chembl_release}/parquet/molecule_dictionary.parquet"
66
+ sql = f"""
67
+ SELECT b.reactant_set_id, b.chembl_molregno, b.chembl_id, b.match_method,
68
+ m.ligand_smiles, d.pref_name AS chembl_pref_name
69
+ FROM chembl_bridge b
70
+ JOIN measurements m ON m.reactant_set_id = b.reactant_set_id
71
+ LEFT JOIN read_parquet('{mol_dict}') d ON d.molregno = b.chembl_molregno
72
+ {clause}
73
+ """
74
+ else:
75
+ sql = f"""
76
+ SELECT b.reactant_set_id, b.chembl_molregno, b.chembl_id, b.match_method,
77
+ m.ligand_smiles
78
+ FROM chembl_bridge b
79
+ JOIN measurements m ON m.reactant_set_id = b.reactant_set_id
80
+ {clause}
81
+ """
82
+ if limit is not None:
83
+ sql += " LIMIT ?"
84
+ params.append(int(limit))
85
+ return con.execute(sql, params).df()
86
+ finally:
87
+ con.close()
@@ -0,0 +1,55 @@
1
+ """Command-line interface: `scigantic-bindingdb info` and `scigantic-bindingdb query`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import Sequence, cast
8
+
9
+ from .connection import query as run_query
10
+ from .releases import releases
11
+
12
+
13
+ def _cmd_info(_args: argparse.Namespace) -> int:
14
+ for info in releases():
15
+ derived = [
16
+ name
17
+ for name, present in (
18
+ ("chembl_bridge", info.chembl_bridge),
19
+ ("dti_pairs", info.dti_pairs),
20
+ )
21
+ if present
22
+ ]
23
+ suffix = f" + {', '.join(derived)}" if derived else " (raw tables only)"
24
+ print(f"{info.release}{suffix}")
25
+ return 0
26
+
27
+
28
+ def _cmd_query(args: argparse.Namespace) -> int:
29
+ df = run_query(args.sql, release=args.release)
30
+ print(df.to_csv(sep="\t", index=False), end="")
31
+ return 0
32
+
33
+
34
+ def main(argv: Sequence[str] | None = None) -> int:
35
+ parser = argparse.ArgumentParser(prog="scigantic-bindingdb")
36
+ subparsers = parser.add_subparsers(dest="command", required=True)
37
+
38
+ info_parser = subparsers.add_parser(
39
+ "info", help="list mirrored releases and what each one supports"
40
+ )
41
+ info_parser.set_defaults(func=_cmd_info)
42
+
43
+ query_parser = subparsers.add_parser(
44
+ "query", help="run SQL against a release and print tab-separated output"
45
+ )
46
+ query_parser.add_argument("sql")
47
+ query_parser.add_argument("--release", default=None, help="defaults to the current release")
48
+ query_parser.set_defaults(func=_cmd_query)
49
+
50
+ args = parser.parse_args(argv)
51
+ return cast(int, args.func(args))
52
+
53
+
54
+ if __name__ == "__main__":
55
+ sys.exit(main())
@@ -0,0 +1,84 @@
1
+ """DuckDB connection helpers. Queries run against the public S3 mirror over
2
+ httpfs, no download.
3
+
4
+ This module deliberately does not participate in enable_cache() (see
5
+ cache.py): connect() registers all five core tables as views on every call,
6
+ the largest over 200 MB, so caching them here would mean any call to
7
+ connect() or query() eagerly downloads everything regardless of what the
8
+ query actually touches. Caching applies to chembl_bridge() instead, which
9
+ needs exactly one known file.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING
15
+
16
+ from ._constants import BUCKET, REGION
17
+ from .releases import _validate_release, latest
18
+
19
+ if TYPE_CHECKING:
20
+ import duckdb
21
+ import pandas as pd
22
+
23
+ # Registered as views on connect() so plain SQL can reference a table by
24
+ # name instead of a full read_parquet() path.
25
+ _CORE_TABLES = (
26
+ "measurements",
27
+ "target_chains",
28
+ "target_chain_names",
29
+ "assays",
30
+ "id_mappings",
31
+ )
32
+
33
+
34
+ def connect(release: str | None = None) -> "duckdb.DuckDBPyConnection":
35
+ """Open a DuckDB connection against s3://scigantic-bindingdb.
36
+
37
+ `SELECT * FROM measurements` works directly; any other table under
38
+ `<release>/parquet/` is reachable with
39
+ `read_parquet('s3://scigantic-bindingdb/<release>/parquet/<table>.parquet')`.
40
+
41
+ release defaults to whatever the live manifest currently calls latest(),
42
+ resolved at call time rather than import time.
43
+ """
44
+ import duckdb
45
+
46
+ release = release or latest()
47
+ _validate_release(release)
48
+
49
+ con = duckdb.connect()
50
+ # DuckDB auto-shows an ASCII progress bar for queries it estimates will
51
+ # take a while, on stdout, regardless of whether that's a real terminal.
52
+ # Surprising output for a library call in a notebook or script, so it's
53
+ # off here by default.
54
+ con.execute("SET enable_progress_bar=false")
55
+ con.execute("INSTALL httpfs")
56
+ con.execute("LOAD httpfs")
57
+ con.execute(f"SET s3_region='{REGION}'")
58
+ # The mirror is public-read. Without this, DuckDB looks for AWS
59
+ # credentials and fails on a machine that has none configured.
60
+ con.execute(
61
+ "CREATE OR REPLACE SECRET scigantic_bindingdb "
62
+ "(TYPE s3, PROVIDER config, KEY_ID '', SECRET '')"
63
+ )
64
+
65
+ base = f"s3://{BUCKET}/{release}/parquet"
66
+ for table in _CORE_TABLES:
67
+ con.execute(
68
+ f"CREATE OR REPLACE VIEW {table} AS "
69
+ f"SELECT * FROM read_parquet('{base}/{table}.parquet')"
70
+ )
71
+ return con
72
+
73
+
74
+ def query(sql: str, release: str | None = None) -> "pd.DataFrame":
75
+ """Run SQL against a release and return a pandas DataFrame.
76
+
77
+ Opens a new connection per call. For several queries against the same
78
+ release, call connect() once and reuse it instead.
79
+ """
80
+ con = connect(release)
81
+ try:
82
+ return con.execute(sql).df()
83
+ finally:
84
+ con.close()
@@ -0,0 +1,74 @@
1
+ """(Ligand, target, affinity) triples, ready for a drug-target-interaction
2
+ or proteochemometric model to train on.
3
+
4
+ BindingDB is the dataset most DTI tooling (DeepPurpose and similar) is built
5
+ around, specifically because it ships a full protein sequence alongside
6
+ every affinity measurement, something ChEMBL's bioactivity tables don't
7
+ do as directly. This wraps derived/dti_pairs.parquet, the pre-filtered,
8
+ pre-transformed version of that pairing: exact measurements only (a
9
+ censored ">"/"<" bound is never a usable regression label), chain 1's
10
+ sequence required, and p_affinity already computed as -log10(affinity_nm *
11
+ 1e-9), the same transform as ChEMBL's pchembl_value.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Literal
17
+
18
+ from .cache import resolve as _resolve
19
+ from .connection import connect
20
+ from .releases import _require, latest
21
+
22
+ if TYPE_CHECKING:
23
+ import pandas as pd
24
+
25
+ Endpoint = Literal["ki", "ic50", "kd", "ec50"]
26
+
27
+
28
+ def dti_pairs(
29
+ release: str | None = None,
30
+ endpoint: Endpoint | None = None,
31
+ uniprot_id: str | None = None,
32
+ single_chain_only: bool = False,
33
+ limit: int | None = None,
34
+ ) -> "pd.DataFrame":
35
+ """Ligand/target/affinity triples for DTI or proteochemometric training.
36
+
37
+ endpoint filters to one of 'ki', 'ic50', 'kd', 'ec50'; omit to get all
38
+ four (a row's `endpoint` column says which).
39
+
40
+ single_chain_only=True drops rows whose target came from a complex with
41
+ more than one declared chain (n_chains_declared > 1). Those rows still
42
+ represent the interaction with chain 1's sequence only, which is fine
43
+ for most uses, but exclude them if single-chain purity matters for your
44
+ model.
45
+
46
+ release defaults to the manifest's current latest(). Only that release
47
+ is guaranteed to carry this file; call releases() to check.
48
+ """
49
+ release = release or latest()
50
+ _require(release, "dti_pairs")
51
+ con = connect(release)
52
+ try:
53
+ path = _resolve(f"{release}/derived/dti_pairs.parquet")
54
+ con.execute(f"CREATE OR REPLACE VIEW dti_pairs AS SELECT * FROM read_parquet('{path}')")
55
+
56
+ where: list[str] = []
57
+ params: list[str | int] = []
58
+ if endpoint is not None:
59
+ where.append("endpoint = ?")
60
+ params.append(endpoint)
61
+ if uniprot_id is not None:
62
+ where.append("uniprot_id = ?")
63
+ params.append(uniprot_id)
64
+ if single_chain_only:
65
+ where.append("n_chains_declared = 1")
66
+ clause = f"WHERE {' AND '.join(where)}" if where else ""
67
+
68
+ sql = f"SELECT * FROM dti_pairs {clause} ORDER BY p_affinity DESC"
69
+ if limit is not None:
70
+ sql += " LIMIT ?"
71
+ params.append(int(limit))
72
+ return con.execute(sql, params).df()
73
+ finally:
74
+ con.close()
@@ -0,0 +1,79 @@
1
+ """Binding measurements, filtered the way that avoids BindingDB's two
2
+ sharpest edges: censored affinity values, and target joins that fan out on
3
+ multichain complexes.
4
+
5
+ Ki/IC50/Kd/EC50 are occasionally reported as ">X" or "<X" rather than an
6
+ exact value (an assay hit its ceiling or floor). exact_only=True, the
7
+ default, keeps only rows where that endpoint's qualifier is '=', so a
8
+ censored bound is never silently treated as a real affinity.
9
+
10
+ Filtering by target uses EXISTS against target_chains rather than a JOIN,
11
+ so a target whose UniProt ID appears on more than one chain of the same
12
+ complex still returns each measurement once, not once per matching chain.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING, Literal
18
+
19
+ from .connection import connect
20
+ from .releases import latest
21
+
22
+ if TYPE_CHECKING:
23
+ import pandas as pd
24
+
25
+ Endpoint = Literal["ki", "ic50", "kd", "ec50"]
26
+ _ENDPOINTS: tuple[Endpoint, ...] = ("ki", "ic50", "kd", "ec50")
27
+
28
+
29
+ def measurements(
30
+ release: str | None = None,
31
+ uniprot_id: str | None = None,
32
+ endpoint: Endpoint = "ki",
33
+ exact_only: bool = True,
34
+ limit: int | None = None,
35
+ ) -> "pd.DataFrame":
36
+ """Binding measurements for one endpoint, most potent first.
37
+
38
+ uniprot_id matches either UniProt (SwissProt or TrEMBL) primary ID on
39
+ any chain of the target. A multichain complex needs the accession on
40
+ only one chain to match, and matches once regardless of how many chains
41
+ it appears on.
42
+
43
+ exact_only (default True) keeps only rows where this endpoint's
44
+ qualifier is '='. Set False to also see censored ">"/"<" bounds,
45
+ which come back with their qualifier column intact rather than a bare
46
+ number that looks exact but isn't.
47
+
48
+ release defaults to the manifest's current latest().
49
+ """
50
+ if endpoint not in _ENDPOINTS:
51
+ raise ValueError(f"endpoint must be one of {_ENDPOINTS}, got {endpoint!r}")
52
+ release = release or latest()
53
+ value_col, qual_col = f"{endpoint}_nm_value", f"{endpoint}_nm_qualifier"
54
+
55
+ con = connect(release)
56
+ try:
57
+ where: list[str] = [f"{value_col} IS NOT NULL"]
58
+ params: list[str | int] = []
59
+ if exact_only:
60
+ where.append(f"{qual_col} = '='")
61
+ if uniprot_id is not None:
62
+ where.append(
63
+ "EXISTS (SELECT 1 FROM target_chains c WHERE "
64
+ "c.reactant_set_id = measurements.reactant_set_id AND "
65
+ "(c.uniprot_swissprot_primary_id = ? OR c.uniprot_trembl_primary_id = ?))"
66
+ )
67
+ params.extend([uniprot_id, uniprot_id])
68
+
69
+ sql = (
70
+ "SELECT * FROM measurements "
71
+ f"WHERE {' AND '.join(where)} "
72
+ f"ORDER BY {value_col} ASC"
73
+ )
74
+ if limit is not None:
75
+ sql += " LIMIT ?"
76
+ params.append(int(limit))
77
+ return con.execute(sql, params).df()
78
+ finally:
79
+ con.close()
File without changes
@@ -0,0 +1,105 @@
1
+ """Release metadata: what's mirrored, and what each release supports.
2
+
3
+ Reads a small manifest at s3://scigantic-bindingdb/_MANIFEST.json. Unlike
4
+ scigantic-chembl's manifest, this one is not yet regenerated by a cron --
5
+ BindingDB releases monthly and re-mirroring here is still a manual step, so
6
+ the manifest is hand-published alongside each mirror run. The mechanism is
7
+ kept identical to scigantic-chembl's on purpose, so a cron can be added later
8
+ without changing this module or anything that calls it.
9
+
10
+ The manifest is fetched once per process and cached. If it can't be fetched
11
+ (no network, a bucket hiccup), calls fall back to the snapshot below rather
12
+ than failing outright: worst case is that the package is only as fresh as
13
+ this package version, not broken.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import urllib.error
20
+ import urllib.request
21
+ import warnings
22
+ from dataclasses import dataclass
23
+ from typing import Any, cast
24
+
25
+ from ._constants import BUCKET, REGION
26
+
27
+ _MANIFEST_URL = f"https://{BUCKET}.s3.{REGION}.amazonaws.com/_MANIFEST.json"
28
+ _TIMEOUT_SECONDS = 5
29
+
30
+ # Last-known-good snapshot, shipped with this package version. Only used if
31
+ # the live manifest can't be fetched.
32
+ _FALLBACK_LATEST = "202608"
33
+ _FALLBACK_RELEASES = {
34
+ "202608": {"raw": True, "chembl_bridge": True, "dti_pairs": True},
35
+ }
36
+
37
+
38
+ class UnknownReleaseError(LookupError):
39
+ """Raised when a release isn't mirrored at all."""
40
+
41
+
42
+ class ReleaseCapabilityError(LookupError):
43
+ """Raised when a release doesn't carry the artifact a call asked for."""
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ReleaseInfo:
48
+ release: str
49
+ raw: bool
50
+ chembl_bridge: bool
51
+ dti_pairs: bool
52
+
53
+
54
+ _cache: dict[str, Any] | None = None
55
+
56
+
57
+ def _manifest() -> dict[str, Any]:
58
+ global _cache
59
+ if _cache is not None:
60
+ return _cache
61
+ try:
62
+ with urllib.request.urlopen(_MANIFEST_URL, timeout=_TIMEOUT_SECONDS) as response:
63
+ _cache = json.loads(response.read())
64
+ except (urllib.error.URLError, TimeoutError, ValueError) as exc:
65
+ # HTTPError (a URLError subclass) carries an open response body; left
66
+ # unclosed it raises its own ResourceWarning on garbage collection,
67
+ # on top of the one we're about to emit deliberately below.
68
+ if isinstance(exc, urllib.error.HTTPError):
69
+ exc.close()
70
+ warnings.warn(
71
+ f"could not fetch the live release manifest ({exc!r}); falling back "
72
+ "to the snapshot shipped with this package version, which may be stale",
73
+ stacklevel=3,
74
+ )
75
+ _cache = {"latest": _FALLBACK_LATEST, "releases": _FALLBACK_RELEASES}
76
+ return _cache
77
+
78
+
79
+ def releases() -> list[ReleaseInfo]:
80
+ """List every release the mirror carries, and what each one supports."""
81
+ data = _manifest()
82
+ return [ReleaseInfo(release=name, **caps) for name, caps in data["releases"].items()]
83
+
84
+
85
+ def latest() -> str:
86
+ """The release the archive treats as its current default."""
87
+ return cast(str, _manifest()["latest"])
88
+
89
+
90
+ def _validate_release(release: str) -> None:
91
+ data = _manifest()
92
+ if release not in data["releases"]:
93
+ known = ", ".join(data["releases"])
94
+ raise UnknownReleaseError(f"{release!r} is not mirrored. Known releases: {known}.")
95
+
96
+
97
+ def _require(release: str, capability: str) -> None:
98
+ _validate_release(release)
99
+ data = _manifest()
100
+ if not data["releases"][release][capability]:
101
+ raise ReleaseCapabilityError(
102
+ f"{release!r} has no {capability.replace('_', ' ')}. Call releases() to "
103
+ "see what each release supports, or use query() against the raw "
104
+ "parquet tables instead."
105
+ )
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: scigantic-bindingdb
3
+ Version: 0.2.0
4
+ Summary: Query BindingDB directly from a public S3 mirror with DuckDB, including a ChEMBL cross-reference bridge table and a ready drug-target-interaction training table.
5
+ Author: Scigantic
6
+ License: MIT-0
7
+ Project-URL: Homepage, https://scigantic.com
8
+ Project-URL: Repository, https://github.com/Scigantic/scigantic-bindingdb
9
+ Project-URL: Issues, https://github.com/Scigantic/scigantic-bindingdb/issues
10
+ Keywords: bindingdb,cheminformatics,duckdb,drug-discovery,binding-affinity
11
+ Classifier: License :: OSI Approved :: MIT No Attribution License (MIT-0)
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Typing :: Typed
19
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
20
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: duckdb>=0.10
25
+ Requires-Dist: pandas>=1.5
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7; extra == "dev"
28
+ Requires-Dist: mypy>=1.10; extra == "dev"
29
+ Requires-Dist: pandas-stubs; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ <h1 align="center">scigantic-bindingdb</h1>
33
+
34
+ <p align="center">
35
+ <a href="https://github.com/Scigantic/scigantic-bindingdb/actions/workflows/ci.yml">
36
+ <img alt="CI" src="https://github.com/Scigantic/scigantic-bindingdb/actions/workflows/ci.yml/badge.svg" /></a>
37
+ <a href="https://pypi.org/project/scigantic-bindingdb/">
38
+ <img alt="PyPI" src="https://img.shields.io/pypi/v/scigantic-bindingdb" /></a>
39
+ <a href="https://pypi.org/project/scigantic-bindingdb/">
40
+ <img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/scigantic-bindingdb" /></a>
41
+ <a href="https://github.com/Scigantic/scigantic-bindingdb/blob/main/LICENSE">
42
+ <img alt="License" src="https://img.shields.io/github/license/Scigantic/scigantic-bindingdb" /></a>
43
+ </p>
44
+
45
+ Query BindingDB directly from a public S3 mirror with DuckDB.
46
+
47
+ ```python
48
+ import scigantic_bindingdb as bindingdb
49
+
50
+ df = bindingdb.query("""
51
+ SELECT reactant_set_id, ligand_smiles, ki_nm_value
52
+ FROM measurements
53
+ WHERE ki_nm_value IS NOT NULL
54
+ LIMIT 5
55
+ """)
56
+ ```
57
+
58
+ That query runs against `s3://scigantic-bindingdb` over DuckDB's httpfs extension. Nothing is downloaded first, and there's no local database file sitting on disk afterward.
59
+
60
+ ## Installation
61
+
62
+ ```console
63
+ $ pip install scigantic-bindingdb
64
+ ```
65
+
66
+ ## What's different about BindingDB
67
+
68
+ Every row is one binding measurement (Ki, IC50, Kd or EC50) between one ligand and one protein target, rather than ChEMBL's assay-centric bioactivity record. BindingDB ships no relational database, just a flat TSV; the mirror normalizes it into `measurements` (one row per measurement), `target_chains` (one row per protein chain of the target, since BindingDB's raw format repeats a column block once per chain in a multimer) and `target_chain_names`.
69
+
70
+ ## Measurements, filtered the way that avoids the two sharp edges
71
+
72
+ ```python
73
+ df = bindingdb.measurements(uniprot_id="P00533", endpoint="ki") # EGFR
74
+ ```
75
+
76
+ Two things this does that a raw query on `measurements` doesn't do for you:
77
+
78
+ **Censored values stay out unless you ask for them.** Ki/IC50/Kd/EC50 are occasionally reported as `>X` or `<X` rather than an exact value, the same idea as ChEMBL's `standard_relation`. `exact_only=True`, the default, keeps only rows where that endpoint's qualifier is `=`:
79
+
80
+ ```python
81
+ df = bindingdb.measurements(uniprot_id="P00533", endpoint="ic50", exact_only=False) # include censored bounds too
82
+ ```
83
+
84
+ **Target filtering doesn't fan out on multichain complexes.** `uniprot_id` matches against `target_chains` with an `EXISTS` check, not a `JOIN`, so a target whose accession appears on more than one chain of the same complex still returns each measurement once.
85
+
86
+ `bindingdb.query()` still reaches the raw tables directly for anything this leaves out.
87
+
88
+ ## Cross-referencing ChEMBL
89
+
90
+ BindingDB ingests ChEMBL as one of its own curated source feeds (51.3% of measurements in the 202608 release), and ChEMBL separately absorbs some BindingDB patent-derived bioactivity data, so the two archives are not independent corpora. `derived/bindingdb_chembl_bridge.parquet`, built once at mirror time, joins measurements to [scigantic-chembl](https://github.com/Scigantic/scigantic-chembl) by BindingDB's own `chembl_id` column where present (authoritative) and falls back to an exact InChIKey match where it's missing:
91
+
92
+ ```python
93
+ df = bindingdb.chembl_bridge(reactant_set_id="50000001")
94
+ ```
95
+
96
+ `with_names=True` (the default) also reaches into the live `scigantic-chembl` mirror for the matched compound's ChEMBL preferred name, a second public-bucket read over the same connection: not a mount-level dependency between the two archives, just a query-time join across two buckets that are both public and read-only here.
97
+
98
+ ## Drug-target-interaction pairs
99
+
100
+ BindingDB is the dataset most DTI/proteochemometric tooling (like [DeepPurpose](https://github.com/kexinhuang12345/DeepPurpose)) is built around, specifically because it ships a full protein sequence alongside every affinity measurement, something ChEMBL's bioactivity tables don't do as directly. `derived/dti_pairs.parquet` is BindingDB reshaped into the (ligand, target, affinity) triples a model trains on, done once rather than re-derived by every caller:
101
+
102
+ ```python
103
+ df = bindingdb.dti_pairs(endpoint="ki", single_chain_only=True)
104
+ ```
105
+
106
+ ```
107
+ reactant_set_id ligand_smiles target_sequence uniprot_id endpoint affinity_nm p_affinity
108
+ 764556 Cc1ncoc1-c1nnc... MASLSQLSSHLN... P35462 ki 1.74 8.759451
109
+ ```
110
+
111
+ Only exact measurements (never a censored `>X`/`<X` bound treated as a real label), and `p_affinity` is already computed as `-log10(affinity_nm * 1e-9)`, the same transform as ChEMBL's `pchembl_value`. 2,589,053 pairs across the four endpoints in the 202608 release, 1,163,672 distinct ligands, 9,219 distinct UniProt targets.
112
+
113
+ Multichain targets are represented by chain 1's sequence only, standard practice for DTI benchmarks. Pass `single_chain_only=True` to drop the 5.7% of rows where that simplifies an actual multi-protein complex, if single-chain purity matters for your model. See `derived/DTI_README.md` in the mirror for the exact filters applied.
114
+
115
+ ## Working offline
116
+
117
+ Off by default, since zero setup is the whole point. Turn it on to run the same queries repeatedly without re-fetching from S3:
118
+
119
+ ```python
120
+ import scigantic_bindingdb as bindingdb
121
+
122
+ bindingdb.enable_cache()
123
+ df = bindingdb.chembl_bridge() # downloads the bridge table once, then reads from disk
124
+ ```
125
+
126
+ `chembl_bridge()` and `dti_pairs()` each need exactly one derived file, so caching downloads that one file to `~/.cache/scigantic-bindingdb` (override with `enable_cache(cache_dir=...)` or the `SCIGANTIC_BINDINGDB_CACHE` environment variable) and reuses it after that.
127
+
128
+ `connect()`, `query()` and `measurements()` don't participate in this: `connect()` registers five core tables as views on every call, so caching them there would mean any call eagerly downloads everything regardless of what the query actually touches. Cache one table yourself if you want it locally: `bindingdb.cache_resolve("202608/parquet/measurements.parquet")` downloads it and returns the local path, usable directly in `read_parquet(...)`.
129
+
130
+ ## What's mirrored
131
+
132
+ ```python
133
+ bindingdb.releases()
134
+ ```
135
+
136
+ | release | raw tables | ChEMBL bridge | DTI pairs |
137
+ |---|---|---|---|
138
+ | 202608 | yes | yes | yes |
139
+
140
+ This table isn't hardcoded. `releases()` reads a small manifest published alongside each mirror run. If it can't be reached, calls fall back to the snapshot shipped with whatever version you have installed and print a warning, rather than failing outright.
141
+
142
+ Not mirrored yet: BindingDB's 3D SDF structures and precomputed similarity/substructure search (no fingerprint corpus has been built for this archive). `bindingdb.query()` still reaches every raw table the mirror carries.
143
+
144
+ ## Command line
145
+
146
+ ```console
147
+ $ scigantic-bindingdb info
148
+ $ scigantic-bindingdb query "SELECT count(*) FROM measurements" --release 202608
149
+ ```
150
+
151
+ ## License
152
+
153
+ MIT-0. See [LICENSE](LICENSE).
@@ -0,0 +1,17 @@
1
+ scigantic_bindingdb/__init__.py,sha256=Mi46LufFt4rdagj_ng8Y9tGMA8bffiAoDAu4-ad0tM4,1050
2
+ scigantic_bindingdb/__main__.py,sha256=rMnNg3rwz8DD2ZVM0ylfdeYfSLyHULSVg4aICcCN5eQ,190
3
+ scigantic_bindingdb/_constants.py,sha256=8EJUA6yIHAILlf1W8lnO45HkqW_qZwYX_OjiAUeUN88,216
4
+ scigantic_bindingdb/cache.py,sha256=2NPs0A_BCcgSYgSChrdw6kIrMLKZsxa7TP88-6awNnE,3802
5
+ scigantic_bindingdb/chembl_bridge.py,sha256=Oi2R1tLprKZ3jlx7igxHFJpKtjTGsSn6WMYENdDgiyw,3577
6
+ scigantic_bindingdb/cli.py,sha256=BOTUtDcFah2vE-m6JemcoGZmhMx0_ZPA6OQ3v2-_19A,1655
7
+ scigantic_bindingdb/connection.py,sha256=V4SlUJ0Ys_ZFBAH_G3ZTArsQCHNnTcSDlSo7nzNpc28,2844
8
+ scigantic_bindingdb/dti_pairs.py,sha256=Hurw3UQtTseVDClHoJjRmehyz_lGpg2vq4ibP9YSRCE,2790
9
+ scigantic_bindingdb/measurements.py,sha256=uOLOKHsKT9y6VJw3-sLoVd4a-e6EBBVdqwtbNWmjvp8,2897
10
+ scigantic_bindingdb/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ scigantic_bindingdb/releases.py,sha256=0kbGo2wTKB6hvBli5fQsmihqy59Nc8qEfBeUa0syi9k,3652
12
+ scigantic_bindingdb-0.2.0.dist-info/licenses/LICENSE,sha256=RiBhEtE0qZ65CeSf05VqMHWAhMh0ETETZAj4TEtscWE,904
13
+ scigantic_bindingdb-0.2.0.dist-info/METADATA,sha256=vymmP5sGoetAJeOg-l_GMA4slyq28WWfVaWa_nncBog,8419
14
+ scigantic_bindingdb-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ scigantic_bindingdb-0.2.0.dist-info/entry_points.txt,sha256=IuFxRQdzRACD7prBaS_gRgcfWFRK7RdJAynBc2eMU0o,69
16
+ scigantic_bindingdb-0.2.0.dist-info/top_level.txt,sha256=1FMpDQXbzsrl91ICHFlMrUKUH6iE-Cew56k6wt1VRKk,20
17
+ scigantic_bindingdb-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scigantic-bindingdb = scigantic_bindingdb.cli:main
@@ -0,0 +1,16 @@
1
+ MIT No Attribution
2
+
3
+ Copyright 2026 Scigantic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this
6
+ software and associated documentation files (the "Software"), to deal in the Software
7
+ without restriction, including without limitation the rights to use, copy, modify,
8
+ merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
9
+ permit persons to whom the Software is furnished to do so.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
12
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
13
+ PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
14
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
15
+ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
16
+ OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ scigantic_bindingdb