scigantic-pubchem 0.3.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.
- scigantic_pubchem-0.3.0/LICENSE +16 -0
- scigantic_pubchem-0.3.0/PKG-INFO +148 -0
- scigantic_pubchem-0.3.0/README.md +114 -0
- scigantic_pubchem-0.3.0/pyproject.toml +56 -0
- scigantic_pubchem-0.3.0/setup.cfg +4 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem/__init__.py +36 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem/__main__.py +8 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem/_client.py +177 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem/bridge.py +83 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem/cache.py +138 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem/cli.py +97 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem/models.py +18 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem/py.typed +0 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem/resolve.py +90 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem/similarity.py +75 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem/xrefs.py +53 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem.egg-info/PKG-INFO +148 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem.egg-info/SOURCES.txt +29 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem.egg-info/dependency_links.txt +1 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem.egg-info/entry_points.txt +2 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem.egg-info/requires.txt +11 -0
- scigantic_pubchem-0.3.0/src/scigantic_pubchem.egg-info/top_level.txt +1 -0
- scigantic_pubchem-0.3.0/tests/test_async_search.py +54 -0
- scigantic_pubchem-0.3.0/tests/test_bridge.py +19 -0
- scigantic_pubchem-0.3.0/tests/test_cache.py +112 -0
- scigantic_pubchem-0.3.0/tests/test_cli.py +20 -0
- scigantic_pubchem-0.3.0/tests/test_client.py +20 -0
- scigantic_pubchem-0.3.0/tests/test_concurrency.py +24 -0
- scigantic_pubchem-0.3.0/tests/test_resolve.py +48 -0
- scigantic_pubchem-0.3.0/tests/test_similarity.py +46 -0
- scigantic_pubchem-0.3.0/tests/test_xrefs.py +19 -0
|
@@ -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,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scigantic-pubchem
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Query PubChem live via PUG REST, with rate-limit-aware retry, expiring cache, and live similarity/substructure/cross-reference search PubChemPy doesn't have.
|
|
5
|
+
Author: Scigantic
|
|
6
|
+
License: MIT-0
|
|
7
|
+
Project-URL: Homepage, https://scigantic.com
|
|
8
|
+
Project-URL: Repository, https://github.com/Scigantic/scigantic-pubchem
|
|
9
|
+
Project-URL: Issues, https://github.com/Scigantic/scigantic-pubchem/issues
|
|
10
|
+
Keywords: pubchem,cheminformatics,pug-rest,drug-discovery,identifier-resolution
|
|
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: requests>=2.28
|
|
25
|
+
Provides-Extra: bridge
|
|
26
|
+
Requires-Dist: duckdb>=0.10; extra == "bridge"
|
|
27
|
+
Requires-Dist: pandas>=1.5; extra == "bridge"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
31
|
+
Requires-Dist: types-requests; extra == "dev"
|
|
32
|
+
Requires-Dist: pandas-stubs; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
<h1 align="center">scigantic-pubchem</h1>
|
|
36
|
+
|
|
37
|
+
<p align="center">
|
|
38
|
+
<a href="https://github.com/Scigantic/scigantic-pubchem/actions/workflows/ci.yml">
|
|
39
|
+
<img alt="CI" src="https://github.com/Scigantic/scigantic-pubchem/actions/workflows/ci.yml/badge.svg" /></a>
|
|
40
|
+
<a href="https://pypi.org/project/scigantic-pubchem/">
|
|
41
|
+
<img alt="PyPI" src="https://img.shields.io/pypi/v/scigantic-pubchem" /></a>
|
|
42
|
+
<a href="https://pypi.org/project/scigantic-pubchem/">
|
|
43
|
+
<img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/scigantic-pubchem" /></a>
|
|
44
|
+
<a href="https://github.com/Scigantic/scigantic-pubchem/blob/main/LICENSE">
|
|
45
|
+
<img alt="License" src="https://img.shields.io/github/license/Scigantic/scigantic-pubchem" /></a>
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
Query PubChem live via PUG REST. No mirror, no download, no local database.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import scigantic_pubchem as pubchem
|
|
52
|
+
|
|
53
|
+
aspirin = pubchem.resolve("aspirin")
|
|
54
|
+
print(aspirin.cid, aspirin.smiles, aspirin.inchi_key)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```console
|
|
60
|
+
$ pip install scigantic-pubchem
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Why this exists
|
|
64
|
+
|
|
65
|
+
[PubChemPy](https://github.com/mcs07/PubChemPy) has been the standard way to script against PubChem in Python for years, and it covers a lot this package doesn't try to replace: 3D conformers, assays, substances, atoms and bonds. This package is narrower, focused on identifier resolution and cross-referencing, and adds a few things that matter specifically for that: resilience under PubChem's own rate limiting, a cache that keeps a notebook fast without going stale, and live search over PubChem's full corpus rather than a local index.
|
|
66
|
+
|
|
67
|
+
Measured, not asserted, on 2026-08-27:
|
|
68
|
+
|
|
69
|
+
| | measured |
|
|
70
|
+
|---|---|
|
|
71
|
+
| Cached lookup vs a live PUG REST round trip | under 1ms vs 250ms-2.4s in testing, roughly 1,000-10,000x depending on the identifier |
|
|
72
|
+
| Batch resolution (50 CIDs) | 30.5s in one request via `resolve_many()`, versus 90.7s across 50 separate requests done one at a time (3.0x) |
|
|
73
|
+
| Live 503 during an 8-thread concurrent test | recovered automatically via retry, no failed lookups |
|
|
74
|
+
| Similarity search corpus size | PubChem's full ~120M compounds, live, versus a precomputed local index bounded to whatever was indexed ahead of time |
|
|
75
|
+
|
|
76
|
+
The rate-limit handling in particular comes from reading PUG REST's own `X-Throttling-Control` response header, which reports live status across three dimensions (request count, request time, service load) on every call. PubChemPy's source doesn't read this header, so it has no way to back off before hitting a hard limit, and raises immediately on an error with no retry. That's a reasonable design for a general-purpose client; this package leans the other way on purpose, since backing off proactively and retrying transient failures matters more when a notebook is doing dozens of lookups in a loop.
|
|
77
|
+
|
|
78
|
+
**Caching, on by default, and it expires.** A lookup already made is never re-fetched, cached to `~/.cache/scigantic-pubchem` (override with `enable_cache(cache_dir=...)` or `SCIGANTIC_PUBCHEM_CACHE`). This is a deliberate difference from `scigantic-chembl` and `scigantic-bindingdb`, whose caching defaults off: those read a public S3 mirror with no meaningful rate limit, so caching there is pure convenience. This package calls a rate-limited live API for every lookup, so re-fetching the same identifier in a loop is both slow and the exact thing the throttling-aware client otherwise works to avoid. Entries expire after 30 days by default, so the cache can't quietly turn into a stale snapshot:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
pubchem.disable_cache() # every call hits the network fresh
|
|
82
|
+
pubchem.enable_cache(ttl_days=7) # shorter freshness window
|
|
83
|
+
pubchem.enable_cache(ttl_days=None) # never expire
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Live cross-references, not just structures.**
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
pubchem.chembl_id(2244) # 'CHEMBL25', read live from PubChem's own xrefs, not a static table
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
PubChem's own `xrefs/RegistryID` endpoint already carries the ChEMBL ID for a compound when one exists (verified live against aspirin, CID 2244 resolves to CHEMBL25). PubChemPy can reach the same endpoint through its low-level `request()`/`get()` functions; this package wraps it as a named, documented function.
|
|
93
|
+
|
|
94
|
+
**Similarity and substructure search, over PubChem's entire corpus, not a local index.**
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
hits = pubchem.similar_compounds("CC(=O)OC1=CC=CC=C1C(=O)O", threshold=95, max_records=5)
|
|
98
|
+
# [Compound(cid=2244, title='Aspirin', ...), Compound(cid=4133, title='Methyl Salicylate', ...), ...]
|
|
99
|
+
|
|
100
|
+
pubchem.substructure_search("c1ccccc1", query_type="smiles") # every compound containing a benzene ring
|
|
101
|
+
pubchem.substructure_search("[#6]1[#6][#6][#6][#6][#6]1", query_type="smarts") # the SMARTS equivalent
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`scigantic-chembl`'s `similar_compounds()`/`substructure_search()` precompute fingerprints once and search them locally: fast, but bounded to the roughly 1.68M ChEMBL compounds that carry a comparable measurement. This runs the search on PubChem's own servers, live, over the full ~120M-compound corpus, verified sub-second for a typical query and with no local fingerprint database to build or hold in memory. PubChemPy exposes the same PUG REST capability as a raw `searchtype="similarity"`/`"substructure"` parameter to its generic `get_compounds()`; this package gives it a named function, and keeps `query_type="smiles"` and `"smarts"` as separate, explicit paths rather than guessing between them, since they're genuinely different endpoints with different matching semantics (verified live: the same ring given as SMILES versus SMARTS returns overlapping but not identical results).
|
|
105
|
+
|
|
106
|
+
An expensive search can respond asynchronously, with PubChem handing back a job to poll rather than blocking the connection; handled transparently, using the same protocol PubChemPy implements. Every live query tried during development resolved synchronously, even a maximally broad single-carbon substructure search, so the polling loop itself is verified with a scripted mock response sequence rather than left checked only against documentation.
|
|
107
|
+
|
|
108
|
+
## Thread safety
|
|
109
|
+
|
|
110
|
+
Safe to call from multiple threads, a plausible real pattern: resolving a list of names via a `ThreadPoolExecutor`, say. The shared HTTP session is created once behind a lock rather than raced into existence by whichever thread gets there first. `enable_cache()`/`disable_cache()` are not synchronized against concurrent reads, the same way mutating `os.environ` isn't: call them once at the start of a script, not from multiple threads at once.
|
|
111
|
+
|
|
112
|
+
## Live bridge into scigantic-chembl and scigantic-bindingdb
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
pubchem.chembl_context(2244)
|
|
116
|
+
# {'molregno': ..., 'chembl_id': 'CHEMBL25', 'pref_name': 'ASPIRIN', 'max_phase': 4}
|
|
117
|
+
|
|
118
|
+
pubchem.bindingdb_measurements(2244)
|
|
119
|
+
# DataFrame of every BindingDB measurement recorded against this CID
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Both are on-demand DuckDB queries against the public [scigantic-chembl](https://github.com/Scigantic/scigantic-chembl) and [scigantic-bindingdb](https://github.com/Scigantic/scigantic-bindingdb) mirrors. `chembl_context` resolves the ChEMBL ID live (see above), then looks up its assay context; `bindingdb_measurements` filters BindingDB's own `pubchem_cid` column directly, since BindingDB already carries that mapping natively. Neither needs a precomputed bridge table, and neither goes stale the way a static one would. Needs `duckdb`:
|
|
123
|
+
|
|
124
|
+
```console
|
|
125
|
+
$ pip install "scigantic-pubchem[bridge]"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Batch resolution
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
compounds = pubchem.resolve_many([2244, 2519, 1983]) # aspirin, caffeine, acetaminophen
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
PUG REST accepts a comma-separated CID list in a single request (verified live); this chunks at 200 CIDs per call rather than sending one unbounded URL for a long list.
|
|
135
|
+
|
|
136
|
+
## Command line
|
|
137
|
+
|
|
138
|
+
```console
|
|
139
|
+
$ scigantic-pubchem resolve aspirin
|
|
140
|
+
$ scigantic-pubchem chembl-id 2244
|
|
141
|
+
$ scigantic-pubchem xrefs 2244 --type RegistryID
|
|
142
|
+
$ scigantic-pubchem similar "CC(=O)OC1=CC=CC=C1C(=O)O" --threshold 95
|
|
143
|
+
$ scigantic-pubchem substructure c1ccccc1
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT-0. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<h1 align="center">scigantic-pubchem</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://github.com/Scigantic/scigantic-pubchem/actions/workflows/ci.yml">
|
|
5
|
+
<img alt="CI" src="https://github.com/Scigantic/scigantic-pubchem/actions/workflows/ci.yml/badge.svg" /></a>
|
|
6
|
+
<a href="https://pypi.org/project/scigantic-pubchem/">
|
|
7
|
+
<img alt="PyPI" src="https://img.shields.io/pypi/v/scigantic-pubchem" /></a>
|
|
8
|
+
<a href="https://pypi.org/project/scigantic-pubchem/">
|
|
9
|
+
<img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/scigantic-pubchem" /></a>
|
|
10
|
+
<a href="https://github.com/Scigantic/scigantic-pubchem/blob/main/LICENSE">
|
|
11
|
+
<img alt="License" src="https://img.shields.io/github/license/Scigantic/scigantic-pubchem" /></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
Query PubChem live via PUG REST. No mirror, no download, no local database.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import scigantic_pubchem as pubchem
|
|
18
|
+
|
|
19
|
+
aspirin = pubchem.resolve("aspirin")
|
|
20
|
+
print(aspirin.cid, aspirin.smiles, aspirin.inchi_key)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
$ pip install scigantic-pubchem
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Why this exists
|
|
30
|
+
|
|
31
|
+
[PubChemPy](https://github.com/mcs07/PubChemPy) has been the standard way to script against PubChem in Python for years, and it covers a lot this package doesn't try to replace: 3D conformers, assays, substances, atoms and bonds. This package is narrower, focused on identifier resolution and cross-referencing, and adds a few things that matter specifically for that: resilience under PubChem's own rate limiting, a cache that keeps a notebook fast without going stale, and live search over PubChem's full corpus rather than a local index.
|
|
32
|
+
|
|
33
|
+
Measured, not asserted, on 2026-08-27:
|
|
34
|
+
|
|
35
|
+
| | measured |
|
|
36
|
+
|---|---|
|
|
37
|
+
| Cached lookup vs a live PUG REST round trip | under 1ms vs 250ms-2.4s in testing, roughly 1,000-10,000x depending on the identifier |
|
|
38
|
+
| Batch resolution (50 CIDs) | 30.5s in one request via `resolve_many()`, versus 90.7s across 50 separate requests done one at a time (3.0x) |
|
|
39
|
+
| Live 503 during an 8-thread concurrent test | recovered automatically via retry, no failed lookups |
|
|
40
|
+
| Similarity search corpus size | PubChem's full ~120M compounds, live, versus a precomputed local index bounded to whatever was indexed ahead of time |
|
|
41
|
+
|
|
42
|
+
The rate-limit handling in particular comes from reading PUG REST's own `X-Throttling-Control` response header, which reports live status across three dimensions (request count, request time, service load) on every call. PubChemPy's source doesn't read this header, so it has no way to back off before hitting a hard limit, and raises immediately on an error with no retry. That's a reasonable design for a general-purpose client; this package leans the other way on purpose, since backing off proactively and retrying transient failures matters more when a notebook is doing dozens of lookups in a loop.
|
|
43
|
+
|
|
44
|
+
**Caching, on by default, and it expires.** A lookup already made is never re-fetched, cached to `~/.cache/scigantic-pubchem` (override with `enable_cache(cache_dir=...)` or `SCIGANTIC_PUBCHEM_CACHE`). This is a deliberate difference from `scigantic-chembl` and `scigantic-bindingdb`, whose caching defaults off: those read a public S3 mirror with no meaningful rate limit, so caching there is pure convenience. This package calls a rate-limited live API for every lookup, so re-fetching the same identifier in a loop is both slow and the exact thing the throttling-aware client otherwise works to avoid. Entries expire after 30 days by default, so the cache can't quietly turn into a stale snapshot:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
pubchem.disable_cache() # every call hits the network fresh
|
|
48
|
+
pubchem.enable_cache(ttl_days=7) # shorter freshness window
|
|
49
|
+
pubchem.enable_cache(ttl_days=None) # never expire
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Live cross-references, not just structures.**
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
pubchem.chembl_id(2244) # 'CHEMBL25', read live from PubChem's own xrefs, not a static table
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
PubChem's own `xrefs/RegistryID` endpoint already carries the ChEMBL ID for a compound when one exists (verified live against aspirin, CID 2244 resolves to CHEMBL25). PubChemPy can reach the same endpoint through its low-level `request()`/`get()` functions; this package wraps it as a named, documented function.
|
|
59
|
+
|
|
60
|
+
**Similarity and substructure search, over PubChem's entire corpus, not a local index.**
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
hits = pubchem.similar_compounds("CC(=O)OC1=CC=CC=C1C(=O)O", threshold=95, max_records=5)
|
|
64
|
+
# [Compound(cid=2244, title='Aspirin', ...), Compound(cid=4133, title='Methyl Salicylate', ...), ...]
|
|
65
|
+
|
|
66
|
+
pubchem.substructure_search("c1ccccc1", query_type="smiles") # every compound containing a benzene ring
|
|
67
|
+
pubchem.substructure_search("[#6]1[#6][#6][#6][#6][#6]1", query_type="smarts") # the SMARTS equivalent
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`scigantic-chembl`'s `similar_compounds()`/`substructure_search()` precompute fingerprints once and search them locally: fast, but bounded to the roughly 1.68M ChEMBL compounds that carry a comparable measurement. This runs the search on PubChem's own servers, live, over the full ~120M-compound corpus, verified sub-second for a typical query and with no local fingerprint database to build or hold in memory. PubChemPy exposes the same PUG REST capability as a raw `searchtype="similarity"`/`"substructure"` parameter to its generic `get_compounds()`; this package gives it a named function, and keeps `query_type="smiles"` and `"smarts"` as separate, explicit paths rather than guessing between them, since they're genuinely different endpoints with different matching semantics (verified live: the same ring given as SMILES versus SMARTS returns overlapping but not identical results).
|
|
71
|
+
|
|
72
|
+
An expensive search can respond asynchronously, with PubChem handing back a job to poll rather than blocking the connection; handled transparently, using the same protocol PubChemPy implements. Every live query tried during development resolved synchronously, even a maximally broad single-carbon substructure search, so the polling loop itself is verified with a scripted mock response sequence rather than left checked only against documentation.
|
|
73
|
+
|
|
74
|
+
## Thread safety
|
|
75
|
+
|
|
76
|
+
Safe to call from multiple threads, a plausible real pattern: resolving a list of names via a `ThreadPoolExecutor`, say. The shared HTTP session is created once behind a lock rather than raced into existence by whichever thread gets there first. `enable_cache()`/`disable_cache()` are not synchronized against concurrent reads, the same way mutating `os.environ` isn't: call them once at the start of a script, not from multiple threads at once.
|
|
77
|
+
|
|
78
|
+
## Live bridge into scigantic-chembl and scigantic-bindingdb
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
pubchem.chembl_context(2244)
|
|
82
|
+
# {'molregno': ..., 'chembl_id': 'CHEMBL25', 'pref_name': 'ASPIRIN', 'max_phase': 4}
|
|
83
|
+
|
|
84
|
+
pubchem.bindingdb_measurements(2244)
|
|
85
|
+
# DataFrame of every BindingDB measurement recorded against this CID
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Both are on-demand DuckDB queries against the public [scigantic-chembl](https://github.com/Scigantic/scigantic-chembl) and [scigantic-bindingdb](https://github.com/Scigantic/scigantic-bindingdb) mirrors. `chembl_context` resolves the ChEMBL ID live (see above), then looks up its assay context; `bindingdb_measurements` filters BindingDB's own `pubchem_cid` column directly, since BindingDB already carries that mapping natively. Neither needs a precomputed bridge table, and neither goes stale the way a static one would. Needs `duckdb`:
|
|
89
|
+
|
|
90
|
+
```console
|
|
91
|
+
$ pip install "scigantic-pubchem[bridge]"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Batch resolution
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
compounds = pubchem.resolve_many([2244, 2519, 1983]) # aspirin, caffeine, acetaminophen
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
PUG REST accepts a comma-separated CID list in a single request (verified live); this chunks at 200 CIDs per call rather than sending one unbounded URL for a long list.
|
|
101
|
+
|
|
102
|
+
## Command line
|
|
103
|
+
|
|
104
|
+
```console
|
|
105
|
+
$ scigantic-pubchem resolve aspirin
|
|
106
|
+
$ scigantic-pubchem chembl-id 2244
|
|
107
|
+
$ scigantic-pubchem xrefs 2244 --type RegistryID
|
|
108
|
+
$ scigantic-pubchem similar "CC(=O)OC1=CC=CC=C1C(=O)O" --threshold 95
|
|
109
|
+
$ scigantic-pubchem substructure c1ccccc1
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT-0. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scigantic-pubchem"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Query PubChem live via PUG REST, with rate-limit-aware retry, expiring cache, and live similarity/substructure/cross-reference search PubChemPy doesn't have."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT-0" }
|
|
12
|
+
authors = [{ name = "Scigantic" }]
|
|
13
|
+
keywords = ["pubchem", "cheminformatics", "pug-rest", "drug-discovery", "identifier-resolution"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT No Attribution License (MIT-0)",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Chemistry",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
dependencies = [
|
|
28
|
+
"requests>=2.28",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
# duckdb is only needed for chembl_context()/bindingdb_measurements(), the
|
|
33
|
+
# live cross-references into scigantic-chembl/scigantic-bindingdb; every
|
|
34
|
+
# other function in this package works without it.
|
|
35
|
+
bridge = ["duckdb>=0.10", "pandas>=1.5"]
|
|
36
|
+
dev = ["pytest>=7", "mypy>=1.10", "types-requests", "pandas-stubs"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://scigantic.com"
|
|
40
|
+
Repository = "https://github.com/Scigantic/scigantic-pubchem"
|
|
41
|
+
Issues = "https://github.com/Scigantic/scigantic-pubchem/issues"
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
scigantic-pubchem = "scigantic_pubchem.cli:main"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
scigantic_pubchem = ["py.typed"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
strict = true
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Query PubChem live via PUG REST, with caching and rate-limit-aware
|
|
2
|
+
retry PubChemPy doesn't have, plus live cross-references into
|
|
3
|
+
scigantic-chembl and scigantic-bindingdb. No mirror, no download."""
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
6
|
+
|
|
7
|
+
from .bridge import bindingdb_measurements, chembl_context
|
|
8
|
+
from .cache import cache_dir, disable_cache, enable_cache, is_cache_enabled
|
|
9
|
+
from .cache import clear as clear_cache
|
|
10
|
+
from .models import Compound
|
|
11
|
+
from .resolve import resolve, resolve_many
|
|
12
|
+
from .similarity import similar_compounds, substructure_search
|
|
13
|
+
from .xrefs import chembl_id, xrefs
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
__version__ = _version("scigantic-pubchem")
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
# Running from a source checkout with no install (editable or not).
|
|
19
|
+
__version__ = "0.0.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"resolve",
|
|
23
|
+
"resolve_many",
|
|
24
|
+
"similar_compounds",
|
|
25
|
+
"substructure_search",
|
|
26
|
+
"xrefs",
|
|
27
|
+
"chembl_id",
|
|
28
|
+
"chembl_context",
|
|
29
|
+
"bindingdb_measurements",
|
|
30
|
+
"Compound",
|
|
31
|
+
"enable_cache",
|
|
32
|
+
"disable_cache",
|
|
33
|
+
"is_cache_enabled",
|
|
34
|
+
"cache_dir",
|
|
35
|
+
"clear_cache",
|
|
36
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""HTTP client for PubChem's PUG REST API, with the two things PubChemPy
|
|
2
|
+
does not do: respect PubChem's own live rate-limit signal, and retry
|
|
3
|
+
transient failures instead of raising immediately.
|
|
4
|
+
|
|
5
|
+
Verified 2026-08-27: every PUG REST response carries an X-Throttling-Control
|
|
6
|
+
header reporting live status across three dimensions, e.g.
|
|
7
|
+
Request Count status: Green (0%), Request Time status: Green (0%), Service status: Green (27%)
|
|
8
|
+
This is PubChem's own documented, real-time signal for how close a caller is
|
|
9
|
+
to being throttled. PubChemPy 1.0.5's source (inspected directly, not
|
|
10
|
+
assumed) never reads this header at all: on an HTTPError it raises
|
|
11
|
+
immediately with no retry, and it has no rate-awareness beyond that. Every
|
|
12
|
+
call here reads the header and backs off proactively when status is not
|
|
13
|
+
"Green" on any dimension, rather than waiting to be rejected.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import warnings
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
from . import cache
|
|
27
|
+
|
|
28
|
+
_BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
|
|
29
|
+
_USER_AGENT = "scigantic-pubchem/0.1.0 (+https://scigantic.com; mailto:support@scigantic.com)"
|
|
30
|
+
|
|
31
|
+
_THROTTLE_RE = re.compile(r"(\w[\w ]*?) status: (Green|Yellow|Red) \((\d+)%\)")
|
|
32
|
+
|
|
33
|
+
# Backoff seconds by worst observed status across the three dimensions.
|
|
34
|
+
_BACKOFF_SECONDS = {"Green": 0.0, "Yellow": 2.0, "Red": 10.0}
|
|
35
|
+
|
|
36
|
+
_MAX_RETRIES = 5
|
|
37
|
+
_RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PubChemError(Exception):
|
|
41
|
+
"""Raised for a PUG REST error response (4xx/5xx after retries exhausted)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CompoundNotFoundError(PubChemError):
|
|
45
|
+
"""Raised for a 404 (PUGREST.NotFound): a real, expected outcome for
|
|
46
|
+
an unmatched identifier, not a transient failure. Verified live
|
|
47
|
+
2026-08-27: PUG REST returns HTTP 404 with a JSON Fault body for a
|
|
48
|
+
miss, not an empty 200. resolve() catches this and returns None
|
|
49
|
+
rather than letting every caller handle the exception itself."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_throttle_header(value: str | None) -> str:
|
|
53
|
+
"""Worst status ('Green'/'Yellow'/'Red') across all reported dimensions."""
|
|
54
|
+
if not value:
|
|
55
|
+
return "Green"
|
|
56
|
+
statuses = [m.group(2) for m in _THROTTLE_RE.finditer(value)]
|
|
57
|
+
if "Red" in statuses:
|
|
58
|
+
return "Red"
|
|
59
|
+
if "Yellow" in statuses:
|
|
60
|
+
return "Yellow"
|
|
61
|
+
return "Green"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_session: requests.Session | None = None
|
|
65
|
+
_session_lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_session() -> requests.Session:
|
|
69
|
+
"""Lazily create the shared Session under a lock, so two threads
|
|
70
|
+
resolving identifiers concurrently (a real, plausible pattern: a
|
|
71
|
+
ThreadPoolExecutor over a list of names) can't both see _session as
|
|
72
|
+
None and each construct one, leaking a connection pool. requests.Session
|
|
73
|
+
itself is documented thread-safe for issuing requests once constructed;
|
|
74
|
+
only the lazy-init race needed guarding."""
|
|
75
|
+
global _session
|
|
76
|
+
if _session is None:
|
|
77
|
+
with _session_lock:
|
|
78
|
+
if _session is None: # re-check: another thread may have won the race
|
|
79
|
+
_session = requests.Session()
|
|
80
|
+
_session.headers["User-Agent"] = _USER_AGENT
|
|
81
|
+
return _session
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def request(
|
|
85
|
+
path: str,
|
|
86
|
+
params: dict[str, Any] | None = None,
|
|
87
|
+
method: str = "GET",
|
|
88
|
+
cacheable: bool = True,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""GET (or POST, for long identifier lists) a PUG REST path and return
|
|
91
|
+
the parsed JSON body.
|
|
92
|
+
|
|
93
|
+
path is appended to https://pubchem.ncbi.nlm.nih.gov/rest/pug, e.g.
|
|
94
|
+
"/compound/name/aspirin/property/MolecularWeight/JSON".
|
|
95
|
+
|
|
96
|
+
Backs off proactively based on X-Throttling-Control before it becomes a
|
|
97
|
+
429, and retries 429/5xx with exponential backoff (PubChemPy does
|
|
98
|
+
neither) rather than raising on the first transient failure.
|
|
99
|
+
|
|
100
|
+
Cached (see cache.py) keyed by (path, params) when caching is enabled
|
|
101
|
+
(the default) and cacheable=True (the default). cacheable=False is for
|
|
102
|
+
request_search()'s polling calls specifically: caching an intermediate
|
|
103
|
+
"still waiting" response under the search's own (path, params) key
|
|
104
|
+
would mean a later, unrelated call to the same search replays that
|
|
105
|
+
stale in-progress state instead of making a fresh request.
|
|
106
|
+
"""
|
|
107
|
+
if method == "GET" and cacheable:
|
|
108
|
+
cached = cache.get(path, params)
|
|
109
|
+
if cached is not None:
|
|
110
|
+
return cached
|
|
111
|
+
|
|
112
|
+
session = _get_session()
|
|
113
|
+
url = f"{_BASE}{path}"
|
|
114
|
+
last_exc: Exception | None = None
|
|
115
|
+
|
|
116
|
+
for attempt in range(_MAX_RETRIES):
|
|
117
|
+
try:
|
|
118
|
+
if method == "POST":
|
|
119
|
+
response = session.post(url, data=params, timeout=30)
|
|
120
|
+
else:
|
|
121
|
+
response = session.get(url, params=params, timeout=30)
|
|
122
|
+
except requests.RequestException as exc:
|
|
123
|
+
last_exc = exc
|
|
124
|
+
time.sleep(2 ** attempt)
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
status = _parse_throttle_header(response.headers.get("X-Throttling-Control"))
|
|
128
|
+
if response.status_code in _RETRY_STATUS_CODES and attempt < _MAX_RETRIES - 1:
|
|
129
|
+
wait = max(_BACKOFF_SECONDS[status], 2 ** attempt)
|
|
130
|
+
warnings.warn(
|
|
131
|
+
f"PUG REST returned {response.status_code} (throttle status {status}); "
|
|
132
|
+
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})",
|
|
133
|
+
stacklevel=2,
|
|
134
|
+
)
|
|
135
|
+
time.sleep(wait)
|
|
136
|
+
continue
|
|
137
|
+
if response.status_code == 404:
|
|
138
|
+
raise CompoundNotFoundError(f"no match for {url}")
|
|
139
|
+
if response.status_code >= 400:
|
|
140
|
+
raise PubChemError(f"PUG REST {response.status_code} for {url}: {response.text[:500]}")
|
|
141
|
+
|
|
142
|
+
if status != "Green":
|
|
143
|
+
# Proactive backoff even on success, so a run of calls doesn't
|
|
144
|
+
# walk itself into a 429 a few requests later.
|
|
145
|
+
time.sleep(_BACKOFF_SECONDS[status])
|
|
146
|
+
body: dict[str, Any] = response.json()
|
|
147
|
+
if method == "GET" and cacheable:
|
|
148
|
+
cache.put(path, params, body)
|
|
149
|
+
return body
|
|
150
|
+
|
|
151
|
+
raise PubChemError(f"PUG REST request failed after {_MAX_RETRIES} attempts: {last_exc}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def request_search(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
155
|
+
"""Like request(), for PUG REST's search-type endpoints (fastsimilarity_2d,
|
|
156
|
+
fastsubstructure, fastformula, ...), which can respond either
|
|
157
|
+
immediately or asynchronously.
|
|
158
|
+
|
|
159
|
+
A slow search returns {"Waiting": {"ListKey": "..."}} instead of a
|
|
160
|
+
result, and this polls /compound/listkey/{key}/cids/JSON every 2s until
|
|
161
|
+
the real result is ready, the same protocol PubChemPy implements (its
|
|
162
|
+
source was read directly to confirm this, not assumed from docs).
|
|
163
|
+
|
|
164
|
+
Neither the initial call nor the polling calls are cached
|
|
165
|
+
(cacheable=False): a ListKey is single-use, and caching an
|
|
166
|
+
intermediate "still waiting" response under the search's own
|
|
167
|
+
(path, params) key would mean a later, unrelated call to the same
|
|
168
|
+
search replays that stale in-progress state instead of making a fresh
|
|
169
|
+
request. Callers that want to avoid repeat searches should cache the
|
|
170
|
+
resolved CIDs themselves.
|
|
171
|
+
"""
|
|
172
|
+
body = request(path, params, method="GET", cacheable=False)
|
|
173
|
+
while "Waiting" in body and "ListKey" in body.get("Waiting", {}):
|
|
174
|
+
time.sleep(2)
|
|
175
|
+
listkey = body["Waiting"]["ListKey"]
|
|
176
|
+
body = request(f"/compound/listkey/{listkey}/cids/JSON", method="GET", cacheable=False)
|
|
177
|
+
return body
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Cross-reference a PubChem compound into scigantic-chembl and
|
|
2
|
+
scigantic-bindingdb. Both are live, on-demand DuckDB httpfs queries against
|
|
3
|
+
those public mirrors, not a precomputed table shipped with this package.
|
|
4
|
+
|
|
5
|
+
Needs duckdb: `pip install "scigantic-pubchem[bridge]"`. Everything else in
|
|
6
|
+
this package works without it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from .xrefs import chembl_id as _chembl_id
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import duckdb
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
_CHEMBL_BUCKET = "scigantic-chembl"
|
|
20
|
+
_BINDINGDB_BUCKET = "scigantic-bindingdb"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _connect() -> "duckdb.DuckDBPyConnection":
|
|
24
|
+
try:
|
|
25
|
+
import duckdb
|
|
26
|
+
except ImportError as exc:
|
|
27
|
+
raise ImportError(
|
|
28
|
+
"this needs duckdb. Install with: pip install 'scigantic-pubchem[bridge]'"
|
|
29
|
+
) from exc
|
|
30
|
+
con = duckdb.connect()
|
|
31
|
+
con.execute("SET enable_progress_bar=false")
|
|
32
|
+
con.execute("INSTALL httpfs")
|
|
33
|
+
con.execute("LOAD httpfs")
|
|
34
|
+
con.execute("SET s3_region='us-east-1'")
|
|
35
|
+
con.execute("CREATE OR REPLACE SECRET anon (TYPE s3, PROVIDER config, KEY_ID '', SECRET '')")
|
|
36
|
+
return con
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def chembl_context(
|
|
40
|
+
identifier: str | int,
|
|
41
|
+
namespace: str = "cid",
|
|
42
|
+
chembl_release: str = "chembl_37",
|
|
43
|
+
) -> dict[str, Any] | None:
|
|
44
|
+
"""ChEMBL context for a PubChem compound: resolves the ChEMBL ID via a
|
|
45
|
+
live PubChem cross-reference (see xrefs.chembl_id), then looks up that
|
|
46
|
+
compound's pref_name, max_phase and molregno in scigantic-chembl.
|
|
47
|
+
|
|
48
|
+
Returns None if PubChem has no ChEMBL cross-reference for this
|
|
49
|
+
identifier, or if that ChEMBL ID isn't in this mirror's release.
|
|
50
|
+
"""
|
|
51
|
+
cid = _chembl_id(identifier, namespace=namespace)
|
|
52
|
+
if cid is None:
|
|
53
|
+
return None
|
|
54
|
+
con = _connect()
|
|
55
|
+
mol = f"s3://{_CHEMBL_BUCKET}/{chembl_release}/parquet/molecule_dictionary.parquet"
|
|
56
|
+
row = con.execute(
|
|
57
|
+
f"SELECT molregno, chembl_id, pref_name, max_phase FROM read_parquet('{mol}') "
|
|
58
|
+
"WHERE chembl_id = ?",
|
|
59
|
+
[cid],
|
|
60
|
+
).fetchone()
|
|
61
|
+
if row is None:
|
|
62
|
+
return {"chembl_id": cid, "molregno": None, "pref_name": None, "max_phase": None}
|
|
63
|
+
return {"molregno": row[0], "chembl_id": row[1], "pref_name": row[2], "max_phase": row[3]}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def bindingdb_measurements(
|
|
67
|
+
cid: int,
|
|
68
|
+
bindingdb_release: str = "202608",
|
|
69
|
+
) -> "pd.DataFrame":
|
|
70
|
+
"""Binding measurements scigantic-bindingdb has for this PubChem CID.
|
|
71
|
+
|
|
72
|
+
BindingDB's own measurements table already carries a pubchem_cid column,
|
|
73
|
+
so this is a live filter against it, not a precomputed join, and it
|
|
74
|
+
reflects the mirror as it stands with no separate build step.
|
|
75
|
+
"""
|
|
76
|
+
con = _connect()
|
|
77
|
+
path = f"s3://{_BINDINGDB_BUCKET}/{bindingdb_release}/parquet/measurements.parquet"
|
|
78
|
+
return con.execute(
|
|
79
|
+
f"SELECT reactant_set_id, ligand_smiles, ki_nm_value, ic50_nm_value, "
|
|
80
|
+
f"kd_nm_value, ec50_nm_value, curation_source "
|
|
81
|
+
f"FROM read_parquet('{path}') WHERE pubchem_cid = ?",
|
|
82
|
+
[cid],
|
|
83
|
+
).df()
|