scigantic-bindingdb 0.2.2__tar.gz → 0.2.4__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.
Files changed (29) hide show
  1. {scigantic_bindingdb-0.2.2/src/scigantic_bindingdb.egg-info → scigantic_bindingdb-0.2.4}/PKG-INFO +2 -2
  2. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/README.md +1 -1
  3. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/pyproject.toml +1 -1
  4. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb/cache.py +29 -5
  5. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb/chembl_bridge.py +12 -6
  6. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb/dti_pairs.py +7 -2
  7. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4/src/scigantic_bindingdb.egg-info}/PKG-INFO +2 -2
  8. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/tests/test_cache.py +44 -0
  9. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/tests/test_chembl_bridge.py +24 -0
  10. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/tests/test_dti_pairs.py +24 -0
  11. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/LICENSE +0 -0
  12. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/setup.cfg +0 -0
  13. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb/__init__.py +0 -0
  14. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb/__main__.py +0 -0
  15. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb/_constants.py +0 -0
  16. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb/cli.py +0 -0
  17. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb/connection.py +0 -0
  18. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb/measurements.py +0 -0
  19. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb/py.typed +0 -0
  20. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb/releases.py +0 -0
  21. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb.egg-info/SOURCES.txt +0 -0
  22. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb.egg-info/dependency_links.txt +0 -0
  23. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb.egg-info/entry_points.txt +0 -0
  24. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb.egg-info/requires.txt +0 -0
  25. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/src/scigantic_bindingdb.egg-info/top_level.txt +0 -0
  26. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/tests/test_cli.py +0 -0
  27. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/tests/test_connection.py +0 -0
  28. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/tests/test_measurements.py +0 -0
  29. {scigantic_bindingdb-0.2.2 → scigantic_bindingdb-0.2.4}/tests/test_releases.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scigantic-bindingdb
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
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
5
  Author: Scigantic
6
6
  License: MIT-0
@@ -136,7 +136,7 @@ bindingdb.enable_cache()
136
136
  df = bindingdb.chembl_bridge() # downloads the bridge table once, then reads from disk
137
137
  ```
138
138
 
139
- `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.
139
+ `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. Concurrent callers racing the first download of the same file wait for it rather than each downloading their own copy.
140
140
 
141
141
  `connect()`, `query()` and `measurements()` don't participate in this: `connect()` registers five core tables as views the first time a release is used, so caching them there would mean the first call for any release 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(...)`.
142
142
 
@@ -105,7 +105,7 @@ bindingdb.enable_cache()
105
105
  df = bindingdb.chembl_bridge() # downloads the bridge table once, then reads from disk
106
106
  ```
107
107
 
108
- `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.
108
+ `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. Concurrent callers racing the first download of the same file wait for it rather than each downloading their own copy.
109
109
 
110
110
  `connect()`, `query()` and `measurements()` don't participate in this: `connect()` registers five core tables as views the first time a release is used, so caching them there would mean the first call for any release 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(...)`.
111
111
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scigantic-bindingdb"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "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."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -23,6 +23,7 @@ from __future__ import annotations
23
23
 
24
24
  import os
25
25
  import sys
26
+ import threading
26
27
  import urllib.request
27
28
  import uuid
28
29
  from pathlib import Path
@@ -34,6 +35,24 @@ _cache_dir: Path | None = None
34
35
 
35
36
  _CHUNK_BYTES = 1024 * 1024
36
37
 
38
+ # One lock per key, created on first use. Guards resolve()'s
39
+ # check-then-download against concurrent callers asking for the same key
40
+ # at once: without this, N threads racing the first resolve() of a key
41
+ # each see it missing and each download it in full, rather than one
42
+ # downloading while the rest wait and reuse the result. Verified directly:
43
+ # 16 threads calling chembl_bridge() concurrently on an empty cache
44
+ # triggered 16 separate downloads of the same file before this fix.
45
+ _resolve_locks: dict[str, threading.Lock] = {}
46
+ _resolve_locks_guard = threading.Lock()
47
+
48
+
49
+ def _lock_for(key: str) -> threading.Lock:
50
+ lock = _resolve_locks.get(key)
51
+ if lock is not None:
52
+ return lock
53
+ with _resolve_locks_guard:
54
+ return _resolve_locks.setdefault(key, threading.Lock())
55
+
37
56
 
