Skip to content

Commit 4b74c43

Browse files
committed
Fixed several sanitizing regex'
Added an option for cas retrieval from CAS commonchemistry.cas.org Needs an API key in env CAS_API_KEY
1 parent b7091df commit 4b74c43

3 files changed

Lines changed: 125 additions & 6 deletions

File tree

developer/autocomplete_metadata.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- ChEMBL
99
- ChEBI
1010
- PubChem
11+
- CAS Common Chemistry (requires the ``CAS_API_KEY`` environment variable)
1112
1213
.. note::
1314
This file is meant to be used by automated workflows.
@@ -17,6 +18,7 @@
1718
import os
1819
import re
1920
import sys
21+
import urllib.parse
2022
import urllib.request
2123
from html import unescape
2224

@@ -91,6 +93,43 @@ def get_chebi(chebi_id):
9193
return {}
9294

9395

96+
def get_metabolights(chebi_id):
97+
# MetaboLights reference compounds use the accession MTBLC<chebi numeric id>.
98+
# Return the identifier only if the compound exists in MetaboLights.
99+
if not chebi_id:
100+
return ""
101+
mtbl_id = f"MTBLC{chebi_id}"
102+
if check_api(f"https://www.ebi.ac.uk/metabolights/ws/compounds/{mtbl_id}"):
103+
return mtbl_id
104+
return ""
105+
106+
107+
def get_cas(inchikey):
108+
# CAS Registry Numbers are not exposed by UniChem. They can be retrieved from
109+
# CAS Common Chemistry, which requires an API token supplied via the
110+
# CAS_API_KEY environment variable. Returns "" when the token is missing,
111+
# the service is unreachable, or no match is found.
112+
if not inchikey:
113+
return ""
114+
api_key = os.environ.get("CAS_API_KEY")
115+
if not api_key:
116+
return ""
117+
# CAS Common Chemistry requires field-qualified queries; a bare InChIKey
118+
# does not match, whereas "InChIKey=<value>" does.
119+
query = urllib.parse.quote(f"InChIKey={inchikey}")
120+
url = f"https://commonchemistry.cas.org/api/search?q={query}"
121+
try:
122+
req = urllib.request.Request(url, headers={"X-Api-Key": api_key})
123+
with urllib.request.urlopen(req, timeout=5) as response:
124+
if response.status == 200:
125+
results = json.loads(response.read().decode("utf-8")).get("results", [])
126+
if results:
127+
return results[0].get("rn", "") or ""
128+
except Exception:
129+
pass
130+
return ""
131+
132+
94133
def get_unichem(inchikey):
95134
url = "https://www.ebi.ac.uk/unichem/api/v1/compounds"
96135
if check_api("https://www.ebi.ac.uk/unichem/api/v1/sources"):
@@ -117,8 +156,9 @@ def extract_sameas(sources):
117156
"lipidmaps": "lipidmaps",
118157
"metabolights": "metabolights",
119158
"swisslipids": "slm",
120-
"pdb": "pdb.ligand",
121-
"unii": "unii",
159+
"rcsb_pdb": "pdb.ligand",
160+
"pdbe": "pdb.ligand",
161+
"fdasrs": "unii",
122162
"cas": "cas",
123163
}
124164
result = {}
@@ -127,7 +167,10 @@ def extract_sameas(sources):
127167
if prefix:
128168
value = src["compoundId"]
129169
if prefix == "ChEBI":
130-
value = f"CHEBI:{value}" if value else ""
170+
if value:
171+
value = value if str(value).startswith("CHEBI:") else f"CHEBI:{value}"
172+
else:
173+
value = ""
131174
elif prefix == "pubchem.compound":
132175
try:
133176
value = int(value)
@@ -177,10 +220,20 @@ def sanitize_sameas(sameas):
177220
try:
178221
sanitized[key] = int(value)
179222
except (TypeError, ValueError):
180-
pass
223+
print(
224+
f"Warning: discarding sameAs '{key}' value {value!r}: not a valid integer.",
225+
file=sys.stderr,
226+
)
181227
continue
182-
if isinstance(value, str) and re.match(patterns.get(key, r".+"), value):
228+
pattern = patterns.get(key, r".+")
229+
if isinstance(value, str) and re.match(pattern, value):
183230
sanitized[key] = value
231+
else:
232+
print(
233+
f"Warning: discarding sameAs '{key}' value {value!r}: does not match expected "
234+
f"pattern {pattern!r}.",
235+
file=sys.stderr,
236+
)
184237
return sanitized
185238

186239

@@ -239,6 +292,19 @@ def main():
239292
chebi_id = sameas.get("ChEBI", "").replace("CHEBI:", "")
240293
chebi_data = get_chebi(chebi_id) if chebi_id else {}
241294

