cdxml-toolkit 0.5.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.
- cdxml_toolkit/__init__.py +18 -0
- cdxml_toolkit/_jre/__init__.py +2 -0
- cdxml_toolkit/_jre/temurin-21-jre-win-x64.zip +0 -0
- cdxml_toolkit/analysis/__init__.py +35 -0
- cdxml_toolkit/analysis/deterministic/__init__.py +12 -0
- cdxml_toolkit/analysis/deterministic/discover_experiment_files.py +413 -0
- cdxml_toolkit/analysis/deterministic/lab_book_formatter.py +701 -0
- cdxml_toolkit/analysis/deterministic/lcms_file_categorizer.py +928 -0
- cdxml_toolkit/analysis/deterministic/lcms_identifier.py +598 -0
- cdxml_toolkit/analysis/deterministic/mass_resolver.py +654 -0
- cdxml_toolkit/analysis/deterministic/multi_lcms_analyzer.py +1412 -0
- cdxml_toolkit/analysis/deterministic/procedure_writer.py +446 -0
- cdxml_toolkit/analysis/extract_nmr.py +47 -0
- cdxml_toolkit/analysis/format_procedure_entry.py +479 -0
- cdxml_toolkit/analysis/lcms_analyzer.py +1299 -0
- cdxml_toolkit/analysis/parse_analysis_file.py +134 -0
- cdxml_toolkit/cdxml_builder.py +920 -0
- cdxml_toolkit/cdxml_utils.py +342 -0
- cdxml_toolkit/chemdraw/__init__.py +5 -0
- cdxml_toolkit/chemdraw/_chemscript_server.py +562 -0
- cdxml_toolkit/chemdraw/cdx_converter.py +527 -0
- cdxml_toolkit/chemdraw/cdxml_to_image.py +262 -0
- cdxml_toolkit/chemdraw/cdxml_to_image_rdkit.py +296 -0
- cdxml_toolkit/chemdraw/chemscript_bridge.py +901 -0
- cdxml_toolkit/constants.py +304 -0
- cdxml_toolkit/coord_normalizer.py +438 -0
- cdxml_toolkit/deterministic_pipeline/__init__.py +6 -0
- cdxml_toolkit/deterministic_pipeline/legacy/__init__.py +5 -0
- cdxml_toolkit/deterministic_pipeline/legacy/eln_cdx_cleanup.py +509 -0
- cdxml_toolkit/deterministic_pipeline/legacy/eln_enrichment.py +1394 -0
- cdxml_toolkit/deterministic_pipeline/legacy/scheme_aligner.py +428 -0
- cdxml_toolkit/deterministic_pipeline/legacy/scheme_polisher.py +1337 -0
- cdxml_toolkit/deterministic_pipeline/legacy/scheme_polisher_v2.py +1340 -0
- cdxml_toolkit/deterministic_pipeline/scheme_reader_audit.py +931 -0
- cdxml_toolkit/deterministic_pipeline/scheme_reader_verify.py +1160 -0
- cdxml_toolkit/image/__init__.py +15 -0
- cdxml_toolkit/image/reaction_from_image.py +2103 -0
- cdxml_toolkit/image/structure_from_image.py +1711 -0
- cdxml_toolkit/layout/__init__.py +5 -0
- cdxml_toolkit/layout/alignment.py +1642 -0
- cdxml_toolkit/layout/reaction_cleanup.py +1002 -0
- cdxml_toolkit/layout/scheme_merger.py +2260 -0
- cdxml_toolkit/mcp_server/__init__.py +0 -0
- cdxml_toolkit/mcp_server/__main__.py +5 -0
- cdxml_toolkit/mcp_server/server.py +1567 -0
- cdxml_toolkit/naming/__init__.py +6 -0
- cdxml_toolkit/naming/aligned_namer.py +2342 -0
- cdxml_toolkit/naming/mol_builder.py +3722 -0
- cdxml_toolkit/naming/name_decomposer.py +2843 -0
- cdxml_toolkit/naming/reactions_datamol.json +2414 -0
- cdxml_toolkit/office/__init__.py +5 -0
- cdxml_toolkit/office/doc_from_template.py +722 -0
- cdxml_toolkit/office/ole_embedder.py +808 -0
- cdxml_toolkit/office/ole_extractor.py +272 -0
- cdxml_toolkit/perception/__init__.py +10 -0
- cdxml_toolkit/perception/compound_search.py +229 -0
- cdxml_toolkit/perception/eln_csv_parser.py +240 -0
- cdxml_toolkit/perception/rdf_parser.py +664 -0
- cdxml_toolkit/perception/reactant_heuristic.py +1045 -0
- cdxml_toolkit/perception/reaction_parser.py +2150 -0
- cdxml_toolkit/perception/scheme_reader.py +2948 -0
- cdxml_toolkit/perception/scheme_refine.py +1404 -0
- cdxml_toolkit/perception/scheme_segmenter.py +619 -0
- cdxml_toolkit/perception/spatial_assignment.py +1013 -0
- cdxml_toolkit/rdkit_utils.py +605 -0
- cdxml_toolkit/render/__init__.py +17 -0
- cdxml_toolkit/render/auto_layout.py +229 -0
- cdxml_toolkit/render/compact_parser.py +632 -0
- cdxml_toolkit/render/parser.py +706 -0
- cdxml_toolkit/render/render_scheme.py +267 -0
- cdxml_toolkit/render/renderer.py +2387 -0
- cdxml_toolkit/render/schema.py +90 -0
- cdxml_toolkit/render/scheme_maker.py +1043 -0
- cdxml_toolkit/render/scheme_yaml_writer.py +1487 -0
- cdxml_toolkit/resolve/__init__.py +13 -0
- cdxml_toolkit/resolve/cas_resolver.py +430 -0
- cdxml_toolkit/resolve/chemscanner_abbreviations.json +28813 -0
- cdxml_toolkit/resolve/condensed_formula.py +493 -0
- cdxml_toolkit/resolve/jre_manager.py +195 -0
- cdxml_toolkit/resolve/reagent_abbreviations.json +1046 -0
- cdxml_toolkit/resolve/reagent_db.py +285 -0
- cdxml_toolkit/resolve/superatom_data.json +2856 -0
- cdxml_toolkit/resolve/superatom_table.py +146 -0
- cdxml_toolkit/text_formatting.py +298 -0
- cdxml_toolkit-0.5.0.dist-info/METADATA +318 -0
- cdxml_toolkit-0.5.0.dist-info/RECORD +91 -0
- cdxml_toolkit-0.5.0.dist-info/WHEEL +5 -0
- cdxml_toolkit-0.5.0.dist-info/entry_points.txt +17 -0
- cdxml_toolkit-0.5.0.dist-info/licenses/LICENSE +21 -0
- cdxml_toolkit-0.5.0.dist-info/licenses/NOTICE.md +37 -0
- cdxml_toolkit-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Resolve — turning chemical names, formulae, and abbreviations into SMILES.
|
|
2
|
+
|
|
3
|
+
The 4-tier resolution chain and all supporting databases:
|
|
4
|
+
Tier 1: curated reagent database (~186 entries)
|
|
5
|
+
Tier 2: generative condensed-formula parser
|
|
6
|
+
Tier 3: OPSIN (via reactant_heuristic)
|
|
7
|
+
Tier 4: PubChem name/CAS lookup
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .reagent_db import get_reagent_db, ReagentDB
|
|
11
|
+
from .condensed_formula import resolve_condensed_formula
|
|
12
|
+
from .cas_resolver import resolve_name_to_smiles, resolve_cas
|
|
13
|
+
from .superatom_table import lookup_smiles, get_superatom_table
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CAS Number Resolver via PubChem PUG REST API
|
|
4
|
+
Resolves CAS numbers to compound name, MW, molecular formula, SMILES,
|
|
5
|
+
and optionally 2D coordinates.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python cas_resolver.py 534-17-8
|
|
9
|
+
python cas_resolver.py 534-17-8 51364-51-3 98327-87-8 123-91-1
|
|
10
|
+
python cas_resolver.py 534-17-8 --coords --output result.json
|
|
11
|
+
python cas_resolver.py --batch cas_list.txt --pretty
|
|
12
|
+
|
|
13
|
+
PubChem API docs:
|
|
14
|
+
https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/{CAS}/...
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import urllib.request
|
|
22
|
+
import urllib.error
|
|
23
|
+
from typing import Dict, Any, List, Optional
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# PubChem API base
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
PUBCHEM_BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
|
|
30
|
+
|
|
31
|
+
# Properties to request in a single call
|
|
32
|
+
PROPERTIES = "IUPACName,MolecularWeight,MolecularFormula,CanonicalSMILES,IsomericSMILES"
|
|
33
|
+
|
|
34
|
+
# Rate limiting: PubChem asks for max 5 requests/second
|
|
35
|
+
REQUEST_DELAY = 0.25 # seconds between requests
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Core resolution function (importable by other tools)
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def resolve_cas(cas: str, include_coords: bool = False) -> Optional[Dict[str, Any]]:
|
|
43
|
+
"""
|
|
44
|
+
Resolve a single CAS number via PubChem PUG REST.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
cas: CAS registry number (e.g. "534-17-8").
|
|
48
|
+
include_coords: If True, also fetch 2D atom coordinates.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Dict with keys: cas, name, mw, formula, smiles, isomeric_smiles,
|
|
52
|
+
and optionally coords_2d. Returns None if the lookup fails.
|
|
53
|
+
"""
|
|
54
|
+
if not cas or not _validate_cas(cas):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# Step 1: Get compound properties
|
|
58
|
+
props = _fetch_properties(cas)
|
|
59
|
+
if props is None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
result = {
|
|
63
|
+
"cas": cas,
|
|
64
|
+
"name": props.get("IUPACName", ""),
|
|
65
|
+
"mw": props.get("MolecularWeight"),
|
|
66
|
+
"formula": props.get("MolecularFormula", ""),
|
|
67
|
+
"smiles": (props.get("IsomericSMILES")
|
|
68
|
+
or props.get("CanonicalSMILES")
|
|
69
|
+
or props.get("SMILES")
|
|
70
|
+
or props.get("ConnectivitySMILES", "")),
|
|
71
|
+
"isomeric_smiles": (props.get("IsomericSMILES")
|
|
72
|
+
or props.get("SMILES", "")),
|
|
73
|
+
"cid": props.get("CID"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Convert MW to float
|
|
77
|
+
if result["mw"] is not None:
|
|
78
|
+
try:
|
|
79
|
+
result["mw"] = float(result["mw"])
|
|
80
|
+
except (ValueError, TypeError):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
# Step 2: Optionally get 2D coordinates
|
|
84
|
+
if include_coords and result.get("cid"):
|
|
85
|
+
coords = _fetch_2d_coords(result["cid"])
|
|
86
|
+
if coords:
|
|
87
|
+
result["coords_2d"] = coords
|
|
88
|
+
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# PubChem API helpers
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _validate_cas(cas: str) -> bool:
|
|
97
|
+
"""
|
|
98
|
+
Validate CAS number format (digits-digits-digit with check digit).
|
|
99
|
+
|
|
100
|
+
CAS format: up to 10 digits as XXX...X-YY-Z where Z is a check digit.
|
|
101
|
+
"""
|
|
102
|
+
import re
|
|
103
|
+
if not re.match(r'^\d{2,7}-\d{2}-\d$', cas):
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
# Verify check digit
|
|
107
|
+
digits_only = cas.replace("-", "")
|
|
108
|
+
check = int(digits_only[-1])
|
|
109
|
+
body = digits_only[:-1]
|
|
110
|
+
total = sum(int(d) * (i + 1) for i, d in enumerate(reversed(body)))
|
|
111
|
+
return total % 10 == check
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _fetch_properties(cas: str) -> Optional[Dict[str, Any]]:
|
|
115
|
+
"""Fetch compound properties from PubChem by CAS number."""
|
|
116
|
+
url = f"{PUBCHEM_BASE}/compound/name/{cas}/property/{PROPERTIES}/JSON"
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
req = urllib.request.Request(url)
|
|
120
|
+
req.add_header("User-Agent", "chem-tools/1.0 (cas_resolver.py)")
|
|
121
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
122
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
123
|
+
props_list = data.get("PropertyTable", {}).get("Properties", [])
|
|
124
|
+
if props_list:
|
|
125
|
+
return props_list[0]
|
|
126
|
+
except urllib.error.HTTPError as e:
|
|
127
|
+
if e.code == 404:
|
|
128
|
+
print(f" CAS {cas}: not found in PubChem", file=sys.stderr)
|
|
129
|
+
else:
|
|
130
|
+
print(f" CAS {cas}: HTTP error {e.code}", file=sys.stderr)
|
|
131
|
+
except urllib.error.URLError as e:
|
|
132
|
+
print(f" CAS {cas}: connection error — {e.reason}", file=sys.stderr)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
print(f" CAS {cas}: unexpected error — {e}", file=sys.stderr)
|
|
135
|
+
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _fetch_2d_coords(cid: int) -> Optional[Dict[str, Any]]:
|
|
140
|
+
"""
|
|
141
|
+
Fetch 2D atom coordinates from PubChem SDF and parse into a dict.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Dict with 'atoms' list of {symbol, x, y} and 'bonds' list of
|
|
145
|
+
{atom1, atom2, order}.
|
|
146
|
+
"""
|
|
147
|
+
url = f"{PUBCHEM_BASE}/compound/cid/{cid}/record/SDF/?record_type=2d"
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
req = urllib.request.Request(url)
|
|
151
|
+
req.add_header("User-Agent", "chem-tools/1.0 (cas_resolver.py)")
|
|
152
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
153
|
+
sdf_text = resp.read().decode("utf-8")
|
|
154
|
+
return _parse_sdf_coords(sdf_text)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
print(f" CID {cid}: could not fetch 2D coords — {e}", file=sys.stderr)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_sdf_coords(sdf_text: str) -> Optional[Dict[str, Any]]:
|
|
161
|
+
"""
|
|
162
|
+
Parse atom coordinates and bonds from an SDF/MOL block.
|
|
163
|
+
Handles both V2000 and V3000 formats.
|
|
164
|
+
"""
|
|
165
|
+
lines = sdf_text.strip().split("\n")
|
|
166
|
+
|
|
167
|
+
atoms = []
|
|
168
|
+
bonds = []
|
|
169
|
+
|
|
170
|
+
# Find counts line (line 4 in V2000, or look for V3000 marker)
|
|
171
|
+
if any("V3000" in line for line in lines[:10]):
|
|
172
|
+
return _parse_v3000_sdf(lines)
|
|
173
|
+
|
|
174
|
+
# V2000 parsing
|
|
175
|
+
counts_line = None
|
|
176
|
+
counts_idx = None
|
|
177
|
+
for i, line in enumerate(lines):
|
|
178
|
+
if "V2000" in line:
|
|
179
|
+
counts_line = line
|
|
180
|
+
counts_idx = i
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
if counts_line is None or counts_idx is None:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
# Parse counts
|
|
187
|
+
num_atoms = int(counts_line[:3].strip())
|
|
188
|
+
num_bonds = int(counts_line[3:6].strip())
|
|
189
|
+
|
|
190
|
+
# Atom block starts right after counts line
|
|
191
|
+
for i in range(counts_idx + 1, counts_idx + 1 + num_atoms):
|
|
192
|
+
if i >= len(lines):
|
|
193
|
+
break
|
|
194
|
+
parts = lines[i].split()
|
|
195
|
+
if len(parts) >= 4:
|
|
196
|
+
x = float(parts[0])
|
|
197
|
+
y = float(parts[1])
|
|
198
|
+
symbol = parts[3]
|
|
199
|
+
atoms.append({"symbol": symbol, "x": round(x, 4), "y": round(y, 4)})
|
|
200
|
+
|
|
201
|
+
# Bond block
|
|
202
|
+
bond_start = counts_idx + 1 + num_atoms
|
|
203
|
+
for i in range(bond_start, bond_start + num_bonds):
|
|
204
|
+
if i >= len(lines):
|
|
205
|
+
break
|
|
206
|
+
parts = lines[i].split()
|
|
207
|
+
if len(parts) >= 3:
|
|
208
|
+
a1 = int(parts[0])
|
|
209
|
+
a2 = int(parts[1])
|
|
210
|
+
order = int(parts[2])
|
|
211
|
+
bonds.append({"atom1": a1, "atom2": a2, "order": order})
|
|
212
|
+
|
|
213
|
+
if not atoms:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
return {"atoms": atoms, "bonds": bonds}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _parse_v3000_sdf(lines: List[str]) -> Optional[Dict[str, Any]]:
|
|
220
|
+
"""Parse V3000 format SDF for 2D coords."""
|
|
221
|
+
atoms = []
|
|
222
|
+
bonds = []
|
|
223
|
+
in_atom = False
|
|
224
|
+
in_bond = False
|
|
225
|
+
|
|
226
|
+
for line in lines:
|
|
227
|
+
stripped = line.strip()
|
|
228
|
+
if "BEGIN ATOM" in stripped:
|
|
229
|
+
in_atom = True
|
|
230
|
+
continue
|
|
231
|
+
elif "END ATOM" in stripped:
|
|
232
|
+
in_atom = False
|
|
233
|
+
continue
|
|
234
|
+
elif "BEGIN BOND" in stripped:
|
|
235
|
+
in_bond = True
|
|
236
|
+
continue
|
|
237
|
+
elif "END BOND" in stripped:
|
|
238
|
+
in_bond = False
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
if in_atom and stripped.startswith("M V30"):
|
|
242
|
+
parts = stripped[6:].split()
|
|
243
|
+
if len(parts) >= 5:
|
|
244
|
+
symbol = parts[1]
|
|
245
|
+
x = float(parts[2])
|
|
246
|
+
y = float(parts[3])
|
|
247
|
+
atoms.append({"symbol": symbol, "x": round(x, 4), "y": round(y, 4)})
|
|
248
|
+
|
|
249
|
+
elif in_bond and stripped.startswith("M V30"):
|
|
250
|
+
parts = stripped[6:].split()
|
|
251
|
+
if len(parts) >= 4:
|
|
252
|
+
order = int(parts[1])
|
|
253
|
+
a1 = int(parts[2])
|
|
254
|
+
a2 = int(parts[3])
|
|
255
|
+
bonds.append({"atom1": a1, "atom2": a2, "order": order})
|
|
256
|
+
|
|
257
|
+
if not atoms:
|
|
258
|
+
return None
|
|
259
|
+
return {"atoms": atoms, "bonds": bonds}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Batch resolution
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def resolve_batch(cas_list: List[str], include_coords: bool = False,
|
|
267
|
+
delay: float = REQUEST_DELAY) -> List[Dict[str, Any]]:
|
|
268
|
+
"""
|
|
269
|
+
Resolve a list of CAS numbers with rate limiting.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
cas_list: List of CAS numbers.
|
|
273
|
+
include_coords: Whether to fetch 2D coordinates.
|
|
274
|
+
delay: Seconds between API requests.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
List of result dicts (None entries for failed lookups are excluded).
|
|
278
|
+
"""
|
|
279
|
+
results = []
|
|
280
|
+
for i, cas in enumerate(cas_list):
|
|
281
|
+
cas = cas.strip()
|
|
282
|
+
if not cas:
|
|
283
|
+
continue
|
|
284
|
+
print(f"Resolving {cas} ({i+1}/{len(cas_list)})...", file=sys.stderr)
|
|
285
|
+
result = resolve_cas(cas, include_coords=include_coords)
|
|
286
|
+
if result:
|
|
287
|
+
results.append(result)
|
|
288
|
+
else:
|
|
289
|
+
results.append({"cas": cas, "error": "not found or lookup failed"})
|
|
290
|
+
if i < len(cas_list) - 1:
|
|
291
|
+
time.sleep(delay)
|
|
292
|
+
return results
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# Name → SMILES lookup (common name, abbreviation, or IUPAC)
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
_last_request_time: float = 0.0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _rate_limit() -> None:
|
|
303
|
+
"""Enforce PubChem rate limit (max 5 req/sec) across all calls."""
|
|
304
|
+
global _last_request_time
|
|
305
|
+
elapsed = time.time() - _last_request_time
|
|
306
|
+
if elapsed < REQUEST_DELAY:
|
|
307
|
+
time.sleep(REQUEST_DELAY - elapsed)
|
|
308
|
+
_last_request_time = time.time()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def resolve_name_to_smiles(name: str) -> Optional[str]:
|
|
312
|
+
"""
|
|
313
|
+
Resolve a chemical name to a canonical SMILES via PubChem PUG REST.
|
|
314
|
+
|
|
315
|
+
Works with common names, trade names, and abbreviations
|
|
316
|
+
(e.g. "BINAP", "Cs2CO3", "dioxane", "triethylamine").
|
|
317
|
+
|
|
318
|
+
Returns a SMILES string, or None on failure.
|
|
319
|
+
"""
|
|
320
|
+
import urllib.parse
|
|
321
|
+
|
|
322
|
+
_rate_limit()
|
|
323
|
+
encoded = urllib.parse.quote(name, safe="")
|
|
324
|
+
# Request IsomericSMILES (preserves isotopes, stereochemistry) with
|
|
325
|
+
# CanonicalSMILES as fallback.
|
|
326
|
+
url = (f"{PUBCHEM_BASE}/compound/name/{encoded}"
|
|
327
|
+
f"/property/IsomericSMILES,CanonicalSMILES/JSON")
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
req = urllib.request.Request(url)
|
|
331
|
+
req.add_header("User-Agent", "chem-tools/1.0 (cas_resolver.py)")
|
|
332
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
333
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
334
|
+
props = data.get("PropertyTable", {}).get("Properties", [])
|
|
335
|
+
if props:
|
|
336
|
+
p = props[0]
|
|
337
|
+
# Prefer IsomericSMILES — preserves isotope labels (e.g.
|
|
338
|
+
# deuterium in deucravacitinib) and stereochemistry.
|
|
339
|
+
smiles = (p.get("IsomericSMILES")
|
|
340
|
+
or p.get("CanonicalSMILES")
|
|
341
|
+
or p.get("SMILES")
|
|
342
|
+
or p.get("ConnectivitySMILES"))
|
|
343
|
+
if smiles:
|
|
344
|
+
return smiles
|
|
345
|
+
except urllib.error.HTTPError as e:
|
|
346
|
+
if e.code != 404:
|
|
347
|
+
print(f" PubChem name lookup '{name}': HTTP {e.code}",
|
|
348
|
+
file=sys.stderr)
|
|
349
|
+
except urllib.error.URLError as e:
|
|
350
|
+
print(f" PubChem name lookup '{name}': connection error — {e.reason}",
|
|
351
|
+
file=sys.stderr)
|
|
352
|
+
except Exception as e:
|
|
353
|
+
print(f" PubChem name lookup '{name}': error — {e}", file=sys.stderr)
|
|
354
|
+
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# CLI
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
def main(argv=None) -> int:
|
|
363
|
+
parser = argparse.ArgumentParser(
|
|
364
|
+
description="Resolve CAS numbers to compound info via PubChem API."
|
|
365
|
+
)
|
|
366
|
+
parser.add_argument(
|
|
367
|
+
"cas_numbers",
|
|
368
|
+
nargs="*",
|
|
369
|
+
help="One or more CAS numbers to resolve (e.g. 534-17-8 123-91-1)",
|
|
370
|
+
)
|
|
371
|
+
parser.add_argument(
|
|
372
|
+
"--batch", "-b",
|
|
373
|
+
help="Text file with one CAS number per line",
|
|
374
|
+
)
|
|
375
|
+
parser.add_argument(
|
|
376
|
+
"--coords",
|
|
377
|
+
action="store_true",
|
|
378
|
+
help="Also fetch 2D atom coordinates from PubChem",
|
|
379
|
+
)
|
|
380
|
+
parser.add_argument(
|
|
381
|
+
"--output", "-o",
|
|
382
|
+
help="Output JSON file (default: print to stdout)",
|
|
383
|
+
)
|
|
384
|
+
parser.add_argument(
|
|
385
|
+
"--pretty",
|
|
386
|
+
action="store_true",
|
|
387
|
+
help="Pretty-print JSON output",
|
|
388
|
+
)
|
|
389
|
+
args = parser.parse_args(argv)
|
|
390
|
+
|
|
391
|
+
# Collect CAS numbers from args and/or batch file
|
|
392
|
+
cas_list = list(args.cas_numbers) if args.cas_numbers else []
|
|
393
|
+
|
|
394
|
+
if args.batch:
|
|
395
|
+
with open(args.batch, "r") as f:
|
|
396
|
+
for line in f:
|
|
397
|
+
cas = line.strip()
|
|
398
|
+
if cas and not cas.startswith("#"):
|
|
399
|
+
cas_list.append(cas)
|
|
400
|
+
|
|
401
|
+
if not cas_list:
|
|
402
|
+
parser.error("No CAS numbers provided. Use positional args or --batch.")
|
|
403
|
+
|
|
404
|
+
# Resolve
|
|
405
|
+
if len(cas_list) == 1:
|
|
406
|
+
result = resolve_cas(cas_list[0], include_coords=args.coords)
|
|
407
|
+
if result is None:
|
|
408
|
+
print(f"Could not resolve CAS {cas_list[0]}", file=sys.stderr)
|
|
409
|
+
return 1
|
|
410
|
+
output = result
|
|
411
|
+
else:
|
|
412
|
+
output = resolve_batch(cas_list, include_coords=args.coords)
|
|
413
|
+
|
|
414
|
+
# Output
|
|
415
|
+
indent = 2 if args.pretty else None
|
|
416
|
+
json_str = json.dumps(output, indent=indent, ensure_ascii=False)
|
|
417
|
+
|
|
418
|
+
if args.output:
|
|
419
|
+
with open(args.output, "w", encoding="utf-8") as f:
|
|
420
|
+
f.write(json_str)
|
|
421
|
+
f.write("\n")
|
|
422
|
+
print(f"Written to {args.output}", file=sys.stderr)
|
|
423
|
+
else:
|
|
424
|
+
print(json_str)
|
|
425
|
+
|
|
426
|
+
return 0
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
if __name__ == "__main__":
|
|
430
|
+
sys.exit(main())
|