38
57
  def _default_cache_dir() -> Path:
39
58
  if sys.platform == "win32":
@@ -114,7 +133,9 @@ def resolve(key: str) -> str:
114
133
  `key` is a path relative to the bucket root, e.g.
115
134
  "202608/derived/bindingdb_chembl_bridge.parquet". Downloads to the
116
135
  cache on first access; later calls for the same key reuse the local
117
- file without touching the network.
136
+ file without touching the network. Concurrent callers asking for the
137
+ same key while it's still downloading wait for that download rather
138
+ than each starting their own.
118
139
  """
119
140
  if not _enabled:
120
141
  return f"s3://{BUCKET}/{key}"
@@ -124,8 +145,11 @@ def resolve(key: str) -> str:
124
145
  if local_path.exists():
125
146
  return str(local_path)
126
147
 
127
- local_path.parent.mkdir(parents=True, exist_ok=True)
128
- url = f"https://{BUCKET}.s3.{REGION}.amazonaws.com/{key}"
129
- print(f"scigantic-bindingdb: caching {key} ...", file=sys.stderr, flush=True)
130
- _atomic_download(url, local_path)
148
+ with _lock_for(key):
149
+ if local_path.exists(): # another thread finished it while we waited
150
+ return str(local_path)
151
+ local_path.parent.mkdir(parents=True, exist_ok=True)
152
+ url = f"https://{BUCKET}.s3.{REGION}.amazonaws.com/{key}"
153
+ print(f"scigantic-bindingdb: caching {key} ...", file=sys.stderr, flush=True)
154
+ _atomic_download(url, local_path)
131
155
  return str(local_path)
@@ -49,10 +49,6 @@ def chembl_bridge(
49
49
  con = connect(release)
50
50
  try:
51
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
52
 
57
53
  where: list[str] = []
58
54
  params: list[str | int] = []
@@ -61,12 +57,22 @@ def chembl_bridge(
61
57
  params.append(reactant_set_id)
62
58
  clause = f"WHERE {' AND '.join(where)}" if where else ""
63
59
 
60
+ # read_parquet(bridge_path) inline rather than a named
61
+ # CREATE VIEW: connect() now hands out a cursor on a base
62
+ # connection shared across calls (see connection.py), and a
63
+ # named view is catalog state on that shared connection.
64
+ # Concurrent calls each doing CREATE OR REPLACE VIEW on the same
65
+ # name raced each other's DDL transaction: reproduced directly,
66
+ # concurrent chembl_bridge()/dti_pairs() calls from a thread pool
67
+ # mostly failed with DuckDB's "Catalog write-write conflict".
68
+ # Referencing the parquet file directly in the query has no
69
+ # catalog side effect at all, so there's nothing to conflict on.
64
70
  if with_names:
65
71
  mol_dict = f"s3://{CHEMBL_BUCKET}/{chembl_release}/parquet/molecule_dictionary.parquet"
66
72
  sql = f"""
67
73
  SELECT b.reactant_set_id, b.chembl_molregno, b.chembl_id, b.match_method,
68
74
  m.ligand_smiles, d.pref_name AS chembl_pref_name
69
- FROM chembl_bridge b
75
+ FROM read_parquet('{bridge_path}') b
70
76
  JOIN measurements m ON m.reactant_set_id = b.reactant_set_id
71
77
  LEFT JOIN read_parquet('{mol_dict}') d ON d.molregno = b.chembl_molregno
72
78
  {clause}
