chemql-python 0.1.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.
- chemql/__init__.py +3154 -0
- chemql/__main__.py +4 -0
- chemql/data/BondEnergies.json +602 -0
- chemql/data/MoleculeBonds.json +5708 -0
- chemql/data/Molecules.json +9270 -0
- chemql/data/PeriodicTableJSON.json +6268 -0
- chemql/data/Reactions.json +46749 -0
- chemql/data/THIRD_PARTY_LICENSES/PeriodicTableJSON.txt +10 -0
- chemql/data/THIRD_PARTY_LICENSES/RHEA.txt +14 -0
- chemql/html/periodic_table.html +14124 -0
- chemql_python-0.1.0.dist-info/METADATA +297 -0
- chemql_python-0.1.0.dist-info/RECORD +16 -0
- chemql_python-0.1.0.dist-info/WHEEL +5 -0
- chemql_python-0.1.0.dist-info/entry_points.txt +2 -0
- chemql_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- chemql_python-0.1.0.dist-info/top_level.txt +1 -0
chemql/__init__.py
ADDED
|
@@ -0,0 +1,3154 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import shlex
|
|
4
|
+
import re
|
|
5
|
+
import ast
|
|
6
|
+
import argparse
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from prompt_toolkit import PromptSession
|
|
10
|
+
from prompt_toolkit.lexers import Lexer
|
|
11
|
+
from prompt_toolkit.styles import Style
|
|
12
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
import urllib.error
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ChemQLError",
|
|
20
|
+
"Element",
|
|
21
|
+
"Molecule",
|
|
22
|
+
"Reaction",
|
|
23
|
+
"ReturnTable",
|
|
24
|
+
"Unknown",
|
|
25
|
+
"execute_query",
|
|
26
|
+
"execute_query_text",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Unknown:
|
|
31
|
+
"""Represents a Chemql query result that cannot be resolved to a known object."""
|
|
32
|
+
|
|
33
|
+
__slots__ = ("value",)
|
|
34
|
+
|
|
35
|
+
def __init__(self, value=None):
|
|
36
|
+
self.value = value
|
|
37
|
+
|
|
38
|
+
def __str__(self):
|
|
39
|
+
return str(self.value) if self.value is not None else "Unknown"
|
|
40
|
+
|
|
41
|
+
def __repr__(self):
|
|
42
|
+
return repr(self.value) if self.value is not None else "Unknown"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ChemQLError(Exception):
|
|
46
|
+
def __init__(self, message, position=None, length=1):
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.position = position
|
|
49
|
+
self.length = length
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def format_error(message, text=None, position=None, length=1):
|
|
53
|
+
output = f"Error: {message}"
|
|
54
|
+
|
|
55
|
+
if text is not None and position is not None:
|
|
56
|
+
output += f"\n{text}\n{' ' * position}{'^' * max(1, length)}"
|
|
57
|
+
|
|
58
|
+
return output
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def token_positions(text):
|
|
62
|
+
positions = []
|
|
63
|
+
i = 0
|
|
64
|
+
while i < len(text):
|
|
65
|
+
while i < len(text) and text[i].isspace():
|
|
66
|
+
i += 1
|
|
67
|
+
if i >= len(text):
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
start = i
|
|
71
|
+
quote = None
|
|
72
|
+
escaped = False
|
|
73
|
+
while i < len(text):
|
|
74
|
+
char = text[i]
|
|
75
|
+
if escaped:
|
|
76
|
+
escaped = False
|
|
77
|
+
elif char == "\\":
|
|
78
|
+
escaped = True
|
|
79
|
+
elif quote:
|
|
80
|
+
if char == quote:
|
|
81
|
+
quote = None
|
|
82
|
+
elif char in ("\'", '"'):
|
|
83
|
+
quote = char
|
|
84
|
+
elif char.isspace():
|
|
85
|
+
break
|
|
86
|
+
i += 1
|
|
87
|
+
positions.append((start, max(1, i - start)))
|
|
88
|
+
return positions
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def error_position(positions, index):
|
|
92
|
+
if 0 <= index < len(positions):
|
|
93
|
+
return positions[index]
|
|
94
|
+
return (None, 1)
|
|
95
|
+
from prompt_toolkit.shortcuts import choice
|
|
96
|
+
|
|
97
|
+
BASE_DIR = Path(__file__).resolve().parent
|
|
98
|
+
DATA_DIR = BASE_DIR / "data"
|
|
99
|
+
|
|
100
|
+
with (DATA_DIR / "PeriodicTableJSON.json").open("r", encoding="utf-8") as f:
|
|
101
|
+
elements = json.load(f)
|
|
102
|
+
with (DATA_DIR / "Molecules.json").open("r", encoding="utf-8") as f:
|
|
103
|
+
molecules = json.load(f)
|
|
104
|
+
with (DATA_DIR / "Reactions.json").open("r", encoding="utf-8") as f:
|
|
105
|
+
reactions = json.load(f)
|
|
106
|
+
with (DATA_DIR / "MoleculeBonds.json").open("r", encoding="utf-8") as f:
|
|
107
|
+
moleculeBonds = json.load(f)
|
|
108
|
+
with (DATA_DIR / "BondEnergies.json").open("r", encoding="utf-8") as f:
|
|
109
|
+
bondEnergies = json.load(f)
|
|
110
|
+
|
|
111
|
+
operators = {"has", "like", "like!", "=", ">", "<", ">=", "<="}
|
|
112
|
+
|
|
113
|
+
queryOperations = {"sort", "limit", "count"}
|
|
114
|
+
|
|
115
|
+
def like_match(value, pattern, case_sensitive=False):
|
|
116
|
+
regex = ""
|
|
117
|
+
i = 0
|
|
118
|
+
|
|
119
|
+
while i < len(pattern):
|
|
120
|
+
char = pattern[i]
|
|
121
|
+
|
|
122
|
+
if char == "\\":
|
|
123
|
+
i += 1
|
|
124
|
+
|
|
125
|
+
if i >= len(pattern):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"Escape character at end of pattern"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
regex += re.escape(pattern[i])
|
|
131
|
+
|
|
132
|
+
elif char == "*":
|
|
133
|
+
regex += ".*"
|
|
134
|
+
|
|
135
|
+
elif char == "%":
|
|
136
|
+
regex += "[0-9]*"
|
|
137
|
+
|
|
138
|
+
elif char == "&":
|
|
139
|
+
regex += "[A-Za-z]*"
|
|
140
|
+
|
|
141
|
+
elif char == "_":
|
|
142
|
+
regex += "."
|
|
143
|
+
|
|
144
|
+
elif char == "#":
|
|
145
|
+
regex += "[0-9]"
|
|
146
|
+
|
|
147
|
+
elif char == "?":
|
|
148
|
+
regex += "[A-Za-z]"
|
|
149
|
+
|
|
150
|
+
else:
|
|
151
|
+
regex += re.escape(char)
|
|
152
|
+
|
|
153
|
+
i += 1
|
|
154
|
+
|
|
155
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
156
|
+
|
|
157
|
+
return re.fullmatch(regex, value, flags) is not None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def normal_round(num, p=0):
|
|
161
|
+
num1 = num / 10**p
|
|
162
|
+
numint = math.floor(num1)
|
|
163
|
+
numfloat = num - numint
|
|
164
|
+
if numfloat >= 0.5:
|
|
165
|
+
return -(-numint - 1) * 10**p
|
|
166
|
+
return numint * 10 ** p
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Element:
|
|
171
|
+
__slots__ = ("name", "appearance", "atomic_mass", "boil", "category", "density", "discovered_by", "melt", "molar_heat", "named_by", "number", "period", "group", "phase", "source", "bohr_model_image", "bohr_model_3d", "spectral_img", "summary", "symbol", "xpos", "ypos", "wxpos", "wypos", "shells", "electron_configuration", "electron_configuration_semantic", "electron_affinity", "electronegativity_pauling", "ionization_energies", "cpk_hex", "image", "block")
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
name,
|
|
176
|
+
appearance,
|
|
177
|
+
atomic_mass,
|
|
178
|
+
boil,
|
|
179
|
+
category,
|
|
180
|
+
density,
|
|
181
|
+
discovered_by,
|
|
182
|
+
melt,
|
|
183
|
+
molar_heat,
|
|
184
|
+
named_by,
|
|
185
|
+
number,
|
|
186
|
+
period,
|
|
187
|
+
group,
|
|
188
|
+
phase,
|
|
189
|
+
source,
|
|
190
|
+
bohr_model_image,
|
|
191
|
+
bohr_model_3d,
|
|
192
|
+
spectral_img,
|
|
193
|
+
summary,
|
|
194
|
+
symbol,
|
|
195
|
+
xpos,
|
|
196
|
+
ypos,
|
|
197
|
+
wxpos,
|
|
198
|
+
wypos,
|
|
199
|
+
shells,
|
|
200
|
+
electron_configuration,
|
|
201
|
+
electron_configuration_semantic,
|
|
202
|
+
electron_affinity,
|
|
203
|
+
electronegativity_pauling,
|
|
204
|
+
ionization_energies,
|
|
205
|
+
cpk_hex,
|
|
206
|
+
image,
|
|
207
|
+
block,
|
|
208
|
+
):
|
|
209
|
+
self.name = name
|
|
210
|
+
self.appearance = appearance
|
|
211
|
+
self.atomic_mass = atomic_mass
|
|
212
|
+
self.boil = boil
|
|
213
|
+
self.category = category
|
|
214
|
+
self.density = density
|
|
215
|
+
self.discovered_by = discovered_by
|
|
216
|
+
self.melt = melt
|
|
217
|
+
self.molar_heat = molar_heat
|
|
218
|
+
self.named_by = named_by
|
|
219
|
+
self.number = number
|
|
220
|
+
self.period = period
|
|
221
|
+
self.group = group
|
|
222
|
+
self.phase = phase
|
|
223
|
+
self.source = source
|
|
224
|
+
self.bohr_model_image = bohr_model_image
|
|
225
|
+
self.bohr_model_3d = bohr_model_3d
|
|
226
|
+
self.spectral_img = spectral_img
|
|
227
|
+
self.summary = summary
|
|
228
|
+
self.symbol = symbol
|
|
229
|
+
self.xpos = xpos
|
|
230
|
+
self.ypos = ypos
|
|
231
|
+
self.wxpos = wxpos
|
|
232
|
+
self.wypos = wypos
|
|
233
|
+
self.shells = shells
|
|
234
|
+
self.electron_configuration = electron_configuration
|
|
235
|
+
self.electron_configuration_semantic = electron_configuration_semantic
|
|
236
|
+
self.electron_affinity = electron_affinity
|
|
237
|
+
self.electronegativity_pauling = electronegativity_pauling
|
|
238
|
+
self.ionization_energies = ionization_energies
|
|
239
|
+
self.cpk_hex = cpk_hex
|
|
240
|
+
self.image = image
|
|
241
|
+
self.block = block
|
|
242
|
+
|
|
243
|
+
def __getitem__(self, key):
|
|
244
|
+
return getattr(self, key)
|
|
245
|
+
|
|
246
|
+
def __str__(self):
|
|
247
|
+
if self.cpk_hex:
|
|
248
|
+
r = int(self.cpk_hex[0:2], 16)
|
|
249
|
+
g = int(self.cpk_hex[2:4], 16)
|
|
250
|
+
b = int(self.cpk_hex[4:6], 16)
|
|
251
|
+
else:
|
|
252
|
+
r = g = b = 255
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
f"\033[38;2;{r};{g};{b}m"
|
|
256
|
+
f" ______________\n"
|
|
257
|
+
f"| {self.number:^3} |\n"
|
|
258
|
+
f"| |\n"
|
|
259
|
+
f"| {self.symbol:^3} |\n"
|
|
260
|
+
f"| |\n"
|
|
261
|
+
f"| {normal_round(self.atomic_mass):^3} |\n"
|
|
262
|
+
f"|______________|"
|
|
263
|
+
f"\033[0m\n"
|
|
264
|
+
)
|
|
265
|
+
def __repr__(self):
|
|
266
|
+
return f"{self.name}"
|
|
267
|
+
|
|
268
|
+
elementKeys = ("name", "appearance", "atomic_mass", "boil", "category", "density", "discovered_by", "melt", "molar_heat", "named_by", "number", "period", "group", "phase", "source", "bohr_model_image", "bohr_model_3d", "spectral_img", "summary", "symbol", "xpos", "ypos", "wxpos", "wypos", "shells", "electron_configuration", "electron_configuration_semantic", "electron_affinity", "electronegativity_pauling", "ionization_energies", "cpk-hex", "image", "block")
|
|
269
|
+
|
|
270
|
+
ELEMENTS = [Element(*(item[key] if item[key] is not None else "" for key in elementKeys)) for item in elements["elements"]]
|
|
271
|
+
|
|
272
|
+
# No longer need the JSON structure.
|
|
273
|
+
|
|
274
|
+
ELEMENTS.pop()
|
|
275
|
+
|
|
276
|
+
class Molecule:
|
|
277
|
+
__slots__ = ("name", "formula", "elements", "molecular_weight", "iupac_name", "smiles", "cid", "melting_point","boiling_point", "density", "state", "bonds", "total_bond_energy")
|
|
278
|
+
def __init__(self, name, formula, elements, molecular_weight, iupac_name, smiles, cid, melting_point, boiling_point, density, state):
|
|
279
|
+
self.name = name
|
|
280
|
+
self.formula = formula
|
|
281
|
+
self.elements = elements
|
|
282
|
+
self.molecular_weight = molecular_weight
|
|
283
|
+
self.iupac_name = iupac_name
|
|
284
|
+
self.smiles = smiles
|
|
285
|
+
self.cid = cid
|
|
286
|
+
self.melting_point = melting_point
|
|
287
|
+
self.boiling_point = boiling_point
|
|
288
|
+
self.density = density
|
|
289
|
+
self.state = state
|
|
290
|
+
self.bonds = moleculeBonds.get(name, {}).get("bonds", [])
|
|
291
|
+
self.total_bond_energy = sum(
|
|
292
|
+
bond.get("count", 0) * bondEnergies[
|
|
293
|
+
f"{'-'.join(sorted(bond['elements']))}-{bond['type']}"
|
|
294
|
+
]["energy"]
|
|
295
|
+
for bond in self.bonds
|
|
296
|
+
)
|
|
297
|
+
def __getitem__(self, key):
|
|
298
|
+
return getattr(self, key)
|
|
299
|
+
def __str__(self):
|
|
300
|
+
return f"{self.formula.translate(str.maketrans('0123456789', '₀₁₂₃₄₅₆₇₈₉'))} ({self.name})"
|
|
301
|
+
|
|
302
|
+
def __repr__(self):
|
|
303
|
+
return self.name
|
|
304
|
+
|
|
305
|
+
moleculeKeys = ("name", "formula", "elements", "molecular_weight", "iupac_name", "smiles", "cid", "melting_point", "boiling_point", "density", "state", "bonds", "total_bond_energy")
|
|
306
|
+
|
|
307
|
+
PUBCHEM_BASE_URL = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
|
|
308
|
+
PUBCHEM_VIEW_URL = "https://pubchem.ncbi.nlm.nih.gov/rest/pug_view"
|
|
309
|
+
PUBCHEM_SERVICE_ERROR = False
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _normalize_molecule_value(value):
|
|
313
|
+
"""Strip surrounding quotes and whitespace from a molecule lookup."""
|
|
314
|
+
if value is None:
|
|
315
|
+
return ""
|
|
316
|
+
|
|
317
|
+
text = str(value).strip()
|
|
318
|
+
if len(text) >= 2 and text[0] == text[-1] and text[0] in {'"', "'"}:
|
|
319
|
+
text = text[1:-1].strip()
|
|
320
|
+
|
|
321
|
+
return text
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _fetch_url_text(url, timeout=15, retries=2):
|
|
325
|
+
"""Fetch a raw text response from a URL, retrying transient failures."""
|
|
326
|
+
request = urllib.request.Request(
|
|
327
|
+
url,
|
|
328
|
+
headers={
|
|
329
|
+
"User-Agent": "ChemQL/1.0 (+https://example.invalid)",
|
|
330
|
+
"Accept": "text/html,application/json;q=0.9,*/*;q=0.8",
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
for attempt in range(retries + 1):
|
|
335
|
+
try:
|
|
336
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
337
|
+
return response.read().decode("utf-8", errors="replace")
|
|
338
|
+
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError):
|
|
339
|
+
if attempt < retries:
|
|
340
|
+
time.sleep(0.5 * (attempt + 1))
|
|
341
|
+
continue
|
|
342
|
+
return ""
|
|
343
|
+
|
|
344
|
+
return ""
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _get_json(url, timeout=10, retries=3):
|
|
348
|
+
"""Fetch JSON from a URL with retries for transient server errors."""
|
|
349
|
+
global PUBCHEM_SERVICE_ERROR
|
|
350
|
+
|
|
351
|
+
request = urllib.request.Request(
|
|
352
|
+
url,
|
|
353
|
+
headers={
|
|
354
|
+
"User-Agent": "ChemQL/1.0 (+https://example.invalid)",
|
|
355
|
+
"Accept": "application/json",
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
for attempt in range(retries + 1):
|
|
360
|
+
try:
|
|
361
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
362
|
+
data = json.load(response)
|
|
363
|
+
PUBCHEM_SERVICE_ERROR = False
|
|
364
|
+
return data
|
|
365
|
+
|
|
366
|
+
except urllib.error.HTTPError as exc:
|
|
367
|
+
if exc.code in (500, 502, 503, 504):
|
|
368
|
+
PUBCHEM_SERVICE_ERROR = True
|
|
369
|
+
if attempt < retries:
|
|
370
|
+
time.sleep(0.5 * (attempt + 1))
|
|
371
|
+
continue
|
|
372
|
+
return None
|
|
373
|
+
PUBCHEM_SERVICE_ERROR = False
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
except (
|
|
377
|
+
urllib.error.URLError,
|
|
378
|
+
json.JSONDecodeError,
|
|
379
|
+
TimeoutError,
|
|
380
|
+
OSError,
|
|
381
|
+
ValueError,
|
|
382
|
+
):
|
|
383
|
+
PUBCHEM_SERVICE_ERROR = True
|
|
384
|
+
if attempt < retries:
|
|
385
|
+
time.sleep(0.5 * (attempt + 1))
|
|
386
|
+
continue
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _find_local_molecule(value):
|
|
391
|
+
"""Return a built-in molecule when the network lookup is unavailable."""
|
|
392
|
+
if value is None:
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
target = _normalize_molecule_value(value)
|
|
396
|
+
if not target:
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
normalized = target.lower()
|
|
400
|
+
|
|
401
|
+
for molecule in MOLECULES:
|
|
402
|
+
if molecule.name.lower() == normalized:
|
|
403
|
+
return molecule
|
|
404
|
+
if molecule.formula.lower() == normalized:
|
|
405
|
+
return molecule
|
|
406
|
+
if molecule.iupac_name and molecule.iupac_name.lower() == normalized:
|
|
407
|
+
return molecule
|
|
408
|
+
|
|
409
|
+
for molecule in MOLECULES:
|
|
410
|
+
if molecule.name.lower().startswith(normalized):
|
|
411
|
+
return molecule
|
|
412
|
+
if molecule.formula.lower().startswith(normalized):
|
|
413
|
+
return molecule
|
|
414
|
+
if molecule.iupac_name and molecule.iupac_name.lower().startswith(normalized):
|
|
415
|
+
return molecule
|
|
416
|
+
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _fetch_nist_molecule(value):
|
|
421
|
+
"""Try NIST WebBook as a fallback chemistry source."""
|
|
422
|
+
target = _normalize_molecule_value(value)
|
|
423
|
+
if not target:
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
encoded = urllib.parse.quote(target)
|
|
427
|
+
url = f"https://webbook.nist.gov/cgi/cbook.cgi?Name={encoded}&Units=SI"
|
|
428
|
+
html = _fetch_url_text(url)
|
|
429
|
+
if not html:
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
formula_match = re.search(r"Formula\s*[:<]?[\s\n]*([A-Z][a-z]?(?:\d+)?(?:[A-Z][a-z]?(?:\d+)?)*)", html, re.I)
|
|
433
|
+
weight_match = re.search(r"Molecular weight\s*[:<]?[\s\n]*([0-9]+(?:\.[0-9]+)?)", html, re.I)
|
|
434
|
+
|
|
435
|
+
if not formula_match:
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
formula = formula_match.group(1).strip()
|
|
439
|
+
molecular_weight = float(weight_match.group(1)) if weight_match else 0.0
|
|
440
|
+
|
|
441
|
+
return Molecule(
|
|
442
|
+
name=target,
|
|
443
|
+
formula=formula,
|
|
444
|
+
elements=[],
|
|
445
|
+
molecular_weight=molecular_weight,
|
|
446
|
+
iupac_name="",
|
|
447
|
+
smiles="",
|
|
448
|
+
cid=None,
|
|
449
|
+
melting_point=None,
|
|
450
|
+
boiling_point=None,
|
|
451
|
+
density=None,
|
|
452
|
+
state=None,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def fetch_external_molecule(value):
|
|
457
|
+
"""Try public chemistry sources once the local JSON does not contain the molecule."""
|
|
458
|
+
target = _normalize_molecule_value(value)
|
|
459
|
+
if not target:
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
# PubChem is the first external source because it is the most complete.
|
|
463
|
+
cid = _find_cid(target)
|
|
464
|
+
if cid is None:
|
|
465
|
+
return _fetch_nist_molecule(target)
|
|
466
|
+
|
|
467
|
+
properties = _get_basic_properties(cid)
|
|
468
|
+
if properties is None:
|
|
469
|
+
return _fetch_nist_molecule(target)
|
|
470
|
+
|
|
471
|
+
view_data = _get_view_data(cid)
|
|
472
|
+
melting_point = _get_first_temperature(view_data, "Melting Point") if view_data else None
|
|
473
|
+
boiling_point = _get_first_temperature(view_data, "Boiling Point") if view_data else None
|
|
474
|
+
density = _get_first_density(view_data) if view_data else None
|
|
475
|
+
state = _determine_state(melting_point, boiling_point, temperature=25.0) if melting_point is not None and boiling_point is not None else None
|
|
476
|
+
|
|
477
|
+
return Molecule(
|
|
478
|
+
name=target,
|
|
479
|
+
formula=properties.get("MolecularFormula", ""),
|
|
480
|
+
elements=[],
|
|
481
|
+
molecular_weight=properties.get("MolecularWeight", 0),
|
|
482
|
+
iupac_name=properties.get("IUPACName", ""),
|
|
483
|
+
smiles=properties.get("SMILES", ""),
|
|
484
|
+
cid=properties.get("CID", cid),
|
|
485
|
+
melting_point=melting_point,
|
|
486
|
+
boiling_point=boiling_point,
|
|
487
|
+
density=density,
|
|
488
|
+
state=state,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _find_cid(value):
|
|
493
|
+
"""
|
|
494
|
+
Find a PubChem CID.
|
|
495
|
+
|
|
496
|
+
Tries the input as:
|
|
497
|
+
1. Chemical name
|
|
498
|
+
2. Molecular formula
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
if value is None:
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
encoded_value = urllib.parse.quote(str(value), safe="")
|
|
505
|
+
|
|
506
|
+
# ---------------------------------------------------------
|
|
507
|
+
# Try chemical name
|
|
508
|
+
# ---------------------------------------------------------
|
|
509
|
+
url = (
|
|
510
|
+
f"{PUBCHEM_BASE_URL}/compound/name/"
|
|
511
|
+
f"{encoded_value}/cids/JSON"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
data = _get_json(url)
|
|
515
|
+
|
|
516
|
+
if data:
|
|
517
|
+
identifier_list = data.get("IdentifierList", {})
|
|
518
|
+
cids = identifier_list.get("CID", [])
|
|
519
|
+
if cids:
|
|
520
|
+
return cids[0]
|
|
521
|
+
|
|
522
|
+
# ---------------------------------------------------------
|
|
523
|
+
# Try molecular formula
|
|
524
|
+
# ---------------------------------------------------------
|
|
525
|
+
url = (
|
|
526
|
+
f"{PUBCHEM_BASE_URL}/compound/fastformula/"
|
|
527
|
+
f"{encoded_value}/cids/JSON"
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
data = _get_json(url)
|
|
531
|
+
|
|
532
|
+
if data:
|
|
533
|
+
identifier_list = data.get("IdentifierList", {})
|
|
534
|
+
cids = identifier_list.get("CID", [])
|
|
535
|
+
if cids:
|
|
536
|
+
return cids[0]
|
|
537
|
+
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _get_basic_properties(cid):
|
|
542
|
+
"""Get basic molecular properties from PubChem."""
|
|
543
|
+
|
|
544
|
+
url = (
|
|
545
|
+
f"{PUBCHEM_BASE_URL}/compound/cid/{cid}/property/"
|
|
546
|
+
"MolecularFormula,MolecularWeight,IUPACName,SMILES/JSON"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
data = _get_json(url)
|
|
550
|
+
|
|
551
|
+
if not data:
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
return data["PropertyTable"]["Properties"][0]
|
|
556
|
+
|
|
557
|
+
except (KeyError, IndexError, TypeError):
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _get_view_data(cid):
|
|
562
|
+
"""
|
|
563
|
+
Get the complete PUG-View record.
|
|
564
|
+
|
|
565
|
+
PUG-View contains experimental properties such as:
|
|
566
|
+
- Melting Point
|
|
567
|
+
- Boiling Point
|
|
568
|
+
- Density
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
url = (
|
|
572
|
+
f"{PUBCHEM_VIEW_URL}/data/compound/{cid}/JSON"
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
return _get_json(url)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _walk_sections(obj):
|
|
579
|
+
"""Recursively find PubChem PUG-View sections."""
|
|
580
|
+
|
|
581
|
+
if isinstance(obj, dict):
|
|
582
|
+
|
|
583
|
+
if "TOCHeading" in obj:
|
|
584
|
+
yield obj
|
|
585
|
+
|
|
586
|
+
for value in obj.values():
|
|
587
|
+
yield from _walk_sections(value)
|
|
588
|
+
|
|
589
|
+
elif isinstance(obj, list):
|
|
590
|
+
|
|
591
|
+
for item in obj:
|
|
592
|
+
yield from _walk_sections(item)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _find_section(data, heading):
|
|
596
|
+
"""Find a PUG-View section by heading."""
|
|
597
|
+
|
|
598
|
+
if not data:
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
for section in _walk_sections(data):
|
|
602
|
+
|
|
603
|
+
if section.get("TOCHeading", "").lower() == heading.lower():
|
|
604
|
+
return section
|
|
605
|
+
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _extract_text(value):
|
|
610
|
+
"""
|
|
611
|
+
Convert PubChem's nested Value structure into text.
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
if value is None:
|
|
615
|
+
return ""
|
|
616
|
+
|
|
617
|
+
if isinstance(value, str):
|
|
618
|
+
return value
|
|
619
|
+
|
|
620
|
+
if isinstance(value, (int, float)):
|
|
621
|
+
return str(value)
|
|
622
|
+
|
|
623
|
+
if isinstance(value, list):
|
|
624
|
+
return " ".join(
|
|
625
|
+
text
|
|
626
|
+
for item in value
|
|
627
|
+
if (text := _extract_text(item))
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
if isinstance(value, dict):
|
|
631
|
+
|
|
632
|
+
# Common PubChem formats
|
|
633
|
+
for key in (
|
|
634
|
+
"StringWithMarkup",
|
|
635
|
+
"String",
|
|
636
|
+
"StringValue",
|
|
637
|
+
):
|
|
638
|
+
if key in value:
|
|
639
|
+
text = _extract_text(value[key])
|
|
640
|
+
|
|
641
|
+
if text:
|
|
642
|
+
return text
|
|
643
|
+
|
|
644
|
+
for key in (
|
|
645
|
+
"Number",
|
|
646
|
+
"FloatValue",
|
|
647
|
+
):
|
|
648
|
+
if key in value:
|
|
649
|
+
return str(value[key])
|
|
650
|
+
|
|
651
|
+
# Fallback
|
|
652
|
+
return " ".join(
|
|
653
|
+
text
|
|
654
|
+
for item in value.values()
|
|
655
|
+
if (text := _extract_text(item))
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
return ""
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _get_section_values(data, heading):
|
|
662
|
+
"""
|
|
663
|
+
Get the text values reported in a PubChem section.
|
|
664
|
+
"""
|
|
665
|
+
|
|
666
|
+
section = _find_section(data, heading)
|
|
667
|
+
|
|
668
|
+
if not section:
|
|
669
|
+
return []
|
|
670
|
+
|
|
671
|
+
values = []
|
|
672
|
+
|
|
673
|
+
for information in section.get("Information", []):
|
|
674
|
+
|
|
675
|
+
if not isinstance(information, dict):
|
|
676
|
+
continue
|
|
677
|
+
|
|
678
|
+
value = information.get("Value")
|
|
679
|
+
|
|
680
|
+
text = _extract_text(value)
|
|
681
|
+
|
|
682
|
+
if text:
|
|
683
|
+
values.append(text.strip())
|
|
684
|
+
|
|
685
|
+
return values
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _parse_temperature(text):
|
|
689
|
+
"""
|
|
690
|
+
Convert a temperature string into Celsius.
|
|
691
|
+
|
|
692
|
+
Examples:
|
|
693
|
+
"78 °C" -> 78.0
|
|
694
|
+
"78-80 °C" -> 79.0
|
|
695
|
+
"172 °F" -> 77.777...
|
|
696
|
+
"351 K" -> 77.85
|
|
697
|
+
|
|
698
|
+
Returns None if no temperature can be extracted.
|
|
699
|
+
"""
|
|
700
|
+
|
|
701
|
+
if not text:
|
|
702
|
+
return None
|
|
703
|
+
|
|
704
|
+
# ---------------------------------------------------------
|
|
705
|
+
# Find numbers
|
|
706
|
+
# ---------------------------------------------------------
|
|
707
|
+
numbers = re.findall(
|
|
708
|
+
r"[-+]?\d+(?:\.\d+)?",
|
|
709
|
+
text
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
if not numbers:
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
values = [float(number) for number in numbers]
|
|
716
|
+
|
|
717
|
+
# If it's a range, use the average
|
|
718
|
+
value = sum(values[:2]) / min(len(values), 2)
|
|
719
|
+
|
|
720
|
+
text_lower = text.lower()
|
|
721
|
+
|
|
722
|
+
# ---------------------------------------------------------
|
|
723
|
+
# Convert to Celsius
|
|
724
|
+
# ---------------------------------------------------------
|
|
725
|
+
if "°f" in text_lower or "deg f" in text_lower:
|
|
726
|
+
return (value - 32) * 5 / 9
|
|
727
|
+
|
|
728
|
+
if re.search(r"\bk\b", text_lower):
|
|
729
|
+
return value - 273.15
|
|
730
|
+
|
|
731
|
+
# Default / expected unit
|
|
732
|
+
return value
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _parse_density(text):
|
|
736
|
+
"""
|
|
737
|
+
Convert density to g/cm^3.
|
|
738
|
+
|
|
739
|
+
Examples:
|
|
740
|
+
"1.05 g/mL" -> 1.05
|
|
741
|
+
"1.05 g/cm3" -> 1.05
|
|
742
|
+
"1050 kg/m3" -> 1.05
|
|
743
|
+
"1.05 g/cm³" -> 1.05
|
|
744
|
+
"""
|
|
745
|
+
|
|
746
|
+
if not text:
|
|
747
|
+
return None
|
|
748
|
+
|
|
749
|
+
match = re.search(
|
|
750
|
+
r"[-+]?\d+(?:\.\d+)?",
|
|
751
|
+
text
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
if not match:
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
value = float(match.group(0))
|
|
758
|
+
text_lower = text.lower()
|
|
759
|
+
|
|
760
|
+
# ---------------------------------------------------------
|
|
761
|
+
# Convert to g/cm^3
|
|
762
|
+
# ---------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
if "kg/m3" in text_lower or "kg/m³" in text_lower:
|
|
765
|
+
return value / 1000
|
|
766
|
+
|
|
767
|
+
if "mg/ml" in text_lower:
|
|
768
|
+
return value / 1000
|
|
769
|
+
|
|
770
|
+
# g/mL and g/cm3 are numerically equivalent
|
|
771
|
+
if "g/ml" in text_lower:
|
|
772
|
+
return value
|
|
773
|
+
|
|
774
|
+
if "g/cm3" in text_lower or "g/cm³" in text_lower:
|
|
775
|
+
return value
|
|
776
|
+
|
|
777
|
+
# If PubChem didn't provide a recognizable unit,
|
|
778
|
+
# assume g/cm3 because that is the unit used by this app.
|
|
779
|
+
return value
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _get_first_temperature(data, heading):
|
|
783
|
+
"""Get the first reported temperature in Celsius."""
|
|
784
|
+
|
|
785
|
+
values = _get_section_values(data, heading)
|
|
786
|
+
|
|
787
|
+
for value in values:
|
|
788
|
+
|
|
789
|
+
temperature = _parse_temperature(value)
|
|
790
|
+
|
|
791
|
+
if temperature is not None:
|
|
792
|
+
return temperature
|
|
793
|
+
|
|
794
|
+
return None
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _get_first_density(data):
|
|
798
|
+
"""Get the first reported density in g/cm3."""
|
|
799
|
+
|
|
800
|
+
values = _get_section_values(data, "Density")
|
|
801
|
+
|
|
802
|
+
for value in values:
|
|
803
|
+
|
|
804
|
+
density = _parse_density(value)
|
|
805
|
+
|
|
806
|
+
if density is not None:
|
|
807
|
+
return density
|
|
808
|
+
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _determine_state(
|
|
813
|
+
melting_point,
|
|
814
|
+
boiling_point,
|
|
815
|
+
temperature=25.0,
|
|
816
|
+
):
|
|
817
|
+
"""
|
|
818
|
+
Determine physical state at 25 °C.
|
|
819
|
+
|
|
820
|
+
Assumes approximately 1 atm pressure.
|
|
821
|
+
"""
|
|
822
|
+
|
|
823
|
+
if melting_point is None or boiling_point is None:
|
|
824
|
+
return None
|
|
825
|
+
|
|
826
|
+
if temperature < melting_point:
|
|
827
|
+
return "solid"
|
|
828
|
+
|
|
829
|
+
if temperature >= boiling_point:
|
|
830
|
+
return "gas"
|
|
831
|
+
|
|
832
|
+
return "liquid"
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def fetch_pubchem_molecule(value):
|
|
836
|
+
"""
|
|
837
|
+
Look up a molecule locally, then using PubChem and fallback sources.
|
|
838
|
+
|
|
839
|
+
`value` can be:
|
|
840
|
+
- chemical name: "water"
|
|
841
|
+
- molecular formula: "H2O"
|
|
842
|
+
|
|
843
|
+
Returns:
|
|
844
|
+
Molecule instance
|
|
845
|
+
or None if the molecule cannot be found.
|
|
846
|
+
"""
|
|
847
|
+
|
|
848
|
+
local_match = _find_local_molecule(value)
|
|
849
|
+
if local_match is not None:
|
|
850
|
+
return local_match
|
|
851
|
+
|
|
852
|
+
# ---------------------------------------------------------
|
|
853
|
+
# Search external chemistry sources when not in the local dataset.
|
|
854
|
+
# ---------------------------------------------------------
|
|
855
|
+
external_match = fetch_external_molecule(value)
|
|
856
|
+
return external_match
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
MOLECULES = [Molecule(item["name"], item["formula"], item["elements"], item["molecular_weight"], item["iupac_name"] or "", item["smiles"] or "", item["cid"], item["melting_point"], item["boiling_point"], item["density"], item["state"]) for item in molecules]
|
|
861
|
+
|
|
862
|
+
MOLECULES_BY_FORMULA = {molecule.formula: molecule for molecule in MOLECULES}
|
|
863
|
+
|
|
864
|
+
def molecule_bond_energy(formula):
|
|
865
|
+
molecule = MOLECULES_BY_FORMULA.get(formula)
|
|
866
|
+
return molecule.total_bond_energy if molecule is not None else 0
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
class Reaction:
|
|
871
|
+
__slots__ = ("id", "name", "equation", "reaction_type", "reversible", "reactants", "products", "conditions", "notes", "source")
|
|
872
|
+
def __init__(self, id_, name, reaction_type, reversible, reactants, products, conditions, notes, source=""):
|
|
873
|
+
self.id = id_
|
|
874
|
+
self.name = name
|
|
875
|
+
self.reaction_type = reaction_type
|
|
876
|
+
self.reversible = reversible
|
|
877
|
+
self.reactants = reactants
|
|
878
|
+
self.products = products
|
|
879
|
+
self.conditions = conditions
|
|
880
|
+
self.notes = notes
|
|
881
|
+
self.source = source
|
|
882
|
+
subscript_digits = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
|
|
883
|
+
|
|
884
|
+
reactant_text = " + ".join(
|
|
885
|
+
f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
|
|
886
|
+
for item in reactants
|
|
887
|
+
)
|
|
888
|
+
product_text = " + ".join(
|
|
889
|
+
f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
|
|
890
|
+
for item in products
|
|
891
|
+
)
|
|
892
|
+
arrow = " ⇌ " if self.reversible is True else " → " if self.reversible is False else " = "
|
|
893
|
+
self.equation = f"{reactant_text}{arrow}{product_text}"
|
|
894
|
+
|
|
895
|
+
if reversible is not None:
|
|
896
|
+
reactant_energy = sum(
|
|
897
|
+
molecule_bond_energy(item["molecule"]) * item["stoichiometric_coefficient"]
|
|
898
|
+
for item in reactants
|
|
899
|
+
)
|
|
900
|
+
product_energy = sum(
|
|
901
|
+
molecule_bond_energy(item["molecule"]) * item["stoichiometric_coefficient"]
|
|
902
|
+
for item in products
|
|
903
|
+
)
|
|
904
|
+
total_energy = reactant_energy - product_energy
|
|
905
|
+
self.equation += f"\tΔH={total_energy}kJ/mol"
|
|
906
|
+
if self.conditions:
|
|
907
|
+
self.equation += "\nConditions:\n"
|
|
908
|
+
for i in self.conditions:
|
|
909
|
+
self.equation += f"\t{i}: {self.conditions[i]}\n"
|
|
910
|
+
def __str__(self):
|
|
911
|
+
return self.equation
|
|
912
|
+
def __repr__(self):
|
|
913
|
+
return self.name
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
REACTIONS = [Reaction(item["id"], item["name"], item["reaction_type"], item["reversible"], item["reactants"], item["products"], item["conditions"], item["notes"], item.get("source", "")) for item in reactions]
|
|
917
|
+
reactionKeys = ("id", "name", "equation", "reaction_type", "reversible", "reactants", "products", "conditions", "notes", "source")
|
|
918
|
+
del elements
|
|
919
|
+
del reactions
|
|
920
|
+
del molecules
|
|
921
|
+
del MOLECULES_BY_FORMULA
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def queryElement(key, func, value, *params):
|
|
926
|
+
if key not in elementKeys:
|
|
927
|
+
return f"There is no key `{key}`"
|
|
928
|
+
|
|
929
|
+
for element in ELEMENTS:
|
|
930
|
+
if getattr(element[key], func)(value, *params):
|
|
931
|
+
return element
|
|
932
|
+
|
|
933
|
+
return f"Turns out no element has a {key} of {value}"
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def queryElementLast(key, func, value, *params):
|
|
937
|
+
if key not in elementKeys:
|
|
938
|
+
raise KeyError(f"There is no key `{key}`")
|
|
939
|
+
|
|
940
|
+
for element in reversed(ELEMENTS):
|
|
941
|
+
if getattr(element[key], func)(value, *params):
|
|
942
|
+
return element
|
|
943
|
+
|
|
944
|
+
return f"Turns out no element has a {key} of {value}"
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def queryElementAll(key, func, value, *params):
|
|
948
|
+
if key not in elementKeys:
|
|
949
|
+
raise KeyError(f"There is no key `{key}`")
|
|
950
|
+
|
|
951
|
+
found = False
|
|
952
|
+
|
|
953
|
+
for element in ELEMENTS:
|
|
954
|
+
if getattr(element[key], func)(value, *params):
|
|
955
|
+
found = True
|
|
956
|
+
yield element
|
|
957
|
+
|
|
958
|
+
if not found:
|
|
959
|
+
return
|
|
960
|
+
|
|
961
|
+
class ReturnTable(dict):
|
|
962
|
+
"""Dictionary result that prints as a table in the REPL."""
|
|
963
|
+
|
|
964
|
+
def __str__(self):
|
|
965
|
+
return format_return_table(self)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def format_return_table(data):
|
|
969
|
+
if not data:
|
|
970
|
+
return ""
|
|
971
|
+
|
|
972
|
+
columns = list(data.keys())
|
|
973
|
+
rows = []
|
|
974
|
+
row_count = max((len(data[column]) for column in columns), default=0)
|
|
975
|
+
|
|
976
|
+
for i in range(row_count):
|
|
977
|
+
rows.append([str(data[column][i]) if i < len(data[column]) else "" for column in columns])
|
|
978
|
+
|
|
979
|
+
widths = [max(len(str(column)), *(len(row[index]) for row in rows)) for index, column in enumerate(columns)]
|
|
980
|
+
|
|
981
|
+
header = " ".join(
|
|
982
|
+
str(column).upper().ljust(widths[index])
|
|
983
|
+
for index, column in enumerate(columns)
|
|
984
|
+
).rstrip()
|
|
985
|
+
|
|
986
|
+
separator = ''.join("─" * i for i in widths) + "──" *(len(data.keys()) - 1)
|
|
987
|
+
|
|
988
|
+
body = [" ".join(row[index].ljust(widths[index]) for index in range(len(columns))).rstrip() for row in rows]
|
|
989
|
+
|
|
990
|
+
return "\n".join([header, separator, *body])
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def project_results(results, attributes):
|
|
994
|
+
"""Return one column as a 1D list, or multiple columns as a dict of 1D lists."""
|
|
995
|
+
|
|
996
|
+
if len(attributes) == 1:
|
|
997
|
+
return [getattr(item, attributes[0]) for item in results]
|
|
998
|
+
|
|
999
|
+
return ReturnTable({attribute: [getattr(item, attribute) for item in results] for attribute in attributes})
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def sort_results(results, key, descending=False):
|
|
1003
|
+
try:
|
|
1004
|
+
return sorted(
|
|
1005
|
+
results,
|
|
1006
|
+
key=lambda item: getattr(item, key),
|
|
1007
|
+
reverse=descending
|
|
1008
|
+
)
|
|
1009
|
+
except (TypeError, AttributeError):
|
|
1010
|
+
raise ValueError(f"Cannot sort by `{key}`")
|
|
1011
|
+
|
|
1012
|
+
def apply_query_operations(results, tokens, keys, positions=None):
|
|
1013
|
+
i = 0
|
|
1014
|
+
|
|
1015
|
+
while i < len(tokens):
|
|
1016
|
+
token = tokens[i]
|
|
1017
|
+
|
|
1018
|
+
# sort
|
|
1019
|
+
if token == "sort":
|
|
1020
|
+
if i + 1 >= len(tokens):
|
|
1021
|
+
raise ChemQLError("Expected a key after `sort`", *(error_position(positions, i) if positions else (None, 1))) #type:ignore
|
|
1022
|
+
|
|
1023
|
+
key = tokens[i + 1]
|
|
1024
|
+
|
|
1025
|
+
if key not in keys:
|
|
1026
|
+
pos, length = error_position(positions, i + 1) if positions else (None, 1)
|
|
1027
|
+
raise ChemQLError(f"There is no key `{key}`", pos, length)
|
|
1028
|
+
|
|
1029
|
+
descending = False
|
|
1030
|
+
|
|
1031
|
+
if i + 2 < len(tokens):
|
|
1032
|
+
direction = tokens[i + 2].lower()
|
|
1033
|
+
|
|
1034
|
+
if direction == "asc":
|
|
1035
|
+
i += 1
|
|
1036
|
+
|
|
1037
|
+
elif direction == "desc":
|
|
1038
|
+
descending = True
|
|
1039
|
+
i += 1
|
|
1040
|
+
|
|
1041
|
+
try:
|
|
1042
|
+
results = sort_results(
|
|
1043
|
+
results,
|
|
1044
|
+
key,
|
|
1045
|
+
descending
|
|
1046
|
+
)
|
|
1047
|
+
except ValueError as e:
|
|
1048
|
+
pos, length = error_position(positions, i + 1) if positions else (None, 1)
|
|
1049
|
+
raise ChemQLError(str(e), pos, length) from e
|
|
1050
|
+
|
|
1051
|
+
i += 2
|
|
1052
|
+
|
|
1053
|
+
# limit
|
|
1054
|
+
elif token == "limit":
|
|
1055
|
+
if i + 1 >= len(tokens):
|
|
1056
|
+
raise ChemQLError("Expected a number after `limit`", *(error_position(positions, i) if positions else (None, 1)))#type:ignore
|
|
1057
|
+
|
|
1058
|
+
try:
|
|
1059
|
+
amount = int(tokens[i + 1])
|
|
1060
|
+
except ValueError:
|
|
1061
|
+
pos, length = error_position(positions, i + 1) if positions else (None, 1)
|
|
1062
|
+
raise ChemQLError("`limit` requires an integer", pos, length)
|
|
1063
|
+
|
|
1064
|
+
if amount < 0:
|
|
1065
|
+
pos, length = error_position(positions, i + 1) if positions else (None, 1)
|
|
1066
|
+
raise ChemQLError("`limit` cannot be negative", pos, length)
|
|
1067
|
+
|
|
1068
|
+
results = results[:amount] # type: ignore
|
|
1069
|
+
|
|
1070
|
+
i += 2
|
|
1071
|
+
|
|
1072
|
+
# count
|
|
1073
|
+
elif token == "count":
|
|
1074
|
+
if i + 1 < len(tokens):
|
|
1075
|
+
pos, length = error_position(positions, i) if positions else (None, 1)
|
|
1076
|
+
raise ChemQLError("`count` must be the final query operation", pos, length)
|
|
1077
|
+
|
|
1078
|
+
results = len(results) # type: ignore
|
|
1079
|
+
i += 1
|
|
1080
|
+
|
|
1081
|
+
else:
|
|
1082
|
+
pos, length = error_position(positions, i) if positions else (None, 1)
|
|
1083
|
+
raise ChemQLError(f"Unknown query operation `{token}`", pos, length)
|
|
1084
|
+
|
|
1085
|
+
return results
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
functionMap = {"=": "__eq__", ">": "__gt__", "<": "__lt__", ">=": "__ge__", "<=": "__le__"}
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def convert_value(value, sample):
|
|
1092
|
+
"""Convert a query value to the type of the element value."""
|
|
1093
|
+
|
|
1094
|
+
if isinstance(sample, bool):
|
|
1095
|
+
|
|
1096
|
+
if value.lower() == "true":
|
|
1097
|
+
return True
|
|
1098
|
+
|
|
1099
|
+
if value.lower() == "false":
|
|
1100
|
+
return False
|
|
1101
|
+
|
|
1102
|
+
raise ValueError(f"`{value}` is not a boolean")
|
|
1103
|
+
|
|
1104
|
+
if isinstance(sample, (int, float)):
|
|
1105
|
+
|
|
1106
|
+
try:
|
|
1107
|
+
number = float(value)
|
|
1108
|
+
|
|
1109
|
+
if number.is_integer():
|
|
1110
|
+
return int(number)
|
|
1111
|
+
|
|
1112
|
+
return number
|
|
1113
|
+
|
|
1114
|
+
except ValueError:
|
|
1115
|
+
raise ValueError(f"`{value}` is not a number")
|
|
1116
|
+
|
|
1117
|
+
return value
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def tokenize_query(arguments):
|
|
1121
|
+
"""Convert tokens into a form suitable for parsing."""
|
|
1122
|
+
|
|
1123
|
+
return arguments
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def parse_condition(tokens, index, source, keys, positions=None):
|
|
1127
|
+
if index + 2 >= len(tokens):
|
|
1128
|
+
raise ChemQLError("Incomplete condition", *(error_position(positions, index) if positions else (None, 1)))#type:ignore
|
|
1129
|
+
|
|
1130
|
+
key = tokens[index]
|
|
1131
|
+
operator = tokens[index + 1]
|
|
1132
|
+
value = tokens[index + 2]
|
|
1133
|
+
|
|
1134
|
+
if key not in keys:
|
|
1135
|
+
pos, length = error_position(positions, index) if positions else (None, 1)
|
|
1136
|
+
raise ChemQLError(f"There is no key `{key}`", pos, length)
|
|
1137
|
+
|
|
1138
|
+
if operator == "like" or operator == "like!":
|
|
1139
|
+
pattern = value
|
|
1140
|
+
case_sensitive = operator == "like!"
|
|
1141
|
+
|
|
1142
|
+
def like_condition(item):
|
|
1143
|
+
actual = getattr(item, key)
|
|
1144
|
+
|
|
1145
|
+
if not isinstance(actual, str):
|
|
1146
|
+
return False
|
|
1147
|
+
|
|
1148
|
+
return like_match(
|
|
1149
|
+
actual,
|
|
1150
|
+
pattern,
|
|
1151
|
+
case_sensitive
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
return like_condition, index + 3
|
|
1155
|
+
|
|
1156
|
+
if operator == "has":
|
|
1157
|
+
def has_condition(item):
|
|
1158
|
+
actual = getattr(item, key)
|
|
1159
|
+
|
|
1160
|
+
try:
|
|
1161
|
+
return value in actual
|
|
1162
|
+
except TypeError:
|
|
1163
|
+
return False
|
|
1164
|
+
|
|
1165
|
+
return has_condition, index + 3
|
|
1166
|
+
|
|
1167
|
+
if operator not in functionMap:
|
|
1168
|
+
pos, length = error_position(positions, index + 1) if positions else (None, 1)
|
|
1169
|
+
raise ChemQLError(f"Unknown operator `{operator}`", pos, length)
|
|
1170
|
+
|
|
1171
|
+
sample = getattr(source[0], key)
|
|
1172
|
+
|
|
1173
|
+
try:
|
|
1174
|
+
value = convert_value(value, sample)
|
|
1175
|
+
except ValueError as e:
|
|
1176
|
+
pos, length = error_position(positions, index + 2) if positions else (None, 1)
|
|
1177
|
+
raise ChemQLError(str(e), pos, length) from e
|
|
1178
|
+
|
|
1179
|
+
function = functionMap[operator]
|
|
1180
|
+
|
|
1181
|
+
def comparison_condition(item):
|
|
1182
|
+
try:
|
|
1183
|
+
return getattr(
|
|
1184
|
+
getattr(item, key),
|
|
1185
|
+
function
|
|
1186
|
+
)(value)
|
|
1187
|
+
except (TypeError, AttributeError):
|
|
1188
|
+
return False
|
|
1189
|
+
|
|
1190
|
+
return comparison_condition, index + 3
|
|
1191
|
+
|
|
1192
|
+
def parse_primary(tokens, index, source, keys, positions=None):
|
|
1193
|
+
if index >= len(tokens):
|
|
1194
|
+
raise ChemQLError("Expected condition", *(error_position(positions, index) if positions else (None, 1)))#type:ignore
|
|
1195
|
+
|
|
1196
|
+
if tokens[index] == "(":
|
|
1197
|
+
expression, index = parse_or(
|
|
1198
|
+
tokens,
|
|
1199
|
+
index + 1,
|
|
1200
|
+
source,
|
|
1201
|
+
keys,
|
|
1202
|
+
positions
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
if index >= len(tokens) or tokens[index] != ")":
|
|
1206
|
+
pos = positions[index][0] if positions and index < len(positions) else None
|
|
1207
|
+
raise ChemQLError("Missing `)`", pos)
|
|
1208
|
+
|
|
1209
|
+
return expression, index + 1
|
|
1210
|
+
|
|
1211
|
+
return parse_condition(
|
|
1212
|
+
tokens,
|
|
1213
|
+
index,
|
|
1214
|
+
source,
|
|
1215
|
+
keys,
|
|
1216
|
+
positions
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def parse_and(tokens, index, source, keys, positions=None):
|
|
1221
|
+
left, index = parse_primary(
|
|
1222
|
+
tokens,
|
|
1223
|
+
index,
|
|
1224
|
+
source,
|
|
1225
|
+
keys,
|
|
1226
|
+
positions
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
while index < len(tokens) and tokens[index] == "and":
|
|
1230
|
+
right, index = parse_primary(
|
|
1231
|
+
tokens,
|
|
1232
|
+
index + 1,
|
|
1233
|
+
source,
|
|
1234
|
+
keys,
|
|
1235
|
+
positions
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
previous = left
|
|
1239
|
+
|
|
1240
|
+
left = lambda item, a=previous, b=right: (
|
|
1241
|
+
a(item) and b(item)
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
return left, index
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def parse_or(tokens, index, source, keys, positions=None):
|
|
1248
|
+
left, index = parse_and(
|
|
1249
|
+
tokens,
|
|
1250
|
+
index,
|
|
1251
|
+
source,
|
|
1252
|
+
keys,
|
|
1253
|
+
positions
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
while index < len(tokens) and tokens[index] == "or":
|
|
1257
|
+
right, index = parse_and(
|
|
1258
|
+
tokens,
|
|
1259
|
+
index + 1,
|
|
1260
|
+
source,
|
|
1261
|
+
keys,
|
|
1262
|
+
positions
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
previous = left
|
|
1266
|
+
|
|
1267
|
+
left = lambda item, a=previous, b=right: (
|
|
1268
|
+
a(item) or b(item)
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
return left, index
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def parse_query(tokens, source, keys, positions=None):
|
|
1275
|
+
if not tokens:
|
|
1276
|
+
raise ChemQLError("No conditions specified", *(error_position(positions, 0) if positions else (None, 1)))#type:ignore
|
|
1277
|
+
|
|
1278
|
+
# Allow a query to operate on the entire source when it begins
|
|
1279
|
+
# with a query operation, e.g. `search molecules count`.
|
|
1280
|
+
if tokens[0] in queryOperations:
|
|
1281
|
+
return (lambda item: True), 0
|
|
1282
|
+
|
|
1283
|
+
condition, index = parse_or(
|
|
1284
|
+
tokens,
|
|
1285
|
+
0,
|
|
1286
|
+
source,
|
|
1287
|
+
keys,
|
|
1288
|
+
positions
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
# Conditions end when a query operation begins.
|
|
1292
|
+
if index < len(tokens) and tokens[index] not in queryOperations:
|
|
1293
|
+
pos, length = error_position(positions, index) if positions else (None, 1)
|
|
1294
|
+
raise ChemQLError(
|
|
1295
|
+
f"Unexpected `{tokens[index]}`", pos, length
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
return condition, index
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
SOURCES = {"elements": (ELEMENTS, elementKeys), "molecules": (MOLECULES, moleculeKeys), "reactions": (REACTIONS, reactionKeys)}
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def get_source(source):
|
|
1306
|
+
return SOURCES.get(source)
|
|
1307
|
+
|
|
1308
|
+
current_source = None
|
|
1309
|
+
|
|
1310
|
+
REACTION_TEMPERATURE_TOLERANCE_K = 5.0
|
|
1311
|
+
REACTION_PRESSURE_TOLERANCE = 0.05
|
|
1312
|
+
reaction_state = {
|
|
1313
|
+
"temperature_k": None,
|
|
1314
|
+
"pressure_pa": None,
|
|
1315
|
+
"catalysts": [],
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
# Python variables persist for the whole session.
|
|
1319
|
+
python_globals = {"__builtins__": __builtins__, "math": math}
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def find_matching(text, start, opening, closing):
|
|
1323
|
+
"""Find the matching closing delimiter, allowing nested delimiters."""
|
|
1324
|
+
depth = 1
|
|
1325
|
+
i = start + len(opening)
|
|
1326
|
+
quote = None
|
|
1327
|
+
escaped = False
|
|
1328
|
+
|
|
1329
|
+
while i < len(text):
|
|
1330
|
+
char = text[i]
|
|
1331
|
+
|
|
1332
|
+
if quote is not None:
|
|
1333
|
+
if escaped:
|
|
1334
|
+
escaped = False
|
|
1335
|
+
elif char == "\\":
|
|
1336
|
+
escaped = True
|
|
1337
|
+
elif char == quote:
|
|
1338
|
+
quote = None
|
|
1339
|
+
i += 1
|
|
1340
|
+
continue
|
|
1341
|
+
|
|
1342
|
+
if char in ("'", '"'):
|
|
1343
|
+
quote = char
|
|
1344
|
+
i += 1
|
|
1345
|
+
continue
|
|
1346
|
+
|
|
1347
|
+
if text.startswith(opening, i):
|
|
1348
|
+
depth += 1
|
|
1349
|
+
i += len(opening)
|
|
1350
|
+
continue
|
|
1351
|
+
|
|
1352
|
+
if text.startswith(closing, i):
|
|
1353
|
+
depth -= 1
|
|
1354
|
+
if depth == 0:
|
|
1355
|
+
return i
|
|
1356
|
+
i += len(closing)
|
|
1357
|
+
continue
|
|
1358
|
+
|
|
1359
|
+
i += 1
|
|
1360
|
+
|
|
1361
|
+
raise SyntaxError(f"Missing `{closing}`")
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def interpolate_query(text):
|
|
1365
|
+
"""Evaluate [Python] expressions inside a query."""
|
|
1366
|
+
result = []
|
|
1367
|
+
i = 0
|
|
1368
|
+
|
|
1369
|
+
while i < len(text):
|
|
1370
|
+
if text[i] == "[":
|
|
1371
|
+
end = find_matching(text, i, "[", "]")
|
|
1372
|
+
code = text[i + 1:end]
|
|
1373
|
+
value = evaluate_python(code)
|
|
1374
|
+
|
|
1375
|
+
if isinstance(value, str):
|
|
1376
|
+
result.append(shlex.quote(value))
|
|
1377
|
+
elif value is None:
|
|
1378
|
+
result.append("None")
|
|
1379
|
+
else:
|
|
1380
|
+
result.append(shlex.quote(str(value)))
|
|
1381
|
+
|
|
1382
|
+
i = end + 1
|
|
1383
|
+
continue
|
|
1384
|
+
|
|
1385
|
+
result.append(text[i])
|
|
1386
|
+
i += 1
|
|
1387
|
+
|
|
1388
|
+
return "".join(result)
|
|
1389
|
+
|
|
1390
|
+
def interpolate_python_queries(code):
|
|
1391
|
+
rewritten = []
|
|
1392
|
+
query_values = {}
|
|
1393
|
+
query_index = 0
|
|
1394
|
+
i = 0
|
|
1395
|
+
|
|
1396
|
+
while i < len(code):
|
|
1397
|
+
if code.startswith("{{", i):
|
|
1398
|
+
end = find_matching(code, i, "{{", "}}")
|
|
1399
|
+
query = code[i + 2:end].strip()
|
|
1400
|
+
|
|
1401
|
+
value = execute_query_block(query)
|
|
1402
|
+
|
|
1403
|
+
name = f"__chemql_query_{query_index}"
|
|
1404
|
+
query_index += 1
|
|
1405
|
+
|
|
1406
|
+
query_values[name] = value
|
|
1407
|
+
rewritten.append(name)
|
|
1408
|
+
|
|
1409
|
+
i = end + 2
|
|
1410
|
+
continue
|
|
1411
|
+
|
|
1412
|
+
rewritten.append(code[i])
|
|
1413
|
+
i += 1
|
|
1414
|
+
|
|
1415
|
+
python_globals.update(query_values)
|
|
1416
|
+
return "".join(rewritten)
|
|
1417
|
+
|
|
1418
|
+
def execute_query_text(text):
|
|
1419
|
+
"""Execute plain query-language text."""
|
|
1420
|
+
text = interpolate_query(text.strip())
|
|
1421
|
+
positions = token_positions(text)
|
|
1422
|
+
|
|
1423
|
+
try:
|
|
1424
|
+
arguments = shlex.split(text)
|
|
1425
|
+
except ValueError as e:
|
|
1426
|
+
raise ChemQLError(f"Query syntax error: {e}")
|
|
1427
|
+
|
|
1428
|
+
try:
|
|
1429
|
+
return execute(arguments, text, positions)
|
|
1430
|
+
except ChemQLError as e:
|
|
1431
|
+
if e.position is None:
|
|
1432
|
+
raise
|
|
1433
|
+
raise
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def execute_query(query: str) -> str | Molecule | Element | Reaction | list | None | Unknown:
|
|
1437
|
+
"""Execute a Chemql query through the public programmatic API."""
|
|
1438
|
+
result = execute_query_text(query)
|
|
1439
|
+
|
|
1440
|
+
if isinstance(result, (str, Molecule, Element, Reaction, Unknown)):
|
|
1441
|
+
return result
|
|
1442
|
+
|
|
1443
|
+
# Keep the public API total for result types that have not yet been
|
|
1444
|
+
# given a dedicated return type (for example lists, ReturnTable, and
|
|
1445
|
+
# integer counts).
|
|
1446
|
+
return Unknown(result)
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def execute_query_block(text):
|
|
1450
|
+
# Hybrid code needs the raw query result, so it intentionally bypasses
|
|
1451
|
+
# the public Unknown wrapper.
|
|
1452
|
+
return execute_query_text(text)
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def evaluate_python(code):
|
|
1456
|
+
"""Evaluate Python, with {{ query }} expressions available inside it.
|
|
1457
|
+
|
|
1458
|
+
If the Python is an expression, its value is returned.
|
|
1459
|
+
Otherwise it is executed and None is returned.
|
|
1460
|
+
"""
|
|
1461
|
+
query_values = {}
|
|
1462
|
+
rewritten = []
|
|
1463
|
+
i = 0
|
|
1464
|
+
query_index = 0
|
|
1465
|
+
|
|
1466
|
+
while i < len(code):
|
|
1467
|
+
if code.startswith("{{", i):
|
|
1468
|
+
end = find_matching(code, i, "{{", "}}")
|
|
1469
|
+
query = code[i + 2:end].strip()
|
|
1470
|
+
value = execute_query_block(query)
|
|
1471
|
+
|
|
1472
|
+
name = f"__chemql_query_{query_index}"
|
|
1473
|
+
query_index += 1
|
|
1474
|
+
query_values[name] = value
|
|
1475
|
+
rewritten.append(name)
|
|
1476
|
+
i = end + 2
|
|
1477
|
+
continue
|
|
1478
|
+
|
|
1479
|
+
rewritten.append(code[i])
|
|
1480
|
+
i += 1
|
|
1481
|
+
|
|
1482
|
+
rewritten = "".join(rewritten)
|
|
1483
|
+
|
|
1484
|
+
python_globals.update(query_values)
|
|
1485
|
+
|
|
1486
|
+
try:
|
|
1487
|
+
tree = ast.parse(rewritten, mode="eval")
|
|
1488
|
+
except SyntaxError:
|
|
1489
|
+
exec(rewritten, python_globals)
|
|
1490
|
+
return None
|
|
1491
|
+
|
|
1492
|
+
return eval(
|
|
1493
|
+
compile(tree, "<embedded-python>", "eval"),
|
|
1494
|
+
python_globals
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def is_python_block_header(text):
|
|
1499
|
+
"""Recognize [for ...], [if ...], etc. without requiring a colon."""
|
|
1500
|
+
text = text.strip()
|
|
1501
|
+
|
|
1502
|
+
if not (text.startswith("[") and text.endswith("]")):
|
|
1503
|
+
return False
|
|
1504
|
+
|
|
1505
|
+
code = text[1:-1].strip()
|
|
1506
|
+
|
|
1507
|
+
# Replace {{...}} queries with valid Python variable names
|
|
1508
|
+
while "{{" in code:
|
|
1509
|
+
start = code.index("{{")
|
|
1510
|
+
end = find_matching(code, start, "{{", "}}")
|
|
1511
|
+
code = code[:start] + "__chemql_query_placeholder" + code[end + 2:]
|
|
1512
|
+
|
|
1513
|
+
try:
|
|
1514
|
+
tree = ast.parse(code + ":\n pass", mode="exec")
|
|
1515
|
+
except SyntaxError:
|
|
1516
|
+
return False
|
|
1517
|
+
|
|
1518
|
+
return bool(tree.body) and isinstance(
|
|
1519
|
+
tree.body[0],
|
|
1520
|
+
(
|
|
1521
|
+
ast.For,
|
|
1522
|
+
ast.While,
|
|
1523
|
+
ast.If,
|
|
1524
|
+
ast.With,
|
|
1525
|
+
ast.Try,
|
|
1526
|
+
ast.FunctionDef,
|
|
1527
|
+
ast.AsyncFunctionDef,
|
|
1528
|
+
ast.ClassDef,
|
|
1529
|
+
)
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def find_endblock(lines, start):
|
|
1534
|
+
"""Find the matching [endblock], allowing nested blocks."""
|
|
1535
|
+
depth = 1
|
|
1536
|
+
|
|
1537
|
+
for i in range(start, len(lines)):
|
|
1538
|
+
stripped = lines[i].strip()
|
|
1539
|
+
|
|
1540
|
+
if is_python_block_header(stripped):
|
|
1541
|
+
depth += 1
|
|
1542
|
+
|
|
1543
|
+
elif stripped == "[endblock]":
|
|
1544
|
+
depth -= 1
|
|
1545
|
+
|
|
1546
|
+
if depth == 0:
|
|
1547
|
+
return i
|
|
1548
|
+
|
|
1549
|
+
raise SyntaxError("Missing `[endblock]`")
|
|
1550
|
+
|
|
1551
|
+
def remove_comments(line):
|
|
1552
|
+
in_quote = None
|
|
1553
|
+
escaped = False
|
|
1554
|
+
|
|
1555
|
+
for i in range(len(line) - 1):
|
|
1556
|
+
char = line[i]
|
|
1557
|
+
|
|
1558
|
+
if escaped:
|
|
1559
|
+
escaped = False
|
|
1560
|
+
continue
|
|
1561
|
+
|
|
1562
|
+
if char == "\\" and in_quote:
|
|
1563
|
+
escaped = True
|
|
1564
|
+
continue
|
|
1565
|
+
|
|
1566
|
+
if char in ('"', "'"):
|
|
1567
|
+
if in_quote is None:
|
|
1568
|
+
in_quote = char
|
|
1569
|
+
elif in_quote == char:
|
|
1570
|
+
in_quote = None
|
|
1571
|
+
continue
|
|
1572
|
+
|
|
1573
|
+
if line[i:i + 2] == "//" and in_quote is None:
|
|
1574
|
+
return line[:i].rstrip()
|
|
1575
|
+
|
|
1576
|
+
return line
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
class ChemQLBreak(Exception):
|
|
1580
|
+
pass
|
|
1581
|
+
class ChemQLContinue(Exception):
|
|
1582
|
+
pass
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def execute_for_block(header, body):
|
|
1586
|
+
code = header[1:-1].strip()
|
|
1587
|
+
code = interpolate_python_queries(code)
|
|
1588
|
+
|
|
1589
|
+
tree = ast.parse(code + ":\n pass", mode="exec")
|
|
1590
|
+
|
|
1591
|
+
node = tree.body[0]
|
|
1592
|
+
|
|
1593
|
+
if not isinstance(node, ast.For):
|
|
1594
|
+
raise SyntaxError("Expected a `for` block")
|
|
1595
|
+
|
|
1596
|
+
iterable = eval(
|
|
1597
|
+
compile(ast.Expression(node.iter), "<embedded-python>", "eval"),
|
|
1598
|
+
python_globals
|
|
1599
|
+
)
|
|
1600
|
+
|
|
1601
|
+
if not hasattr(iterable, "__iter__"):
|
|
1602
|
+
iterable = (iterable,)
|
|
1603
|
+
|
|
1604
|
+
for value in iterable:
|
|
1605
|
+
temp_name = "__chemql_for_value"
|
|
1606
|
+
python_globals[temp_name] = value
|
|
1607
|
+
|
|
1608
|
+
assignment = ast.Assign(
|
|
1609
|
+
targets=[node.target],
|
|
1610
|
+
value=ast.Name(
|
|
1611
|
+
id=temp_name,
|
|
1612
|
+
ctx=ast.Load()
|
|
1613
|
+
)
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1616
|
+
module = ast.Module(
|
|
1617
|
+
body=[assignment],
|
|
1618
|
+
type_ignores=[]
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
ast.fix_missing_locations(module)
|
|
1622
|
+
|
|
1623
|
+
exec(
|
|
1624
|
+
compile(
|
|
1625
|
+
module,
|
|
1626
|
+
"<embedded-python>",
|
|
1627
|
+
"exec"
|
|
1628
|
+
),
|
|
1629
|
+
python_globals
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
try:
|
|
1633
|
+
process_lines(body)
|
|
1634
|
+
|
|
1635
|
+
except ChemQLContinue:
|
|
1636
|
+
continue
|
|
1637
|
+
|
|
1638
|
+
except ChemQLBreak:
|
|
1639
|
+
break
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
def execute_while_block(header, body):
|
|
1643
|
+
code = header[1:-1].strip()
|
|
1644
|
+
|
|
1645
|
+
tree = ast.parse(
|
|
1646
|
+
code + ":\n pass",
|
|
1647
|
+
mode="exec"
|
|
1648
|
+
)
|
|
1649
|
+
|
|
1650
|
+
node = tree.body[0]
|
|
1651
|
+
|
|
1652
|
+
if not isinstance(node, ast.While):
|
|
1653
|
+
raise SyntaxError("Expected a `while` block")
|
|
1654
|
+
|
|
1655
|
+
while eval(
|
|
1656
|
+
compile(
|
|
1657
|
+
ast.Expression(node.test),
|
|
1658
|
+
"<embedded-python>",
|
|
1659
|
+
"eval"
|
|
1660
|
+
),
|
|
1661
|
+
python_globals
|
|
1662
|
+
):
|
|
1663
|
+
try:
|
|
1664
|
+
process_lines(body)
|
|
1665
|
+
|
|
1666
|
+
except ChemQLContinue:
|
|
1667
|
+
continue
|
|
1668
|
+
|
|
1669
|
+
except ChemQLBreak:
|
|
1670
|
+
break
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def execute_if_block(header, body):
|
|
1674
|
+
"""
|
|
1675
|
+
Execute an if/elif/else chain.
|
|
1676
|
+
|
|
1677
|
+
The body contains the entire block, including any
|
|
1678
|
+
[elif ...] and [else] clauses.
|
|
1679
|
+
"""
|
|
1680
|
+
|
|
1681
|
+
sections = []
|
|
1682
|
+
|
|
1683
|
+
current_header = header
|
|
1684
|
+
current_body = []
|
|
1685
|
+
|
|
1686
|
+
depth = 0
|
|
1687
|
+
|
|
1688
|
+
for line in body:
|
|
1689
|
+
stripped = line.strip()
|
|
1690
|
+
|
|
1691
|
+
if is_python_block_header(stripped):
|
|
1692
|
+
depth += 1
|
|
1693
|
+
|
|
1694
|
+
elif stripped == "[endblock]":
|
|
1695
|
+
depth -= 1
|
|
1696
|
+
|
|
1697
|
+
# Only recognize elif/else belonging to THIS if.
|
|
1698
|
+
if depth == 0 and (
|
|
1699
|
+
stripped.startswith("[elif ")
|
|
1700
|
+
or stripped == "[else]"
|
|
1701
|
+
):
|
|
1702
|
+
sections.append(
|
|
1703
|
+
(current_header, current_body)
|
|
1704
|
+
)
|
|
1705
|
+
|
|
1706
|
+
current_header = stripped
|
|
1707
|
+
current_body = []
|
|
1708
|
+
|
|
1709
|
+
else:
|
|
1710
|
+
current_body.append(line)
|
|
1711
|
+
|
|
1712
|
+
sections.append(
|
|
1713
|
+
(current_header, current_body)
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
for section_header, section_body in sections:
|
|
1717
|
+
code = section_header[1:-1].strip()
|
|
1718
|
+
|
|
1719
|
+
if code.startswith("if "):
|
|
1720
|
+
condition = code[3:].strip()
|
|
1721
|
+
|
|
1722
|
+
elif code.startswith("elif "):
|
|
1723
|
+
condition = code[5:].strip()
|
|
1724
|
+
|
|
1725
|
+
elif code == "else":
|
|
1726
|
+
process_lines(section_body)
|
|
1727
|
+
return
|
|
1728
|
+
|
|
1729
|
+
else:
|
|
1730
|
+
raise SyntaxError(
|
|
1731
|
+
f"Invalid conditional `{code}`"
|
|
1732
|
+
)
|
|
1733
|
+
|
|
1734
|
+
if evaluate_python(condition):
|
|
1735
|
+
process_lines(section_body)
|
|
1736
|
+
return
|
|
1737
|
+
|
|
1738
|
+
def split_commands(text):
|
|
1739
|
+
"""Split ; commands, but never split inside [], {{}} or strings."""
|
|
1740
|
+
commands = []
|
|
1741
|
+
start = 0
|
|
1742
|
+
i = 0
|
|
1743
|
+
square = 0
|
|
1744
|
+
curly = 0
|
|
1745
|
+
quote = None
|
|
1746
|
+
escaped = False
|
|
1747
|
+
|
|
1748
|
+
while i < len(text):
|
|
1749
|
+
char = text[i]
|
|
1750
|
+
|
|
1751
|
+
if quote is not None:
|
|
1752
|
+
if escaped:
|
|
1753
|
+
escaped = False
|
|
1754
|
+
elif char == "\\":
|
|
1755
|
+
escaped = True
|
|
1756
|
+
elif char == quote:
|
|
1757
|
+
quote = None
|
|
1758
|
+
i += 1
|
|
1759
|
+
continue
|
|
1760
|
+
|
|
1761
|
+
if char in ("'", '"'):
|
|
1762
|
+
quote = char
|
|
1763
|
+
elif text.startswith("{{", i):
|
|
1764
|
+
curly += 1
|
|
1765
|
+
i += 2
|
|
1766
|
+
continue
|
|
1767
|
+
elif text.startswith("}}", i) and curly:
|
|
1768
|
+
curly -= 1
|
|
1769
|
+
i += 2
|
|
1770
|
+
continue
|
|
1771
|
+
elif char == "[":
|
|
1772
|
+
square += 1
|
|
1773
|
+
elif char == "]" and square:
|
|
1774
|
+
square -= 1
|
|
1775
|
+
elif char == ";" and square == 0 and curly == 0:
|
|
1776
|
+
commands.append(text[start:i])
|
|
1777
|
+
start = i + 1
|
|
1778
|
+
|
|
1779
|
+
i += 1
|
|
1780
|
+
|
|
1781
|
+
commands.append(text[start:])
|
|
1782
|
+
return commands
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
def process_inline(text):
|
|
1786
|
+
"""Process one complete line of the chemql language."""
|
|
1787
|
+
stripped = text.strip()
|
|
1788
|
+
|
|
1789
|
+
if not stripped:
|
|
1790
|
+
return
|
|
1791
|
+
|
|
1792
|
+
# [ ... ] is Python.
|
|
1793
|
+
if stripped.startswith("["):
|
|
1794
|
+
end = find_matching(stripped, 0, "[", "]")
|
|
1795
|
+
if not stripped[end + 1:].strip():
|
|
1796
|
+
return evaluate_python(stripped[1:end])
|
|
1797
|
+
|
|
1798
|
+
# {{ ... }} is a query expression.
|
|
1799
|
+
if stripped.startswith("{{"):
|
|
1800
|
+
end = find_matching(stripped, 0, "{{", "}}")
|
|
1801
|
+
if not stripped[end + 2:].strip():
|
|
1802
|
+
return execute_query_block(stripped[2:end].strip())
|
|
1803
|
+
|
|
1804
|
+
# Everything else is query language.
|
|
1805
|
+
return execute_query_text(stripped)
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
def join_continuations(lines):
|
|
1809
|
+
"""Join lines ending in \\ with the following line."""
|
|
1810
|
+
joined = []
|
|
1811
|
+
buffer = None
|
|
1812
|
+
|
|
1813
|
+
for line in lines:
|
|
1814
|
+
if buffer is None:
|
|
1815
|
+
buffer = line
|
|
1816
|
+
else:
|
|
1817
|
+
buffer += line
|
|
1818
|
+
|
|
1819
|
+
if buffer.endswith("\\"):
|
|
1820
|
+
buffer = buffer[:-1] + " "
|
|
1821
|
+
continue
|
|
1822
|
+
|
|
1823
|
+
joined.append(buffer)
|
|
1824
|
+
buffer = None
|
|
1825
|
+
|
|
1826
|
+
if buffer is not None:
|
|
1827
|
+
joined.append(buffer)
|
|
1828
|
+
|
|
1829
|
+
return joined
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
def process_lines(lines):
|
|
1833
|
+
lines = join_continuations(lines)
|
|
1834
|
+
lines = [remove_comments(line) for line in lines]
|
|
1835
|
+
lines = [line for line in lines if line.strip()]
|
|
1836
|
+
i = 0
|
|
1837
|
+
|
|
1838
|
+
while i < len(lines):
|
|
1839
|
+
|
|
1840
|
+
line = lines[i]
|
|
1841
|
+
stripped = line.strip()
|
|
1842
|
+
|
|
1843
|
+
if not line.strip():
|
|
1844
|
+
continue
|
|
1845
|
+
|
|
1846
|
+
if stripped == "[endblock]":
|
|
1847
|
+
raise SyntaxError("Unexpected `[endblock]`")
|
|
1848
|
+
|
|
1849
|
+
# ----------------------------------------
|
|
1850
|
+
# Control-flow block
|
|
1851
|
+
# ----------------------------------------
|
|
1852
|
+
|
|
1853
|
+
if is_python_block_header(stripped):
|
|
1854
|
+
end = find_endblock(lines, i + 1)
|
|
1855
|
+
body = lines[i + 1:end]
|
|
1856
|
+
|
|
1857
|
+
code = stripped[1:-1].strip()
|
|
1858
|
+
|
|
1859
|
+
if code.startswith("for "):
|
|
1860
|
+
execute_for_block(
|
|
1861
|
+
stripped,
|
|
1862
|
+
body
|
|
1863
|
+
)
|
|
1864
|
+
|
|
1865
|
+
elif code.startswith("while "):
|
|
1866
|
+
execute_while_block(
|
|
1867
|
+
stripped,
|
|
1868
|
+
body
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
elif code.startswith("if "):
|
|
1872
|
+
execute_if_block(
|
|
1873
|
+
stripped,
|
|
1874
|
+
body
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
else:
|
|
1878
|
+
raise SyntaxError(
|
|
1879
|
+
f"Unsupported block `{code}`"
|
|
1880
|
+
)
|
|
1881
|
+
|
|
1882
|
+
i = end + 1
|
|
1883
|
+
continue
|
|
1884
|
+
|
|
1885
|
+
# ----------------------------------------
|
|
1886
|
+
# break / continue
|
|
1887
|
+
# ----------------------------------------
|
|
1888
|
+
|
|
1889
|
+
if stripped == "[break]":
|
|
1890
|
+
raise ChemQLBreak()
|
|
1891
|
+
|
|
1892
|
+
if stripped == "[continue]":
|
|
1893
|
+
raise ChemQLContinue()
|
|
1894
|
+
|
|
1895
|
+
if stripped == "view":
|
|
1896
|
+
view_periodic_table()
|
|
1897
|
+
i += 1
|
|
1898
|
+
continue
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
# ----------------------------------------
|
|
1902
|
+
# Normal chemql-language line
|
|
1903
|
+
# ----------------------------------------
|
|
1904
|
+
|
|
1905
|
+
for command in split_commands(line):
|
|
1906
|
+
result = process_inline(command)
|
|
1907
|
+
|
|
1908
|
+
if result is not None:
|
|
1909
|
+
print(result)
|
|
1910
|
+
|
|
1911
|
+
i += 1
|
|
1912
|
+
|
|
1913
|
+
|
|
1914
|
+
def normalize_catalysts(values):
|
|
1915
|
+
catalysts = []
|
|
1916
|
+
seen = set()
|
|
1917
|
+
|
|
1918
|
+
for value in values:
|
|
1919
|
+
catalyst = value.strip()
|
|
1920
|
+
normalized = catalyst.casefold()
|
|
1921
|
+
if catalyst and normalized not in seen:
|
|
1922
|
+
catalysts.append(catalyst)
|
|
1923
|
+
seen.add(normalized)
|
|
1924
|
+
|
|
1925
|
+
return catalysts
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def parse_temperature_setting(value):
|
|
1929
|
+
normalized = value.strip().casefold()
|
|
1930
|
+
if normalized == "standard":
|
|
1931
|
+
return 273.15
|
|
1932
|
+
if normalized == "room":
|
|
1933
|
+
return 298.15
|
|
1934
|
+
|
|
1935
|
+
match = re.fullmatch(r"([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*°?\s*([cfk])", normalized)
|
|
1936
|
+
if not match:
|
|
1937
|
+
raise ValueError("Temperature must use C, F, or K, or be `standard` or `room`")
|
|
1938
|
+
|
|
1939
|
+
temperature = float(match.group(1))
|
|
1940
|
+
unit = match.group(2)
|
|
1941
|
+
if unit == "f":
|
|
1942
|
+
temperature = (temperature - 32) * 5 / 9 + 273.15
|
|
1943
|
+
elif unit == "c":
|
|
1944
|
+
temperature += 273.15
|
|
1945
|
+
|
|
1946
|
+
if temperature < 0:
|
|
1947
|
+
raise ValueError("Temperature cannot be below absolute zero")
|
|
1948
|
+
|
|
1949
|
+
return temperature
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
def parse_pressure_setting(value):
|
|
1953
|
+
normalized = value.strip().casefold()
|
|
1954
|
+
if normalized == "standard":
|
|
1955
|
+
return 100000.0
|
|
1956
|
+
if normalized == "room":
|
|
1957
|
+
return 101325.0
|
|
1958
|
+
|
|
1959
|
+
match = re.fullmatch(r"([+]?(?:\d+(?:\.\d*)?|\.\d+))\s*(.*?)", normalized)
|
|
1960
|
+
if not match:
|
|
1961
|
+
raise ValueError("Pressure must be a non-negative value with Pa, N/m^2, bar, or atm units")
|
|
1962
|
+
|
|
1963
|
+
pressure = float(match.group(1))
|
|
1964
|
+
unit = re.sub(r"\s+", "", match.group(2)).replace("²", "^2")
|
|
1965
|
+
unit_scales = {
|
|
1966
|
+
"pa": 1.0,
|
|
1967
|
+
"n/m^2": 1.0,
|
|
1968
|
+
"nm^-2": 1.0,
|
|
1969
|
+
"bar": 100000.0,
|
|
1970
|
+
"atm": 101325.0,
|
|
1971
|
+
}
|
|
1972
|
+
if unit not in unit_scales:
|
|
1973
|
+
raise ValueError("Pressure must use Pa, N/m^2, Nm^-2, bar, or atm")
|
|
1974
|
+
|
|
1975
|
+
return pressure * unit_scales[unit]
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def _reaction_formula(value):
|
|
1979
|
+
normalized = value.casefold()
|
|
1980
|
+
for molecule in MOLECULES:
|
|
1981
|
+
if molecule.formula.casefold() == normalized:
|
|
1982
|
+
return molecule.formula
|
|
1983
|
+
if molecule.name.casefold() == normalized or molecule.iupac_name.casefold() == normalized:
|
|
1984
|
+
return molecule.formula
|
|
1985
|
+
return value
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
def parse_reactants(values):
|
|
1989
|
+
counts = {}
|
|
1990
|
+
index = 0
|
|
1991
|
+
|
|
1992
|
+
while index < len(values):
|
|
1993
|
+
value = values[index]
|
|
1994
|
+
coefficient = 1
|
|
1995
|
+
|
|
1996
|
+
if value.isdigit():
|
|
1997
|
+
coefficient = int(value)
|
|
1998
|
+
index += 1
|
|
1999
|
+
if index >= len(values):
|
|
2000
|
+
raise ValueError("Expected a molecule after its coefficient")
|
|
2001
|
+
value = values[index]
|
|
2002
|
+
else:
|
|
2003
|
+
match = re.match(r"^(\d+)(?=[A-Za-z])", value)
|
|
2004
|
+
if match:
|
|
2005
|
+
coefficient = int(match.group(1))
|
|
2006
|
+
value = value[match.end():]
|
|
2007
|
+
|
|
2008
|
+
if coefficient <= 0 or not value:
|
|
2009
|
+
raise ValueError("Reactant coefficients must be positive integers")
|
|
2010
|
+
|
|
2011
|
+
formula = _reaction_formula(value)
|
|
2012
|
+
key = formula.casefold()
|
|
2013
|
+
counts[key] = counts.get(key, 0) + coefficient
|
|
2014
|
+
index += 1
|
|
2015
|
+
|
|
2016
|
+
if not counts:
|
|
2017
|
+
raise ValueError("Usage: react <coefficient><molecule> ...")
|
|
2018
|
+
|
|
2019
|
+
return counts
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
def _reaction_side_counts(participants):
|
|
2023
|
+
counts = {}
|
|
2024
|
+
for participant in participants:
|
|
2025
|
+
formula = participant["molecule"]
|
|
2026
|
+
key = formula.casefold()
|
|
2027
|
+
counts[key] = counts.get(key, 0) + participant["stoichiometric_coefficient"]
|
|
2028
|
+
return counts
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
def _reaction_conditions_match(reaction):
|
|
2032
|
+
conditions = reaction.conditions
|
|
2033
|
+
temperature = conditions.get("temperature_k")
|
|
2034
|
+
if temperature is not None:
|
|
2035
|
+
current_temperature = reaction_state["temperature_k"]
|
|
2036
|
+
if current_temperature is None or abs(current_temperature - temperature) > REACTION_TEMPERATURE_TOLERANCE_K:
|
|
2037
|
+
return False
|
|
2038
|
+
|
|
2039
|
+
pressure = conditions.get("pressure_pa")
|
|
2040
|
+
if pressure is not None:
|
|
2041
|
+
current_pressure = reaction_state["pressure_pa"]
|
|
2042
|
+
if current_pressure is None:
|
|
2043
|
+
return False
|
|
2044
|
+
tolerance = abs(pressure) * REACTION_PRESSURE_TOLERANCE
|
|
2045
|
+
if abs(current_pressure - pressure) > tolerance:
|
|
2046
|
+
return False
|
|
2047
|
+
|
|
2048
|
+
required_catalysts = {
|
|
2049
|
+
item.casefold() for item in conditions.get("catalysts", [])
|
|
2050
|
+
}
|
|
2051
|
+
available_catalysts = {
|
|
2052
|
+
item.casefold() for item in reaction_state["catalysts"]
|
|
2053
|
+
}
|
|
2054
|
+
return required_catalysts.issubset(available_catalysts)
|
|
2055
|
+
|
|
2056
|
+
|
|
2057
|
+
def _resolve_reaction_product(participant):
|
|
2058
|
+
formula = participant["molecule"]
|
|
2059
|
+
for molecule in MOLECULES:
|
|
2060
|
+
if molecule.formula.casefold() == formula.casefold():
|
|
2061
|
+
return molecule
|
|
2062
|
+
|
|
2063
|
+
name = participant.get("name", formula)
|
|
2064
|
+
result = fetch_pubchem_molecule(name)
|
|
2065
|
+
if result is None:
|
|
2066
|
+
result = fetch_pubchem_molecule(formula)
|
|
2067
|
+
return result
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
def react(values):
|
|
2071
|
+
try:
|
|
2072
|
+
reactants = parse_reactants(values)
|
|
2073
|
+
except ValueError as exc:
|
|
2074
|
+
return str(exc)
|
|
2075
|
+
|
|
2076
|
+
matches = []
|
|
2077
|
+
for reaction in REACTIONS:
|
|
2078
|
+
if not _reaction_conditions_match(reaction):
|
|
2079
|
+
continue
|
|
2080
|
+
|
|
2081
|
+
forward = _reaction_side_counts(reaction.reactants)
|
|
2082
|
+
if reactants == forward:
|
|
2083
|
+
matches.append((reaction, reaction.products))
|
|
2084
|
+
continue
|
|
2085
|
+
|
|
2086
|
+
if reaction.reversible is True and reactants == _reaction_side_counts(reaction.products):
|
|
2087
|
+
matches.append((reaction, reaction.reactants))
|
|
2088
|
+
|
|
2089
|
+
if not matches:
|
|
2090
|
+
return "No reaction matches those reactants and the current temperature, pressure, and catalysts"
|
|
2091
|
+
|
|
2092
|
+
if len(matches) > 1:
|
|
2093
|
+
names = ", ".join(f"{reaction.name} ({reaction.id})" for reaction, _ in matches[:5])
|
|
2094
|
+
return f"Multiple reactions match: {names}"
|
|
2095
|
+
|
|
2096
|
+
reaction, products = matches[0]
|
|
2097
|
+
resolved = []
|
|
2098
|
+
for product in products:
|
|
2099
|
+
molecule = _resolve_reaction_product(product)
|
|
2100
|
+
if molecule is None:
|
|
2101
|
+
label = product.get("name", product["molecule"])
|
|
2102
|
+
return f"Could not resolve product molecule `{label}` for reaction `{reaction.id}`"
|
|
2103
|
+
resolved.append((molecule, product["stoichiometric_coefficient"], product.get("phase")))
|
|
2104
|
+
|
|
2105
|
+
if len(resolved) == 1:
|
|
2106
|
+
return resolved[0][0]
|
|
2107
|
+
|
|
2108
|
+
return ReturnTable({
|
|
2109
|
+
"molecule": [item[0] for item in resolved],
|
|
2110
|
+
"stoichiometric_coefficient": [item[1] for item in resolved],
|
|
2111
|
+
"phase": [item[2] or "" for item in resolved],
|
|
2112
|
+
})
|
|
2113
|
+
|
|
2114
|
+
|
|
2115
|
+
def current_conditions():
|
|
2116
|
+
temperature = reaction_state["temperature_k"]
|
|
2117
|
+
pressure = reaction_state["pressure_pa"]
|
|
2118
|
+
catalysts = reaction_state["catalysts"]
|
|
2119
|
+
|
|
2120
|
+
temperature_text = f"{temperature:g} K" if temperature is not None else "Not Set"
|
|
2121
|
+
pressure_text = f"{pressure:g} Pa" if pressure is not None else "Not Set"
|
|
2122
|
+
catalysts_text = ", ".join(catalysts) if catalysts else "Not Set"
|
|
2123
|
+
|
|
2124
|
+
return (
|
|
2125
|
+
f"Temperature: {temperature_text}\n"
|
|
2126
|
+
f"Pressure: {pressure_text}\n"
|
|
2127
|
+
f"Catalysts: {catalysts_text}"
|
|
2128
|
+
)
|
|
2129
|
+
|
|
2130
|
+
|
|
2131
|
+
def execute(arguments, query_text=None, positions=None):
|
|
2132
|
+
global current_source
|
|
2133
|
+
|
|
2134
|
+
if not arguments:
|
|
2135
|
+
return
|
|
2136
|
+
|
|
2137
|
+
if arguments[0].lower() in {"help", "?"}:
|
|
2138
|
+
return HELP
|
|
2139
|
+
|
|
2140
|
+
if arguments[0].lower() == "sorry":
|
|
2141
|
+
return "\033[34mIt's ok, everybody makes mistakes👌\033[0m"
|
|
2142
|
+
|
|
2143
|
+
if arguments[0] == "conditions":
|
|
2144
|
+
if len(arguments) != 1:
|
|
2145
|
+
return "Usage: conditions"
|
|
2146
|
+
return current_conditions()
|
|
2147
|
+
|
|
2148
|
+
if arguments[0] == "set":
|
|
2149
|
+
if len(arguments) < 2:
|
|
2150
|
+
return "Usage: set temperature|pressure|catalysts <value>"
|
|
2151
|
+
|
|
2152
|
+
setting = arguments[1].lower()
|
|
2153
|
+
values = arguments[2:]
|
|
2154
|
+
|
|
2155
|
+
if setting == "temperature":
|
|
2156
|
+
if len(values) != 1:
|
|
2157
|
+
return "Usage: set temperature <valueC|valueF|valueK|standard|room>"
|
|
2158
|
+
|
|
2159
|
+
try:
|
|
2160
|
+
reaction_state["temperature_k"] = parse_temperature_setting(values[0])
|
|
2161
|
+
except ValueError as exc:
|
|
2162
|
+
return str(exc)
|
|
2163
|
+
|
|
2164
|
+
return f"Temperature set to {reaction_state['temperature_k']:.2f} K"
|
|
2165
|
+
|
|
2166
|
+
if setting == "pressure":
|
|
2167
|
+
if len(values) != 1:
|
|
2168
|
+
return "Usage: set pressure <valuePa|valueN/m^2|valuebar|valueatm|standard|room>"
|
|
2169
|
+
|
|
2170
|
+
try:
|
|
2171
|
+
reaction_state["pressure_pa"] = parse_pressure_setting(values[0])
|
|
2172
|
+
except ValueError as exc:
|
|
2173
|
+
return str(exc)
|
|
2174
|
+
|
|
2175
|
+
return f"Pressure set to {reaction_state['pressure_pa']:g} Pa"
|
|
2176
|
+
|
|
2177
|
+
if setting == "catalysts":
|
|
2178
|
+
reaction_state["catalysts"] = normalize_catalysts(values)
|
|
2179
|
+
if not reaction_state["catalysts"]:
|
|
2180
|
+
return "Catalysts cleared"
|
|
2181
|
+
return f"Catalysts set to {', '.join(reaction_state['catalysts'])}"
|
|
2182
|
+
|
|
2183
|
+
return f"Unknown reaction setting `{setting}`"
|
|
2184
|
+
|
|
2185
|
+
if arguments[0] == "add" and len(arguments) >= 2 and arguments[1].lower() == "catalyst":
|
|
2186
|
+
if len(arguments) < 3:
|
|
2187
|
+
return 'Usage: add catalyst "name"'
|
|
2188
|
+
|
|
2189
|
+
catalyst = " ".join(arguments[2:]).strip()
|
|
2190
|
+
current = reaction_state["catalysts"]
|
|
2191
|
+
if catalyst and catalyst.casefold() not in {item.casefold() for item in current}:
|
|
2192
|
+
current.append(catalyst)
|
|
2193
|
+
return f"Catalysts: {', '.join(current)}" if current else "Catalysts cleared"
|
|
2194
|
+
|
|
2195
|
+
if arguments[0] == "remove" and len(arguments) >= 2 and arguments[1].lower() in {"catalyst", "calatyst"}:
|
|
2196
|
+
if len(arguments) < 3:
|
|
2197
|
+
return 'Usage: remove catalyst "name"'
|
|
2198
|
+
|
|
2199
|
+
catalyst = " ".join(arguments[2:]).strip()
|
|
2200
|
+
reaction_state["catalysts"] = [
|
|
2201
|
+
item for item in reaction_state["catalysts"]
|
|
2202
|
+
if item.casefold() != catalyst.casefold()
|
|
2203
|
+
]
|
|
2204
|
+
return f"Catalysts: {', '.join(reaction_state['catalysts'])}" if reaction_state["catalysts"] else "Catalysts cleared"
|
|
2205
|
+
|
|
2206
|
+
if arguments[0] == "react":
|
|
2207
|
+
return react(arguments[1:])
|
|
2208
|
+
|
|
2209
|
+
# Convenience aliases for very common lookups.
|
|
2210
|
+
# These are syntactic sugar for normal search queries.
|
|
2211
|
+
if arguments[0] == "findel":
|
|
2212
|
+
if len(arguments) != 2:
|
|
2213
|
+
return "Usage: findel <name-or-symbol>"
|
|
2214
|
+
|
|
2215
|
+
value = arguments[1]
|
|
2216
|
+
return execute(
|
|
2217
|
+
["search", "elements", "name", "like", value, "or", "symbol", "like", value]
|
|
2218
|
+
)
|
|
2219
|
+
|
|
2220
|
+
if arguments[0] == "findmol":
|
|
2221
|
+
if len(arguments) < 2:
|
|
2222
|
+
return "Usage: findmol <name-or-formula> [return <attribute> ...]"
|
|
2223
|
+
|
|
2224
|
+
value = arguments[1]
|
|
2225
|
+
|
|
2226
|
+
# Optional return clause
|
|
2227
|
+
return_attributes = None
|
|
2228
|
+
|
|
2229
|
+
if "return" in arguments[2:]:
|
|
2230
|
+
return_index = arguments.index("return", 2)
|
|
2231
|
+
return_attributes = arguments[return_index + 1:]
|
|
2232
|
+
|
|
2233
|
+
if not return_attributes:
|
|
2234
|
+
return "Usage: findmol <name-or-formula> return <attribute> ..."
|
|
2235
|
+
|
|
2236
|
+
for attribute in return_attributes:
|
|
2237
|
+
if attribute not in moleculeKeys:
|
|
2238
|
+
return f"There is no key `{attribute}`"
|
|
2239
|
+
|
|
2240
|
+
if return_index != 2:
|
|
2241
|
+
return "Usage: findmol <name-or-formula> [return <attribute> ...]"
|
|
2242
|
+
|
|
2243
|
+
elif len(arguments) > 2:
|
|
2244
|
+
return "Usage: findmol <name-or-formula> [return <attribute> ...]"
|
|
2245
|
+
|
|
2246
|
+
# Search the local dataset first.
|
|
2247
|
+
result = execute(
|
|
2248
|
+
["search", "molecules", "name", "like", value, "or", "formula", "like", value]
|
|
2249
|
+
)
|
|
2250
|
+
|
|
2251
|
+
if result == [] or result == "No matching item":
|
|
2252
|
+
result = fetch_pubchem_molecule(value)
|
|
2253
|
+
|
|
2254
|
+
if result is None:
|
|
2255
|
+
return f"Could not find molecule `{value}`"
|
|
2256
|
+
|
|
2257
|
+
if isinstance(result, str) and "PubChem is currently unavailable" in result:
|
|
2258
|
+
return result
|
|
2259
|
+
|
|
2260
|
+
if isinstance(result, str) and "could not be found in the recommended sources" in result.lower():
|
|
2261
|
+
return result
|
|
2262
|
+
|
|
2263
|
+
# Apply optional projection.
|
|
2264
|
+
if return_attributes:
|
|
2265
|
+
if isinstance(result, list):
|
|
2266
|
+
return project_results(result, return_attributes)
|
|
2267
|
+
|
|
2268
|
+
return ReturnTable({
|
|
2269
|
+
attribute: [getattr(result, attribute)]
|
|
2270
|
+
for attribute in return_attributes
|
|
2271
|
+
})
|
|
2272
|
+
|
|
2273
|
+
return result
|
|
2274
|
+
|
|
2275
|
+
if arguments[0] == "findre":
|
|
2276
|
+
if len(arguments) < 2:
|
|
2277
|
+
return "Usage: findre <name-or-id> [return <attribute> ...]"
|
|
2278
|
+
|
|
2279
|
+
value = arguments[1]
|
|
2280
|
+
return_attributes = None
|
|
2281
|
+
|
|
2282
|
+
if "return" in arguments[2:]:
|
|
2283
|
+
return_index = arguments.index("return", 2)
|
|
2284
|
+
return_attributes = arguments[return_index + 1:]
|
|
2285
|
+
|
|
2286
|
+
if not return_attributes:
|
|
2287
|
+
return "Usage: findre <name-or-id> return <attribute> ..."
|
|
2288
|
+
|
|
2289
|
+
for attribute in return_attributes:
|
|
2290
|
+
if attribute not in reactionKeys:
|
|
2291
|
+
return f"There is no key `{attribute}`"
|
|
2292
|
+
|
|
2293
|
+
if return_index != 2:
|
|
2294
|
+
return "Usage: findre <name-or-id> [return <attribute> ...]"
|
|
2295
|
+
|
|
2296
|
+
elif len(arguments) > 2:
|
|
2297
|
+
return "Usage: findre <name-or-id> [return <attribute> ...]"
|
|
2298
|
+
|
|
2299
|
+
result = execute([
|
|
2300
|
+
"search", "reactions",
|
|
2301
|
+
"name", "like", value,
|
|
2302
|
+
"or", "id", "like", value,
|
|
2303
|
+
"or", "equation", "like", value,
|
|
2304
|
+
])
|
|
2305
|
+
|
|
2306
|
+
if result == [] or result == "No matching item":
|
|
2307
|
+
return f"Could not find reaction `{value}`"
|
|
2308
|
+
|
|
2309
|
+
if return_attributes:
|
|
2310
|
+
if isinstance(result, list):
|
|
2311
|
+
return project_results(result, return_attributes)
|
|
2312
|
+
|
|
2313
|
+
return ReturnTable({
|
|
2314
|
+
attribute: [getattr(result, attribute)]
|
|
2315
|
+
for attribute in return_attributes
|
|
2316
|
+
})
|
|
2317
|
+
|
|
2318
|
+
return result
|
|
2319
|
+
|
|
2320
|
+
if arguments[0] == "clear":
|
|
2321
|
+
if len(arguments) != 1:
|
|
2322
|
+
return "Usage: clear"
|
|
2323
|
+
|
|
2324
|
+
print("\033[2J\033[H", end="")
|
|
2325
|
+
return
|
|
2326
|
+
|
|
2327
|
+
if arguments[0] == "source":
|
|
2328
|
+
if len(arguments) != 2:
|
|
2329
|
+
return "Usage: source <source>"
|
|
2330
|
+
|
|
2331
|
+
source_name = arguments[1]
|
|
2332
|
+
|
|
2333
|
+
if source_name not in SOURCES:
|
|
2334
|
+
return f"There is no source `{source_name}`"
|
|
2335
|
+
|
|
2336
|
+
current_source = source_name
|
|
2337
|
+
return f"Source set to `{source_name}`"
|
|
2338
|
+
|
|
2339
|
+
if arguments[0] == "list":
|
|
2340
|
+
if len(arguments) != 2 or arguments[1] != "all":
|
|
2341
|
+
return "Usage: list all"
|
|
2342
|
+
|
|
2343
|
+
if current_source is None:
|
|
2344
|
+
return list(SOURCES)
|
|
2345
|
+
|
|
2346
|
+
_, keys = SOURCES[current_source]
|
|
2347
|
+
return list(keys)
|
|
2348
|
+
|
|
2349
|
+
if arguments[0] == "search":
|
|
2350
|
+
i = 1
|
|
2351
|
+
mode = "all"
|
|
2352
|
+
|
|
2353
|
+
if i < len(arguments) and arguments[i] in ("first", "last", "all"):
|
|
2354
|
+
mode = arguments[i]
|
|
2355
|
+
i += 1
|
|
2356
|
+
|
|
2357
|
+
if (
|
|
2358
|
+
i + 1 < len(arguments)
|
|
2359
|
+
and arguments[i] in SOURCES
|
|
2360
|
+
and (
|
|
2361
|
+
current_source is None
|
|
2362
|
+
or arguments[i + 1] not in operators
|
|
2363
|
+
)
|
|
2364
|
+
):
|
|
2365
|
+
source_name = arguments[i]
|
|
2366
|
+
i += 1
|
|
2367
|
+
else:
|
|
2368
|
+
source_name = current_source
|
|
2369
|
+
|
|
2370
|
+
if source_name is None:
|
|
2371
|
+
return (
|
|
2372
|
+
"No source selected. "
|
|
2373
|
+
"Specify one, e.g. "
|
|
2374
|
+
"`search all elements atomic_mass < 100`"
|
|
2375
|
+
)
|
|
2376
|
+
|
|
2377
|
+
source, keys = SOURCES[source_name]
|
|
2378
|
+
return_attributes = None
|
|
2379
|
+
|
|
2380
|
+
if "return" in arguments[i:]:
|
|
2381
|
+
return_index = arguments.index("return", i)
|
|
2382
|
+
query_tokens = arguments[i:return_index]
|
|
2383
|
+
return_attributes = arguments[return_index + 1:]
|
|
2384
|
+
|
|
2385
|
+
if not return_attributes:
|
|
2386
|
+
return format_error("Usage: return <attribute> [<attribute> ...]", query_text)
|
|
2387
|
+
|
|
2388
|
+
for attribute_index, attribute in enumerate(return_attributes):
|
|
2389
|
+
if attribute not in keys:
|
|
2390
|
+
absolute_index = return_index + 1 + attribute_index
|
|
2391
|
+
pos, length = error_position(positions, absolute_index) if positions else (None, 1)
|
|
2392
|
+
return format_error(
|
|
2393
|
+
f"There is no key `{attribute}`",
|
|
2394
|
+
query_text,
|
|
2395
|
+
pos,
|
|
2396
|
+
length
|
|
2397
|
+
)
|
|
2398
|
+
else:
|
|
2399
|
+
query_tokens = arguments[i:]
|
|
2400
|
+
|
|
2401
|
+
try:
|
|
2402
|
+
condition, operation_index = parse_query(
|
|
2403
|
+
query_tokens,
|
|
2404
|
+
source,
|
|
2405
|
+
keys,
|
|
2406
|
+
positions[i:] if positions else None
|
|
2407
|
+
)
|
|
2408
|
+
except ChemQLError as e:
|
|
2409
|
+
return format_error(str(e), query_text, e.position, e.length)
|
|
2410
|
+
except ValueError as e:
|
|
2411
|
+
return format_error(str(e))
|
|
2412
|
+
|
|
2413
|
+
results = [item for item in source if condition(item)]
|
|
2414
|
+
|
|
2415
|
+
try:
|
|
2416
|
+
results = apply_query_operations(
|
|
2417
|
+
results,
|
|
2418
|
+
query_tokens[operation_index:],
|
|
2419
|
+
keys,
|
|
2420
|
+
positions[i + operation_index:] if positions else None
|
|
2421
|
+
)
|
|
2422
|
+
except ChemQLError as e:
|
|
2423
|
+
return format_error(str(e), query_text, e.position, e.length)
|
|
2424
|
+
except ValueError as e:
|
|
2425
|
+
return format_error(str(e))
|
|
2426
|
+
|
|
2427
|
+
if isinstance(results, int):
|
|
2428
|
+
if mode != "all":
|
|
2429
|
+
return format_error("`count` cannot be combined with `first` or `last`")
|
|
2430
|
+
if return_attributes is not None:
|
|
2431
|
+
return format_error("`return` cannot be used with `count`")
|
|
2432
|
+
return results
|
|
2433
|
+
|
|
2434
|
+
# `return` is a projection. It intentionally happens after
|
|
2435
|
+
# filtering, sorting, limiting, and first/last selection.
|
|
2436
|
+
if return_attributes is not None:
|
|
2437
|
+
if mode == "first":
|
|
2438
|
+
selected = results[:1]
|
|
2439
|
+
elif mode == "last":
|
|
2440
|
+
selected = results[-1:]
|
|
2441
|
+
else:
|
|
2442
|
+
selected = results
|
|
2443
|
+
|
|
2444
|
+
if not selected:
|
|
2445
|
+
if len(return_attributes) == 1:
|
|
2446
|
+
return []
|
|
2447
|
+
return ReturnTable({attribute: [] for attribute in return_attributes})
|
|
2448
|
+
|
|
2449
|
+
return project_results(selected, return_attributes)
|
|
2450
|
+
|
|
2451
|
+
if mode == "first":
|
|
2452
|
+
result = results[0] if results else None
|
|
2453
|
+
elif mode == "last":
|
|
2454
|
+
result = results[-1] if results else None
|
|
2455
|
+
else:
|
|
2456
|
+
if len(results) == 1:
|
|
2457
|
+
result = results[0]
|
|
2458
|
+
else:
|
|
2459
|
+
result = results
|
|
2460
|
+
|
|
2461
|
+
if result is None:
|
|
2462
|
+
return "No matching item"
|
|
2463
|
+
|
|
2464
|
+
return result
|
|
2465
|
+
|
|
2466
|
+
if query_text is not None and positions:
|
|
2467
|
+
pos, length = error_position(positions, 0)
|
|
2468
|
+
return format_error(
|
|
2469
|
+
f"Unknown command `{arguments[0]}`",
|
|
2470
|
+
query_text,
|
|
2471
|
+
pos,
|
|
2472
|
+
length
|
|
2473
|
+
)
|
|
2474
|
+
|
|
2475
|
+
return format_error(f"Unknown command `{arguments[0]}`")
|
|
2476
|
+
HELP = """\033[1;36mChemql - Python Chemistry Query Language\033[0m
|
|
2477
|
+
|
|
2478
|
+
\033[1;33mQUERYING\033[0m
|
|
2479
|
+
\033[1msearch\033[0m
|
|
2480
|
+
Search the current source.
|
|
2481
|
+
|
|
2482
|
+
search elements
|
|
2483
|
+
search molecules
|
|
2484
|
+
search reactions
|
|
2485
|
+
search all elements
|
|
2486
|
+
search all molecules
|
|
2487
|
+
search all reactions
|
|
2488
|
+
|
|
2489
|
+
Example:
|
|
2490
|
+
search elements name = "Hydrogen"
|
|
2491
|
+
search molecules molecular_weight < 20
|
|
2492
|
+
search reactions reaction_type = "combustion"
|
|
2493
|
+
|
|
2494
|
+
\033[1msource\033[0m
|
|
2495
|
+
Change the current source.
|
|
2496
|
+
|
|
2497
|
+
source elements
|
|
2498
|
+
source molecules
|
|
2499
|
+
source reactions
|
|
2500
|
+
|
|
2501
|
+
\033[1mfindel <value>\033[0m
|
|
2502
|
+
Find an element by name or symbol.
|
|
2503
|
+
|
|
2504
|
+
Example:
|
|
2505
|
+
findel "He"
|
|
2506
|
+
findel "Hydrogen"
|
|
2507
|
+
|
|
2508
|
+
\033[1mfindmol <value>\033[0m
|
|
2509
|
+
Find a molecule by name or formula.
|
|
2510
|
+
|
|
2511
|
+
Example:
|
|
2512
|
+
findmol "H2O"
|
|
2513
|
+
findmol "Water"
|
|
2514
|
+
|
|
2515
|
+
\033[1mfindre <name-or-id>\033[0m
|
|
2516
|
+
Find a reaction by name, identifier, or equation.
|
|
2517
|
+
|
|
2518
|
+
Example:
|
|
2519
|
+
findre "Haber process"
|
|
2520
|
+
findre "RHEA:10000" return name equation source
|
|
2521
|
+
|
|
2522
|
+
Reaction fields:
|
|
2523
|
+
id, name, equation, reaction_type, reversible,
|
|
2524
|
+
reactants, products, conditions, notes
|
|
2525
|
+
|
|
2526
|
+
Example:
|
|
2527
|
+
search reactions name like "*combustion*" return name equation
|
|
2528
|
+
search reactions reversible = true return name conditions
|
|
2529
|
+
|
|
2530
|
+
\033[1;33mFILTERING\033[0m
|
|
2531
|
+
Operators:
|
|
2532
|
+
= Equal
|
|
2533
|
+
> Greater than
|
|
2534
|
+
< Less than
|
|
2535
|
+
>= Greater than or equal
|
|
2536
|
+
<= Less than or equal
|
|
2537
|
+
like Pattern match (case-insensitive)
|
|
2538
|
+
like! Pattern match (case-sensitive)
|
|
2539
|
+
has Check whether a value contains an item
|
|
2540
|
+
|
|
2541
|
+
Logical operators:
|
|
2542
|
+
and
|
|
2543
|
+
or
|
|
2544
|
+
|
|
2545
|
+
Parentheses can be used to group conditions.
|
|
2546
|
+
|
|
2547
|
+
Example:
|
|
2548
|
+
search elements atomic_number > 10 and atomic_number < 20
|
|
2549
|
+
search molecules name like "*water*"
|
|
2550
|
+
search reactions reaction_type = "combustion"
|
|
2551
|
+
|
|
2552
|
+
\033[1;33mREACTIONS\033[0m
|
|
2553
|
+
Set reaction conditions before using `react`.
|
|
2554
|
+
|
|
2555
|
+
set temperature 723K
|
|
2556
|
+
set temperature standard
|
|
2557
|
+
set pressure 20265000pa
|
|
2558
|
+
set pressure room
|
|
2559
|
+
set catalysts iron zinc
|
|
2560
|
+
add catalyst "Vanadium oxide"
|
|
2561
|
+
remove catalyst "Vanadium oxide"
|
|
2562
|
+
set catalysts
|
|
2563
|
+
conditions
|
|
2564
|
+
react N2 3H2
|
|
2565
|
+
|
|
2566
|
+
Temperature accepts C, F, or K. Pressure accepts Pa, N/m^2, Nm^-2, or bar.
|
|
2567
|
+
`standard` means 0 C and 100000 Pa; `room` means 25 C and 101325 Pa.
|
|
2568
|
+
Unset values appear as `Not Set` in `conditions`.
|
|
2569
|
+
Reaction matching allows 5 K temperature and 5% pressure tolerance.
|
|
2570
|
+
|
|
2571
|
+
\033[1;33mRESULTS\033[0m
|
|
2572
|
+
\033[1mfirst\033[0m
|
|
2573
|
+
Return only the first result.
|
|
2574
|
+
|
|
2575
|
+
\033[1mlast\033[0m
|
|
2576
|
+
Return only the last result.
|
|
2577
|
+
|
|
2578
|
+
\033[1msort <key> [asc|desc]\033[0m
|
|
2579
|
+
Sort results by an attribute.
|
|
2580
|
+
|
|
2581
|
+
Example:
|
|
2582
|
+
search elements sort atomic_number
|
|
2583
|
+
search molecules sort molecular_weight desc
|
|
2584
|
+
|
|
2585
|
+
\033[1mlimit <number>\033[0m
|
|
2586
|
+
Limit the number of results.
|
|
2587
|
+
|
|
2588
|
+
Example:
|
|
2589
|
+
search molecules limit 10
|
|
2590
|
+
|
|
2591
|
+
\033[1mcount\033[0m
|
|
2592
|
+
Return the number of results.
|
|
2593
|
+
|
|
2594
|
+
Example:
|
|
2595
|
+
search molecules count
|
|
2596
|
+
search reactions reversible = true return name equation
|
|
2597
|
+
|
|
2598
|
+
\033[1mreturn <attributes...>\033[0m
|
|
2599
|
+
Return only the specified attributes.
|
|
2600
|
+
|
|
2601
|
+
One attribute returns a list.
|
|
2602
|
+
Multiple attributes return a table.
|
|
2603
|
+
|
|
2604
|
+
Example:
|
|
2605
|
+
search molecules molecular_weight < 20 return name
|
|
2606
|
+
|
|
2607
|
+
search molecules molecular_weight < 20 return name formula molecular_weight
|
|
2608
|
+
|
|
2609
|
+
\033[1;33mGENERAL COMMANDS\033[0m
|
|
2610
|
+
\033[1mlist all\033[0m
|
|
2611
|
+
List keys from the active source or all available sources.
|
|
2612
|
+
|
|
2613
|
+
\033[1mview\033[0m
|
|
2614
|
+
Open the periodic table viewer in the default browser.
|
|
2615
|
+
|
|
2616
|
+
\033[1mclear\033[0m
|
|
2617
|
+
Clear the current screen.
|
|
2618
|
+
|
|
2619
|
+
\033[1mdump\033[0m
|
|
2620
|
+
Export the recent command history to a file.
|
|
2621
|
+
Example: dump my_session.txt
|
|
2622
|
+
|
|
2623
|
+
\033[1mhelp\033[0m
|
|
2624
|
+
Show this help message.
|
|
2625
|
+
|
|
2626
|
+
\033[1mexit\033[0m
|
|
2627
|
+
Exit Chemql.
|
|
2628
|
+
|
|
2629
|
+
\033[1;33mHYBRID PYTHON\033[0m
|
|
2630
|
+
Chemql can be combined with Python using [ ... ].
|
|
2631
|
+
|
|
2632
|
+
[x = 10]
|
|
2633
|
+
[print(x)]
|
|
2634
|
+
|
|
2635
|
+
Python control blocks are also supported:
|
|
2636
|
+
|
|
2637
|
+
[for x in ...]
|
|
2638
|
+
[if ...]
|
|
2639
|
+
[elif ...]
|
|
2640
|
+
[else]
|
|
2641
|
+
[while ...]
|
|
2642
|
+
[break]
|
|
2643
|
+
[continue]
|
|
2644
|
+
[endblock]
|
|
2645
|
+
|
|
2646
|
+
ChemQL queries can be embedded directly using {{ ... }}.
|
|
2647
|
+
This evaluates the query and inserts its result into the surrounding Python.
|
|
2648
|
+
|
|
2649
|
+
{{ search elements name = "Hydrogen" }}
|
|
2650
|
+
[print({{ search elements atomic_number = 1 }})]
|
|
2651
|
+
|
|
2652
|
+
You can also use a bare query block as a value-producing expression:
|
|
2653
|
+
|
|
2654
|
+
{{ search molecules molecular_weight < 20 return name formula }}
|
|
2655
|
+
|
|
2656
|
+
\033[1;33mLINE CONTINUATION\033[0m
|
|
2657
|
+
A line ending with '\\' continues onto the next line.
|
|
2658
|
+
|
|
2659
|
+
search elements \\
|
|
2660
|
+
name = "Hydrogen"
|
|
2661
|
+
|
|
2662
|
+
This is treated as:
|
|
2663
|
+
|
|
2664
|
+
search elements name = "Hydrogen"
|
|
2665
|
+
|
|
2666
|
+
\033[1;33mOPTIONS\033[0m
|
|
2667
|
+
\033[1m-c <command>\033[0m
|
|
2668
|
+
Execute a command.
|
|
2669
|
+
|
|
2670
|
+
\033[1m-f <file>\033[0m
|
|
2671
|
+
Execute commands from a file.
|
|
2672
|
+
|
|
2673
|
+
\033[1m?\033[0m
|
|
2674
|
+
Show help.
|
|
2675
|
+
|
|
2676
|
+
\033[1;33mEXAMPLES\033[0m
|
|
2677
|
+
search elements name = "Hydrogen"
|
|
2678
|
+
|
|
2679
|
+
findel "He"
|
|
2680
|
+
|
|
2681
|
+
findmol "H2O"
|
|
2682
|
+
|
|
2683
|
+
search molecules molecular_weight < 20 \\
|
|
2684
|
+
return name formula molecular_weight
|
|
2685
|
+
|
|
2686
|
+
search elements atomic_number > 10 \\
|
|
2687
|
+
and atomic_number < 20 \\
|
|
2688
|
+
sort atomic_number desc \\
|
|
2689
|
+
limit 5
|
|
2690
|
+
|
|
2691
|
+
search molecules count
|
|
2692
|
+
|
|
2693
|
+
\033[1;36mChemql\033[0m
|
|
2694
|
+
Python Chemistry Query Language
|
|
2695
|
+
"""
|
|
2696
|
+
import webbrowser
|
|
2697
|
+
from pathlib import Path
|
|
2698
|
+
|
|
2699
|
+
def view_periodic_table():
|
|
2700
|
+
path = Path(__file__).parent / "html" /"periodic_table.html"
|
|
2701
|
+
webbrowser.open(path.resolve().as_uri())
|
|
2702
|
+
|
|
2703
|
+
class ChemqlLexer(Lexer):
|
|
2704
|
+
def lex_document(self, document):
|
|
2705
|
+
lines = document.lines
|
|
2706
|
+
|
|
2707
|
+
def get_line(lineno):
|
|
2708
|
+
if lineno >= len(lines):
|
|
2709
|
+
return []
|
|
2710
|
+
|
|
2711
|
+
line = lines[lineno]
|
|
2712
|
+
tokens = []
|
|
2713
|
+
|
|
2714
|
+
i = 0
|
|
2715
|
+
words = line.split()
|
|
2716
|
+
|
|
2717
|
+
# Determine basic query context
|
|
2718
|
+
first_word = words[0] if words else ""
|
|
2719
|
+
|
|
2720
|
+
# search / source / list / return / clear / exit
|
|
2721
|
+
if first_word in {"search", "source", "list", "clear", "exit", "findel", "findmol", "findre", "set", "add", "remove", "react", "conditions", "view", "help", "dump"}:
|
|
2722
|
+
command_start = line.find(first_word)
|
|
2723
|
+
command_end = command_start + len(first_word)
|
|
2724
|
+
|
|
2725
|
+
# Preserve leading whitespace exactly.
|
|
2726
|
+
if command_start > 0:
|
|
2727
|
+
tokens.append((
|
|
2728
|
+
"",
|
|
2729
|
+
line[:command_start]
|
|
2730
|
+
))
|
|
2731
|
+
|
|
2732
|
+
tokens.append((
|
|
2733
|
+
"class:command",
|
|
2734
|
+
first_word
|
|
2735
|
+
))
|
|
2736
|
+
|
|
2737
|
+
i = command_end
|
|
2738
|
+
|
|
2739
|
+
# Everything else is scanned character-by-character
|
|
2740
|
+
while i < len(line):
|
|
2741
|
+
# Whitespace
|
|
2742
|
+
if line[i].isspace():
|
|
2743
|
+
start = i
|
|
2744
|
+
|
|
2745
|
+
while i < len(line) and line[i].isspace():
|
|
2746
|
+
i += 1
|
|
2747
|
+
|
|
2748
|
+
tokens.append(("", line[start:i]))
|
|
2749
|
+
continue
|
|
2750
|
+
|
|
2751
|
+
# Python/query blocks
|
|
2752
|
+
if line.startswith("{{", i):
|
|
2753
|
+
end = line.find("}}", i + 2)
|
|
2754
|
+
|
|
2755
|
+
if end == -1:
|
|
2756
|
+
end = len(line) - 2
|
|
2757
|
+
|
|
2758
|
+
tokens.append((
|
|
2759
|
+
"class:python",
|
|
2760
|
+
line[i:end + 2]
|
|
2761
|
+
))
|
|
2762
|
+
|
|
2763
|
+
i = end + 2
|
|
2764
|
+
continue
|
|
2765
|
+
|
|
2766
|
+
if line[i] == "[":
|
|
2767
|
+
end = line.find("]", i + 1)
|
|
2768
|
+
|
|
2769
|
+
if end == -1:
|
|
2770
|
+
end = len(line) - 1
|
|
2771
|
+
|
|
2772
|
+
tokens.append((
|
|
2773
|
+
"class:python",
|
|
2774
|
+
line[i:end + 1]
|
|
2775
|
+
))
|
|
2776
|
+
|
|
2777
|
+
i = end + 1
|
|
2778
|
+
continue
|
|
2779
|
+
|
|
2780
|
+
# Strings
|
|
2781
|
+
if line[i] in "\"'":
|
|
2782
|
+
quote = line[i]
|
|
2783
|
+
start = i
|
|
2784
|
+
i += 1
|
|
2785
|
+
|
|
2786
|
+
while i < len(line):
|
|
2787
|
+
if line[i] == "\\":
|
|
2788
|
+
i += 2
|
|
2789
|
+
continue
|
|
2790
|
+
|
|
2791
|
+
if line[i] == quote:
|
|
2792
|
+
i += 1
|
|
2793
|
+
break
|
|
2794
|
+
|
|
2795
|
+
i += 1
|
|
2796
|
+
|
|
2797
|
+
tokens.append((
|
|
2798
|
+
"class:string",
|
|
2799
|
+
line[start:i]
|
|
2800
|
+
))
|
|
2801
|
+
|
|
2802
|
+
continue
|
|
2803
|
+
|
|
2804
|
+
# Operators
|
|
2805
|
+
matched_operator = None
|
|
2806
|
+
|
|
2807
|
+
for operator in (">=", "<=", "=", ">", "<"):
|
|
2808
|
+
if line.startswith(operator, i):
|
|
2809
|
+
matched_operator = operator
|
|
2810
|
+
break
|
|
2811
|
+
|
|
2812
|
+
if matched_operator:
|
|
2813
|
+
tokens.append((
|
|
2814
|
+
"class:operator",
|
|
2815
|
+
matched_operator
|
|
2816
|
+
))
|
|
2817
|
+
|
|
2818
|
+
i += len(matched_operator)
|
|
2819
|
+
continue
|
|
2820
|
+
|
|
2821
|
+
# Numbers
|
|
2822
|
+
if line[i].isdigit():
|
|
2823
|
+
start = i
|
|
2824
|
+
|
|
2825
|
+
while i < len(line) and (
|
|
2826
|
+
line[i].isdigit() or line[i] in ".-"
|
|
2827
|
+
):
|
|
2828
|
+
i += 1
|
|
2829
|
+
|
|
2830
|
+
tokens.append((
|
|
2831
|
+
"class:number",
|
|
2832
|
+
line[start:i]
|
|
2833
|
+
))
|
|
2834
|
+
|
|
2835
|
+
continue
|
|
2836
|
+
|
|
2837
|
+
# Words
|
|
2838
|
+
if line[i].isalpha() or line[i] == "_":
|
|
2839
|
+
start = i
|
|
2840
|
+
|
|
2841
|
+
while i < len(line) and (
|
|
2842
|
+
line[i].isalnum() or line[i] == "_"
|
|
2843
|
+
):
|
|
2844
|
+
i += 1
|
|
2845
|
+
|
|
2846
|
+
word = line[start:i]
|
|
2847
|
+
|
|
2848
|
+
# Commands
|
|
2849
|
+
if word in {
|
|
2850
|
+
"search",
|
|
2851
|
+
"source",
|
|
2852
|
+
"list",
|
|
2853
|
+
"clear",
|
|
2854
|
+
"exit",
|
|
2855
|
+
"return",
|
|
2856
|
+
"set",
|
|
2857
|
+
"add",
|
|
2858
|
+
"remove",
|
|
2859
|
+
"react",
|
|
2860
|
+
"conditions",
|
|
2861
|
+
"view",
|
|
2862
|
+
"help",
|
|
2863
|
+
"dump"
|
|
2864
|
+
}:
|
|
2865
|
+
style = "class:command"
|
|
2866
|
+
|
|
2867
|
+
# Sources
|
|
2868
|
+
elif word in SOURCES:
|
|
2869
|
+
style = "class:source"
|
|
2870
|
+
|
|
2871
|
+
elif word.casefold() in {"temperature", "pressure", "catalyst", "catalysts", "standard", "room", "c", "f", "k", "pa", "bar"}:
|
|
2872
|
+
style = "class:condition"
|
|
2873
|
+
|
|
2874
|
+
# Operators
|
|
2875
|
+
elif word in {
|
|
2876
|
+
"and",
|
|
2877
|
+
"or",
|
|
2878
|
+
"has",
|
|
2879
|
+
"like",
|
|
2880
|
+
"like!",
|
|
2881
|
+
"sort",
|
|
2882
|
+
"limit",
|
|
2883
|
+
"count",
|
|
2884
|
+
"asc",
|
|
2885
|
+
"desc"
|
|
2886
|
+
}:
|
|
2887
|
+
style = "class:operator"
|
|
2888
|
+
|
|
2889
|
+
# Control flow
|
|
2890
|
+
elif word in {
|
|
2891
|
+
"if",
|
|
2892
|
+
"elif",
|
|
2893
|
+
"else",
|
|
2894
|
+
"for",
|
|
2895
|
+
"while",
|
|
2896
|
+
"in",
|
|
2897
|
+
"break",
|
|
2898
|
+
"continue",
|
|
2899
|
+
"endblock"
|
|
2900
|
+
}:
|
|
2901
|
+
style = "class:control"
|
|
2902
|
+
|
|
2903
|
+
# Python constants
|
|
2904
|
+
elif word in {
|
|
2905
|
+
"True",
|
|
2906
|
+
"False",
|
|
2907
|
+
"None"
|
|
2908
|
+
}:
|
|
2909
|
+
style = "class:number"
|
|
2910
|
+
|
|
2911
|
+
# Dataset keys
|
|
2912
|
+
elif word in elementKeys or word in moleculeKeys or word in reactionKeys:
|
|
2913
|
+
style = "class:key"
|
|
2914
|
+
|
|
2915
|
+
else:
|
|
2916
|
+
style = ""
|
|
2917
|
+
|
|
2918
|
+
tokens.append((style, word))
|
|
2919
|
+
continue
|
|
2920
|
+
|
|
2921
|
+
# Anything else
|
|
2922
|
+
tokens.append(("", line[i]))
|
|
2923
|
+
i += 1
|
|
2924
|
+
|
|
2925
|
+
return tokens
|
|
2926
|
+
|
|
2927
|
+
return get_line
|
|
2928
|
+
|
|
2929
|
+
class ChemqlCompleter(Completer):
|
|
2930
|
+
def get_completions(self, document, complete_event):
|
|
2931
|
+
text = document.text_before_cursor
|
|
2932
|
+
parts = text.split()
|
|
2933
|
+
trailing_space = bool(text and text[-1].isspace())
|
|
2934
|
+
current = "" if trailing_space or not parts else parts[-1]
|
|
2935
|
+
command_words = parts if trailing_space else parts[:-1]
|
|
2936
|
+
commands = (
|
|
2937
|
+
"search", "source", "list", "clear", "return", "exit",
|
|
2938
|
+
"findel", "findmol", "findre", "set", "add", "remove",
|
|
2939
|
+
"react", "conditions",
|
|
2940
|
+
)
|
|
2941
|
+
|
|
2942
|
+
if not command_words:
|
|
2943
|
+
for command in commands:
|
|
2944
|
+
if command.startswith(current):
|
|
2945
|
+
yield Completion(command, start_position=-len(current))
|
|
2946
|
+
return
|
|
2947
|
+
|
|
2948
|
+
command = command_words[0]
|
|
2949
|
+
suggestions = []
|
|
2950
|
+
|
|
2951
|
+
if len(command_words) == 1:
|
|
2952
|
+
if command == "search":
|
|
2953
|
+
suggestions = list(SOURCES)
|
|
2954
|
+
elif command == "source":
|
|
2955
|
+
suggestions = list(SOURCES)
|
|
2956
|
+
elif command == "set":
|
|
2957
|
+
suggestions = ["temperature", "pressure", "catalysts"]
|
|
2958
|
+
elif command in {"add", "remove"}:
|
|
2959
|
+
suggestions = ["catalyst"]
|
|
2960
|
+
|
|
2961
|
+
elif command == "set" and len(command_words) == 2:
|
|
2962
|
+
setting = command_words[1].casefold()
|
|
2963
|
+
if setting == "temperature":
|
|
2964
|
+
suggestions = ["standard", "room", "0C", "0F", "0K"]
|
|
2965
|
+
elif setting == "pressure":
|
|
2966
|
+
suggestions = ["standard", "room", "0Pa", "0N/m^2", "0Nm^-2", "0bar"]
|
|
2967
|
+
elif setting == "catalysts":
|
|
2968
|
+
suggestions = list(reaction_state["catalysts"])
|
|
2969
|
+
|
|
2970
|
+
elif command == "search" and len(command_words) >= 2:
|
|
2971
|
+
source_name = command_words[1]
|
|
2972
|
+
if source_name in SOURCES:
|
|
2973
|
+
_, keys = SOURCES[source_name]
|
|
2974
|
+
suggestions = list(keys) + [
|
|
2975
|
+
"=", ">", "<", ">=", "<=", "like", "like!", "has",
|
|
2976
|
+
"sort", "limit", "count", "asc", "desc", "return",
|
|
2977
|
+
]
|
|
2978
|
+
|
|
2979
|
+
elif command == "remove" and len(command_words) == 2 and command_words[1] == "catalyst":
|
|
2980
|
+
suggestions = list(reaction_state["catalysts"])
|
|
2981
|
+
|
|
2982
|
+
for suggestion in suggestions:
|
|
2983
|
+
if suggestion.casefold().startswith(current.casefold()):
|
|
2984
|
+
yield Completion(suggestion, start_position=-len(current))
|
|
2985
|
+
|
|
2986
|
+
|
|
2987
|
+
|
|
2988
|
+
chemql_style = Style.from_dict({
|
|
2989
|
+
"command": "ansicyan",
|
|
2990
|
+
"source": "ansiblue",
|
|
2991
|
+
"key": "ansimagenta",
|
|
2992
|
+
"operator": "ansiyellow",
|
|
2993
|
+
"string": "ansigreen",
|
|
2994
|
+
"number": "ansimagenta",
|
|
2995
|
+
"control": "ansired",
|
|
2996
|
+
"python": "ansibrightcyan",
|
|
2997
|
+
"condition": "ansibrightyellow",
|
|
2998
|
+
})
|
|
2999
|
+
|
|
3000
|
+
|
|
3001
|
+
def main():
|
|
3002
|
+
parser = argparse.ArgumentParser(
|
|
3003
|
+
description="Chemql query and chemistry language"
|
|
3004
|
+
)
|
|
3005
|
+
|
|
3006
|
+
parser.add_argument(
|
|
3007
|
+
"-c",
|
|
3008
|
+
"--command",
|
|
3009
|
+
help="Execute Chemql code"
|
|
3010
|
+
)
|
|
3011
|
+
|
|
3012
|
+
parser.add_argument(
|
|
3013
|
+
"-f",
|
|
3014
|
+
"--file",
|
|
3015
|
+
help="Execute Chemql code from a file"
|
|
3016
|
+
)
|
|
3017
|
+
|
|
3018
|
+
parser.add_argument(
|
|
3019
|
+
"-v",
|
|
3020
|
+
"--version",
|
|
3021
|
+
action="version",
|
|
3022
|
+
version="0.1.0",
|
|
3023
|
+
)
|
|
3024
|
+
|
|
3025
|
+
args = parser.parse_args()
|
|
3026
|
+
|
|
3027
|
+
if args.command:
|
|
3028
|
+
process_lines(args.command.splitlines())
|
|
3029
|
+
|
|
3030
|
+
elif args.file:
|
|
3031
|
+
with open(args.file) as f:
|
|
3032
|
+
code = f.read()
|
|
3033
|
+
process_lines(code.splitlines())
|
|
3034
|
+
|
|
3035
|
+
else:
|
|
3036
|
+
run_interactive()
|
|
3037
|
+
|
|
3038
|
+
def run_interactive():
|
|
3039
|
+
history = []
|
|
3040
|
+
print("\033[1mType help or ? for help\033[0m")
|
|
3041
|
+
session = PromptSession(
|
|
3042
|
+
lexer=ChemqlLexer(),
|
|
3043
|
+
completer=ChemqlCompleter(),
|
|
3044
|
+
style=chemql_style,
|
|
3045
|
+
reserve_space_for_menu=0,
|
|
3046
|
+
)
|
|
3047
|
+
while True:
|
|
3048
|
+
try:
|
|
3049
|
+
first_line = session.prompt(">>> ")
|
|
3050
|
+
|
|
3051
|
+
except KeyboardInterrupt:
|
|
3052
|
+
print()
|
|
3053
|
+
break
|
|
3054
|
+
|
|
3055
|
+
except EOFError:
|
|
3056
|
+
print("\n\033[34mUse \033[1mCTRL+C\033[22m or type '\033[1mexit\033[22m' to exit\033[0m")
|
|
3057
|
+
continue
|
|
3058
|
+
|
|
3059
|
+
if first_line.strip() == "exit":
|
|
3060
|
+
break
|
|
3061
|
+
|
|
3062
|
+
elif first_line.strip() in ("?", "help"):
|
|
3063
|
+
print(HELP)
|
|
3064
|
+
continue
|
|
3065
|
+
|
|
3066
|
+
elif first_line.strip().lower() == "sorry":
|
|
3067
|
+
print("\033[34mIt's ok, everybody makes mistakes👌\033[0m")
|
|
3068
|
+
continue
|
|
3069
|
+
|
|
3070
|
+
elif first_line.strip().split()[0] == "dump":
|
|
3071
|
+
arguments = first_line.strip().split()
|
|
3072
|
+
|
|
3073
|
+
if len(arguments) != 2:
|
|
3074
|
+
print("Usage: dump <file>")
|
|
3075
|
+
else:
|
|
3076
|
+
filename = arguments[1]
|
|
3077
|
+
|
|
3078
|
+
try:
|
|
3079
|
+
with open(filename, "x") as f:
|
|
3080
|
+
f.write("\n".join(history))
|
|
3081
|
+
|
|
3082
|
+
except FileExistsError:
|
|
3083
|
+
action = choice(
|
|
3084
|
+
message=f"`{filename}` already exists. What would you like to do?",
|
|
3085
|
+
options=[("append", "Append to existing file"), ("overwrite", "Overwrite existing file"), ("cancel", "Cancel")],
|
|
3086
|
+
)
|
|
3087
|
+
|
|
3088
|
+
if action == "append":
|
|
3089
|
+
with open(filename, "a") as f:
|
|
3090
|
+
f.write("\n" + "\n".join(history))
|
|
3091
|
+
|
|
3092
|
+
elif action == "overwrite":
|
|
3093
|
+
with open(filename, "w") as f:
|
|
3094
|
+
f.write("\n".join(history))
|
|
3095
|
+
|
|
3096
|
+
continue
|
|
3097
|
+
|
|
3098
|
+
if not first_line.strip():
|
|
3099
|
+
continue
|
|
3100
|
+
|
|
3101
|
+
# Collect continued input.
|
|
3102
|
+
lines = []
|
|
3103
|
+
current_line = first_line
|
|
3104
|
+
|
|
3105
|
+
while True:
|
|
3106
|
+
if current_line.rstrip().endswith("\\"):
|
|
3107
|
+
current_line = current_line.rstrip()[:-1] + " "
|
|
3108
|
+
|
|
3109
|
+
try:
|
|
3110
|
+
next_line = session.prompt("... ")
|
|
3111
|
+
except KeyboardInterrupt:
|
|
3112
|
+
print()
|
|
3113
|
+
break
|
|
3114
|
+
except EOFError:
|
|
3115
|
+
print("\n\033[31mError: Incomplete continued input\033[0m")
|
|
3116
|
+
break
|
|
3117
|
+
|
|
3118
|
+
current_line += next_line
|
|
3119
|
+
else:
|
|
3120
|
+
lines.append(current_line)
|
|
3121
|
+
break
|
|
3122
|
+
|
|
3123
|
+
# Collect a Python control block until its matching [endblock].
|
|
3124
|
+
if lines and is_python_block_header(lines[0].strip()):
|
|
3125
|
+
depth = 1
|
|
3126
|
+
|
|
3127
|
+
while depth:
|
|
3128
|
+
try:
|
|
3129
|
+
line = session.prompt(">>> ")
|
|
3130
|
+
except KeyboardInterrupt:
|
|
3131
|
+
print()
|
|
3132
|
+
break
|
|
3133
|
+
except EOFError:
|
|
3134
|
+
print("\n\033[31mMissing `\033[1m[endblock]\033[22m`\033[0m")
|
|
3135
|
+
break
|
|
3136
|
+
|
|
3137
|
+
lines.append(line)
|
|
3138
|
+
|
|
3139
|
+
if is_python_block_header(line.strip()):
|
|
3140
|
+
depth += 1
|
|
3141
|
+
elif line.strip() == "[endblock]":
|
|
3142
|
+
depth -= 1
|
|
3143
|
+
else:
|
|
3144
|
+
pass
|
|
3145
|
+
|
|
3146
|
+
try:
|
|
3147
|
+
process_lines(lines)
|
|
3148
|
+
history.extend(lines)
|
|
3149
|
+
except Exception as e:
|
|
3150
|
+
print(f"\033[31mError: {e}\033[0m")
|
|
3151
|
+
|
|
3152
|
+
|
|
3153
|
+
if __name__ == "__main__":
|
|
3154
|
+
main()
|