295+
# MetaboLights is not exposed by UniChem; derive it from the ChEBI id.
296+
if chebi_id and "metabolights" not in sameas:
297+
metabolights_id = get_metabolights(chebi_id)
298+
if metabolights_id:
299+
sameas["metabolights"] = metabolights_id
300+
301+
# CAS Registry Numbers are not exposed by UniChem; fetch them from CAS
302+
# Common Chemistry (requires the CAS_API_KEY environment variable).
303+
if "cas" not in sameas:
304+
cas_rn = get_cas(inchikey)
305+
if cas_rn and re.match(r"^\d{1,7}-\d{2}-\d$", cas_rn):
306+
sameas["cas"] = cas_rn
307+
242308
# Collect alternate names with priority
243309
alternate_names = []
244310

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ markers = [
8686
"rdkit: tests features requiring RDKit installation",
8787
"all: run all tests",
8888
"min: run tests not requiring GROMACS installation",
89+
"network: tests that require live internet access to external APIs",
8990
]
9091

9192
[tool.ruff]

tests/test_autocomplete_metadata.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import importlib.util
22
import json
3+
import os
4+
import re
35
import sys
46
from pathlib import Path
57

8+
import pytest
69
import yaml
710
from jsonschema import Draft7Validator
811

@@ -49,7 +52,9 @@ def test_autocomplete_output_is_schema_compliant(tmp_path, monkeypatch):
4952
"get_unichem",
5053
lambda _: [
5154
{"shortName": "chembl", "compoundId": "CHEMBL446037"},
52-
{"shortName": "chebi", "compoundId": "1234"},
55+
{"shortName": "chebi", "compoundId": "CHEBI:1234"},
56+
{"shortName": "rcsb_pdb", "compoundId": "BOG"},
57+
{"shortName": "fdasrs", "compoundId": "V109WUT6RL"},
5358
],
5459
)
5560
monkeypatch.setattr(mod, "get_pubchem_synonyms", lambda _: [])
@@ -58,6 +63,8 @@ def test_autocomplete_output_is_schema_compliant(tmp_path, monkeypatch):
5863
"get_chebi",
5964
lambda _: {"names": {"SYNONYM": [{"type": "SYNONYM", "name": "1-<em>OD&lt;/small&gt;-glucopyranoside"}]}},
6065
)
66+
monkeypatch.setattr(mod, "get_metabolights", lambda _: "MTBLC1234")
67+
monkeypatch.setattr(mod, "get_cas", lambda _: "29836-26-8")
6168

6269
monkeypatch.setattr(sys, "argv", ["autocomplete_metadata.py", str(metadata_path)])
6370
mod.main()
@@ -79,3 +86,48 @@ def test_autocomplete_output_is_schema_compliant(tmp_path, monkeypatch):
7986
assert generated["NMRlipids"]["name"] == "(2R,3S)-name"
8087
assert generated["bioschema_properties"]["smiles"] == "CCCCCCCCOC@H]1[C@@HCO)O)O)O"
8188
assert generated["bioschema_properties"]["alternateName"] == ["1-OD-glucopyranoside"]
89+
assert generated["sameAs"]["ChEBI"] == "CHEBI:1234"
90+
assert generated["sameAs"]["pdb.ligand"] == "BOG"
91+
assert generated["sameAs"]["unii"] == "V109WUT6RL"
92+
assert generated["sameAs"]["metabolights"] == "MTBLC1234"
93+
assert generated["sameAs"]["cas"] == "29836-26-8"
94+
95+
96+
@pytest.mark.network
97+
def test_autocomplete_sameas_from_live_apis():
98+
"""Live end-to-end check that the real APIs yield the expected cross references.
99+
100+
Uses beta-octyl D-glucopyranoside (BOG). Skipped automatically when the
101+
external services are unreachable.
102+
"""
103+
mod = load_autocomplete_module()
104+
105+
inchikey = "HEGSGKPQLMEBJL-RKQHYHRCSA-N"
106+
107+
sources = mod.get_unichem(inchikey)
108+
if not sources:
109+
pytest.skip("UniChem API unreachable; skipping live network test.")
110+
111+
sameas = mod.sanitize_sameas(mod.extract_sameas(sources))
112+
chebi_id = sameas.get("ChEBI", "").replace("CHEBI:", "")
113+
if chebi_id and "metabolights" not in sameas:
114+
metabolights_id = mod.get_metabolights(chebi_id)
115+
if metabolights_id:
116+
sameas["metabolights"] = metabolights_id
117+
118+
expected = {
119+
"ChEBI": "CHEBI:41128",
120+
"pubchem.compound": 62852,
121+
"metabolights": "MTBLC41128",
122+
"pdb.ligand": "BOG",
123+
"ChEMBL": "CHEMBL446037",
124+
}
125+
for key, value in expected.items():
126+
assert sameas.get(key) == value, f"{key}: expected {value!r}, got {sameas.get(key)!r}"
127+
128+
# CAS Common Chemistry requires an API token; only verify when CAS_API_KEY is set.
129+
if os.environ.get("CAS_API_KEY"):
130+
cas_rn = mod.get_cas(inchikey)
131+
if cas_rn:
132+
assert re.match(r"^\d{1,7}-\d{2}-\d$", cas_rn), f"unexpected CAS format: {cas_rn!r}"
133+

0 commit comments

Comments
 (0)