@@ -75,7 +81,7 @@ def chembl_bridge(
75
81
  sql = f"""
76
82
  SELECT b.reactant_set_id, b.chembl_molregno, b.chembl_id, b.match_method,
77
83
  m.ligand_smiles
78
- FROM chembl_bridge b
84
+ FROM read_parquet('{bridge_path}') b
79
85
  JOIN measurements m ON m.reactant_set_id = b.reactant_set_id
80
86
  {clause}
81
87
  """
@@ -54,7 +54,6 @@ def dti_pairs(
54
54
  con = connect(release)
55
55
  try:
56
56
  path = _resolve(f"{release}/derived/dti_pairs.parquet")
57
- con.execute(f"CREATE OR REPLACE VIEW dti_pairs AS SELECT * FROM read_parquet('{path}')")
58
57
 
59
58
  where: list[str] = []
60
59
  params: list[str | int] = []
@@ -68,7 +67,13 @@ def dti_pairs(
68
67
  where.append("n_chains_declared = 1")
69
68
  clause = f"WHERE {' AND '.join(where)}" if where else ""
70
69
 
71
- sql = f"SELECT * FROM dti_pairs {clause} ORDER BY p_affinity DESC"
70
+ # read_parquet(path) inline rather than a named CREATE VIEW: see
71
+ # chembl_bridge.py's comment on the same change. connect() now
72
+ # returns a cursor on a base connection shared across calls, and a
73
+ # named view is catalog state on that shared connection --
74
+ # concurrent calls racing CREATE OR REPLACE VIEW on the same name
75
+ # hit DuckDB's "Catalog write-write conflict", reproduced directly.
76
+ sql = f"SELECT * FROM read_parquet('{path}') {clause} ORDER BY p_affinity DESC"
72
77
  if limit is not None:
73
78
  sql += " LIMIT ?"
74
79
  params.append(int(limit))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scigantic-bindingdb
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
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
5
  Author: Scigantic
6
6
  License: MIT-0
@@ -136,7 +136,7 @@ bindingdb.enable_cache()
136
136
  df = bindingdb.chembl_bridge() # downloads the bridge table once, then reads from disk
137
137
  ```
138
138
 
139
- `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.
139
+ `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. Concurrent callers racing the first download of the same file wait for it rather than each downloading their own copy.
140
140
 
141
141
  `connect()`, `query()` and `measurements()` don't participate in this: `connect()` registers five core tables as views the first time a release is used, so caching them there would mean the first call for any release 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(...)`.
142
142
 
@@ -127,6 +127,50 @@ def test_concurrent_downloads_of_the_same_key_never_raise(tmp_path):
127
127
  assert list(local_path.parent.glob("key.parquet.*.part")) == []
128
128
 
129
129
 
130
+ def test_concurrent_first_resolve_of_the_same_key_downloads_once(tmp_path):
131
+ # Regression test for a real inefficiency: resolve()'s check-then-
132
+ # download wasn't coordinated across threads, so N threads racing the
133
+ # first resolve() of a key each saw it missing and each downloaded it
134
+ # in full. Verified directly before this was fixed with a per-key
135
+ # lock: 16 threads calling resolve() concurrently on an empty cache
136
+ # triggered 16 separate downloads of the same file instead of one.
137
+ #
138
+ # mock.patch is entered/exited only once here, by the main thread,
139
+ # before the pool starts; the worker threads only read the already-
140
+ # patched attribute, so this doesn't hit the same-target mock.patch
141
+ # race that test_concurrent_downloads_of_the_same_key_never_raise's
142
+ # docstring describes for patching from multiple threads at once.
143
+ body = b"cached-body"
144
+
145
+ class FakeConnection:
146
+ def __enter__(self):
147
+ return self
148
+
149
+ def __exit__(self, *exc_info):
150
+ return False
151
+
152
+ def read(self, *_args):
153
+ if getattr(self, "_served", False):
154
+ return b""
155
+ self._served = True
156
+ return body
157
+
158
+ bindingdb.enable_cache(cache_dir=str(tmp_path))
159
+ try:
160
+ with mock.patch(
161
+ "urllib.request.urlopen", side_effect=lambda *a, **k: FakeConnection()
162
+ ) as urlopen:
163
+ with ThreadPoolExecutor(max_workers=16) as pool:
164
+ paths = list(pool.map(lambda _i: resolve("shared/once.parquet"), range(16)))
165
+
166
+ assert len(set(paths)) == 1
167
+ assert urlopen.call_count == 1 # only one thread actually downloaded
168
+ final = tmp_path / "shared" / "once.parquet"
169
+ assert final.read_bytes() == body
170
+ finally:
171
+ bindingdb.disable_cache()
172
+
173
+
130
174
  def test_cached_bridge_matches_uncached(tmp_path):
131
175
  # The one integration-level check: caching actually gets used by a real
132
176
  # function, not just by resolve() directly, and returns the same data.
@@ -1,3 +1,5 @@
1
+ from concurrent.futures import ThreadPoolExecutor
2
+
1
3
  import scigantic_bindingdb as bindingdb
2
4
 
3
5
  # A gefitinib measurement, verified against the live mirror to resolve
@@ -29,3 +31,25 @@ def test_bridge_total_row_count():
29
31
  "'s3://scigantic-bindingdb/202608/derived/bindingdb_chembl_bridge.parquet')"
30
32
  )
31
33
  assert total["n"].iloc[0] == 2272063
34
+
35
+
36
+ def test_concurrent_calls_do_not_conflict_on_catalog():
37
+ # Regression test for a real bug introduced by connect()'s move to a
38
+ # shared base connection (see connection.py): chembl_bridge() used to
39
+ # register its result as a named CREATE OR REPLACE VIEW, which is
40
+ # catalog-mutating DDL. Two calls racing that DDL on the same shared
41
+ # connection from different threads mostly failed with DuckDB's
42
+ # "Catalog write-write conflict", reproduced directly (46/48 calls
43
+ # failed in a 24-thread stress run) before this was fixed by
44
+ # referencing the parquet file inline instead of via a named view.
45
+ def call(i):
46
+ return bindingdb.chembl_bridge(
47
+ reactant_set_id=_GEFITINIB_REACTANT_SET_ID, with_names=(i % 2 == 0)
48
+ )
49
+
50
+ with ThreadPoolExecutor(max_workers=16) as pool:
51
+ results = list(pool.map(call, range(16)))
52
+
53
+ for df in results:
54
+ assert len(df) == 1
55
+ assert df["chembl_id"].iloc[0] == "CHEMBL939"
@@ -1,4 +1,5 @@
1
1
  import math
2
+ from concurrent.futures import ThreadPoolExecutor
2
3
 
3
4
  import scigantic_bindingdb as bindingdb
4
5
 
@@ -69,3 +70,26 @@ def test_uniprot_filter():
69
70
  def test_sorted_most_potent_first():
70
71
  df = bindingdb.dti_pairs(endpoint="ki", uniprot_id="P00533")
71
72
  assert df["p_affinity"].is_monotonic_decreasing
73
+
74
+
75
+ def test_concurrent_calls_do_not_conflict_on_catalog():
76
+ # Regression test for a real bug introduced by connect()'s move to a
77
+ # shared base connection (see connection.py): dti_pairs() used to
78
+ # register its result as a named CREATE OR REPLACE VIEW, which is
79
+ # catalog-mutating DDL. Two calls racing that DDL on the same shared
80
+ # connection from different threads mostly failed with DuckDB's
81
+ # "Catalog write-write conflict" (reproduced directly before this was
82
+ # fixed by referencing the parquet file inline instead of via a named
83
+ # view; see chembl_bridge.py's equivalent test).
84
+ endpoints = ("ki", "ic50", "kd", "ec50")
85
+
86
+ def call(i):
87
+ endpoint = endpoints[i % len(endpoints)]
88
+ return endpoint, bindingdb.dti_pairs(endpoint=endpoint, limit=50)
89
+
90
+ with ThreadPoolExecutor(max_workers=16) as pool:
91
+ results = list(pool.map(call, range(16)))
92
+
93
+ for endpoint, df in results:
94
+ assert len(df) == 50
95
+ assert (df["endpoint"] == endpoint).all()