chemql-python 1.2.6__py3-none-any.whl → 1.3.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 +1243 -366
- chemql_python-1.3.0.dist-info/METADATA +904 -0
- {chemql_python-1.2.6.dist-info → chemql_python-1.3.0.dist-info}/RECORD +7 -7
- chemql_python-1.2.6.dist-info/METADATA +0 -1067
- {chemql_python-1.2.6.dist-info → chemql_python-1.3.0.dist-info}/WHEEL +0 -0
- {chemql_python-1.2.6.dist-info → chemql_python-1.3.0.dist-info}/entry_points.txt +0 -0
- {chemql_python-1.2.6.dist-info → chemql_python-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {chemql_python-1.2.6.dist-info → chemql_python-1.3.0.dist-info}/top_level.txt +0 -0
chemql/__init__.py
CHANGED
|
@@ -35,6 +35,11 @@ __all__ = [
|
|
|
35
35
|
"Element",
|
|
36
36
|
"Molecule",
|
|
37
37
|
"Reaction",
|
|
38
|
+
"MOLECULES",
|
|
39
|
+
"REACTIONS",
|
|
40
|
+
"find_molecule",
|
|
41
|
+
"find_reaction",
|
|
42
|
+
"search_reactions",
|
|
38
43
|
"ReturnTable",
|
|
39
44
|
"Unknown",
|
|
40
45
|
"balance_stoichiometry",
|
|
@@ -47,7 +52,7 @@ __all__ = [
|
|
|
47
52
|
"__version__"
|
|
48
53
|
]
|
|
49
54
|
|
|
50
|
-
__version__ = "1.
|
|
55
|
+
__version__ = "1.3.0"
|
|
51
56
|
|
|
52
57
|
# Persistent preprocessor/function state for the current ChemQL session.
|
|
53
58
|
DEFINITIONS = {}
|
|
@@ -135,16 +140,23 @@ def _call_function(name, arguments):
|
|
|
135
140
|
had_arguments = "arguments" in python_globals
|
|
136
141
|
saved_arguments = python_globals.get("arguments")
|
|
137
142
|
|
|
138
|
-
# Expose
|
|
143
|
+
# Expose arguments through temporary names rather than repr(). ChemQL
|
|
144
|
+
# structs display as `Fruit{...}`, which is not valid Python syntax; using
|
|
145
|
+
# names also preserves object identity and supports lists/dicts/custom types.
|
|
139
146
|
python_globals["arguments"] = list(values)
|
|
147
|
+
parameter_names = []
|
|
148
|
+
for value in values:
|
|
149
|
+
parameter_name = "__chemql_func_arg_" + uuid.uuid4().hex
|
|
150
|
+
python_globals[parameter_name] = value
|
|
151
|
+
parameter_names.append(parameter_name)
|
|
140
152
|
|
|
141
153
|
def substitute_parameters(source):
|
|
142
|
-
"""Replace \\1, \\2, etc.
|
|
154
|
+
"""Replace \\1, \\2, etc. with safe names bound to the actual values."""
|
|
143
155
|
return re.sub(
|
|
144
156
|
r"\\(\d+)",
|
|
145
157
|
lambda match: (
|
|
146
|
-
|
|
147
|
-
if 0 < int(match.group(1)) <= len(
|
|
158
|
+
parameter_names[int(match.group(1)) - 1]
|
|
159
|
+
if 0 < int(match.group(1)) <= len(parameter_names)
|
|
148
160
|
else match.group(0)
|
|
149
161
|
),
|
|
150
162
|
source,
|
|
@@ -153,7 +165,7 @@ def _call_function(name, arguments):
|
|
|
153
165
|
def normalize_let_line(source):
|
|
154
166
|
"""Translate let name = expression into a Python assignment."""
|
|
155
167
|
match = re.fullmatch(
|
|
156
|
-
r"\s*let\s+([A-Za-z_]\w*)\s*=\s*(.*?)\s*",
|
|
168
|
+
r"\s*let\s+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*=\s*(.*?)\s*",
|
|
157
169
|
source,
|
|
158
170
|
)
|
|
159
171
|
|
|
@@ -184,6 +196,50 @@ def _call_function(name, arguments):
|
|
|
184
196
|
i += 1
|
|
185
197
|
continue
|
|
186
198
|
|
|
199
|
+
# Structured error handling is available inside functions too.
|
|
200
|
+
if line.casefold() == "risk":
|
|
201
|
+
end_risk = i + 1
|
|
202
|
+
risk_depth = 1
|
|
203
|
+
while end_risk < len(lines):
|
|
204
|
+
token = lines[end_risk].strip().casefold()
|
|
205
|
+
if token == "risk": risk_depth += 1
|
|
206
|
+
elif token == "endrisk":
|
|
207
|
+
risk_depth -= 1
|
|
208
|
+
if risk_depth == 0: break
|
|
209
|
+
end_risk += 1
|
|
210
|
+
if end_risk >= len(lines):
|
|
211
|
+
raise SyntaxError(f"Missing `endrisk` in function `{name}`")
|
|
212
|
+
sections = {"risk": [], "fix": [], "cleanup": []}
|
|
213
|
+
section = "risk"
|
|
214
|
+
for risk_line in lines[i + 1:end_risk]:
|
|
215
|
+
marker = risk_line.strip().casefold()
|
|
216
|
+
if marker in {"fix", "cleanup"}: section = marker
|
|
217
|
+
else: sections[section].append(risk_line)
|
|
218
|
+
try:
|
|
219
|
+
result = execute_function_lines(sections["risk"])
|
|
220
|
+
if result is not _NO_RETURN:
|
|
221
|
+
return result
|
|
222
|
+
python_globals["error"] = None
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
python_globals["error"] = exc.error_type if isinstance(exc, ChemQLSignal) else type(exc).__name__
|
|
225
|
+
if sections["fix"]:
|
|
226
|
+
result = execute_function_lines(sections["fix"])
|
|
227
|
+
if result is not _NO_RETURN:
|
|
228
|
+
return result
|
|
229
|
+
elif not isinstance(exc, ChemQLSignal):
|
|
230
|
+
raise
|
|
231
|
+
finally:
|
|
232
|
+
# Cleanup is for side effects; it must not override a return
|
|
233
|
+
# from the risk/fix section (and returning from finally is unsafe).
|
|
234
|
+
if sections["cleanup"]:
|
|
235
|
+
execute_function_lines(sections["cleanup"])
|
|
236
|
+
i = end_risk + 1
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
signal_match = re.fullmatch(r"signal\s+(.+)", line, re.IGNORECASE)
|
|
240
|
+
if signal_match:
|
|
241
|
+
raise ChemQLSignal(evaluate_python(signal_match.group(1)))
|
|
242
|
+
|
|
187
243
|
# Multiline Python block.
|
|
188
244
|
if line == "[[[":
|
|
189
245
|
block = []
|
|
@@ -212,15 +268,27 @@ def _call_function(name, arguments):
|
|
|
212
268
|
# line,
|
|
213
269
|
# )
|
|
214
270
|
|
|
215
|
-
|
|
216
|
-
|
|
271
|
+
# Resolve positional placeholders BEFORE parsing the header.
|
|
272
|
+
# Otherwise `[if \1 <= 1]` is rejected as non-Python syntax before
|
|
273
|
+
# `\1` can be replaced with the actual argument.
|
|
274
|
+
resolved_line = line
|
|
275
|
+
if line.startswith("[") and line.endswith("]"):
|
|
276
|
+
resolved_line = "[" + _expand_definitions(
|
|
277
|
+
substitute_parameters(line[1:-1].strip())
|
|
278
|
+
) + "]"
|
|
279
|
+
|
|
280
|
+
if is_block_header(resolved_line):
|
|
281
|
+
# Nested block discovery must see substituted headers too,
|
|
282
|
+
# or a nested `[if \1 ...]` won't increase the block depth.
|
|
283
|
+
resolved_lines = [substitute_parameters(source_line) for source_line in lines]
|
|
284
|
+
end = find_endblock(resolved_lines, i + 1)
|
|
217
285
|
|
|
218
286
|
block_body = [
|
|
219
287
|
normalize_let_line(substitute_parameters(source_line))
|
|
220
288
|
for source_line in lines[i + 1:end]
|
|
221
289
|
]
|
|
222
290
|
|
|
223
|
-
code =
|
|
291
|
+
code = resolved_line[1:-1].strip()
|
|
224
292
|
|
|
225
293
|
# Function parameters must also be substituted in block
|
|
226
294
|
# headers, e.g. [for i in range(2, \1 + 1)].
|
|
@@ -242,15 +310,24 @@ def _call_function(name, arguments):
|
|
|
242
310
|
condition = substitute_parameters(condition)
|
|
243
311
|
condition = _expand_definitions(condition)
|
|
244
312
|
|
|
245
|
-
# Find the
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
313
|
+
# Find the `else` belonging to this `if`, not an `else`
|
|
314
|
+
# nested inside one of its child blocks. Without tracking
|
|
315
|
+
# nesting, the false branch of an inner `if` can be
|
|
316
|
+
# mistaken for the outer `if`'s `else`.
|
|
317
|
+
else_index = None
|
|
318
|
+
nested_depth = 0
|
|
319
|
+
for j, source_line in enumerate(block_body):
|
|
320
|
+
token = source_line.strip()
|
|
321
|
+
if is_block_header(token):
|
|
322
|
+
nested_depth += 1
|
|
323
|
+
continue
|
|
324
|
+
if token in {"[endblock]", "[endif]", "[endfor]", "[endwhile]"}:
|
|
325
|
+
if nested_depth:
|
|
326
|
+
nested_depth -= 1
|
|
327
|
+
continue
|
|
328
|
+
if token == "[else]" and nested_depth == 0:
|
|
329
|
+
else_index = j
|
|
330
|
+
break
|
|
254
331
|
|
|
255
332
|
if evaluate_python(condition):
|
|
256
333
|
selected_body = (
|
|
@@ -666,7 +743,15 @@ def _run_shell_command_posix(command, marker=None):
|
|
|
666
743
|
_shell_prompt_ready = True
|
|
667
744
|
|
|
668
745
|
try:
|
|
669
|
-
|
|
746
|
+
# Send a fenced block as ONE shell input command. Feeding raw newlines
|
|
747
|
+
# to an interactive Bash PTY lets Bash emit PS1 after the first physical
|
|
748
|
+
# line, so wait_for_prompt() can return early while later lines remain
|
|
749
|
+
# queued and execute out of sync with the next ChemQL command. Quoting
|
|
750
|
+
# the complete script keeps its newlines inside one `eval` command;
|
|
751
|
+
# eval still runs in the persistent shell, so `cd` and shell variables
|
|
752
|
+
# remain effective for later commands.
|
|
753
|
+
shell_input = "eval " + shlex.quote(command)
|
|
754
|
+
os.write(master_fd, (shell_input + "\n").encode())
|
|
670
755
|
result = wait_for_prompt(forward_keyboard=True)
|
|
671
756
|
if result is None:
|
|
672
757
|
close_shell()
|
|
@@ -904,6 +989,9 @@ class ChemQLError(Exception):
|
|
|
904
989
|
self.length = length
|
|
905
990
|
|
|
906
991
|
|
|
992
|
+
_CHEMQL_RISK_DEPTH = 0
|
|
993
|
+
|
|
994
|
+
|
|
907
995
|
def format_error(message, text=None, position=None, length=1):
|
|
908
996
|
output = f"Error: {message}"
|
|
909
997
|
|
|
@@ -1272,6 +1360,131 @@ def _find_local_molecule(value):
|
|
|
1272
1360
|
return None
|
|
1273
1361
|
|
|
1274
1362
|
|
|
1363
|
+
CHEMSPIDER_BASE_URL = "https://api.rsc.org/compounds/v1"
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _chemspider_request(method, endpoint, payload=None, timeout=12):
|
|
1367
|
+
"""Call the authenticated ChemSpider Compound API; return decoded JSON or None."""
|
|
1368
|
+
api_key = os.getenv("CHEMSPIDER_API_KEY", "").strip()
|
|
1369
|
+
if not api_key:
|
|
1370
|
+
return None
|
|
1371
|
+
|
|
1372
|
+
url = f"{CHEMSPIDER_BASE_URL}/{endpoint.lstrip('/')}"
|
|
1373
|
+
body = None if payload is None else json.dumps(payload).encode("utf-8")
|
|
1374
|
+
headers = {
|
|
1375
|
+
"apikey": api_key,
|
|
1376
|
+
"Accept": "application/json",
|
|
1377
|
+
"User-Agent": "ChemQL",
|
|
1378
|
+
}
|
|
1379
|
+
if body is not None:
|
|
1380
|
+
headers["Content-Type"] = "application/json"
|
|
1381
|
+
|
|
1382
|
+
request = urllib.request.Request(
|
|
1383
|
+
url, data=body, headers=headers, method=method.upper()
|
|
1384
|
+
)
|
|
1385
|
+
try:
|
|
1386
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
1387
|
+
raw = response.read().decode("utf-8", errors="replace")
|
|
1388
|
+
return json.loads(raw) if raw.strip() else {}
|
|
1389
|
+
except (
|
|
1390
|
+
urllib.error.HTTPError,
|
|
1391
|
+
urllib.error.URLError,
|
|
1392
|
+
TimeoutError,
|
|
1393
|
+
OSError,
|
|
1394
|
+
json.JSONDecodeError,
|
|
1395
|
+
ValueError,
|
|
1396
|
+
):
|
|
1397
|
+
return None
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _chemspider_record_ids(target):
|
|
1401
|
+
"""Search ChemSpider by name and wait briefly for the asynchronous query."""
|
|
1402
|
+
submitted = _chemspider_request("POST", "filter/name", {"name": target})
|
|
1403
|
+
if not isinstance(submitted, dict):
|
|
1404
|
+
return []
|
|
1405
|
+
|
|
1406
|
+
query_id = submitted.get("queryId") or submitted.get("query_id")
|
|
1407
|
+
if not query_id:
|
|
1408
|
+
ids = submitted.get("recordIds") or submitted.get("results") or []
|
|
1409
|
+
return ids if isinstance(ids, list) else []
|
|
1410
|
+
|
|
1411
|
+
for attempt in range(10):
|
|
1412
|
+
status_data = _chemspider_request("GET", f"filter/{query_id}/status")
|
|
1413
|
+
if not isinstance(status_data, dict):
|
|
1414
|
+
return []
|
|
1415
|
+
status = str(status_data.get("status", "")).casefold()
|
|
1416
|
+
if status in {"complete", "completed", "finished"}:
|
|
1417
|
+
break
|
|
1418
|
+
if status in {"failed", "error", "cancelled", "canceled"}:
|
|
1419
|
+
return []
|
|
1420
|
+
time.sleep(0.4 + attempt * 0.1)
|
|
1421
|
+
else:
|
|
1422
|
+
return []
|
|
1423
|
+
|
|
1424
|
+
results = _chemspider_request("GET", f"filter/{query_id}/results")
|
|
1425
|
+
if isinstance(results, list):
|
|
1426
|
+
return results
|
|
1427
|
+
if isinstance(results, dict):
|
|
1428
|
+
ids = results.get("recordIds") or results.get("results") or results.get("ids")
|
|
1429
|
+
return ids if isinstance(ids, list) else []
|
|
1430
|
+
return []
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def _fetch_chemspider_molecule(value):
|
|
1434
|
+
"""Fetch a ChemSpider compound and normalize it to ChemQL's Molecule model.
|
|
1435
|
+
|
|
1436
|
+
Requires CHEMSPIDER_API_KEY in the environment. Returns None when the key
|
|
1437
|
+
is absent, the API fails, or no suitable record is returned.
|
|
1438
|
+
"""
|
|
1439
|
+
target = _normalize_molecule_value(value)
|
|
1440
|
+
if not target or not os.getenv("CHEMSPIDER_API_KEY", "").strip():
|
|
1441
|
+
return None
|
|
1442
|
+
|
|
1443
|
+
record_ids = _chemspider_record_ids(target)
|
|
1444
|
+
if not record_ids:
|
|
1445
|
+
return None
|
|
1446
|
+
|
|
1447
|
+
record_id = record_ids[0]
|
|
1448
|
+
if isinstance(record_id, dict):
|
|
1449
|
+
record_id = record_id.get("recordId") or record_id.get("id") or record_id.get("csid")
|
|
1450
|
+
if record_id is None:
|
|
1451
|
+
return None
|
|
1452
|
+
|
|
1453
|
+
details = _chemspider_request("GET", f"records/{record_id}/details")
|
|
1454
|
+
if not isinstance(details, dict):
|
|
1455
|
+
return None
|
|
1456
|
+
|
|
1457
|
+
def pick(*names, default=None):
|
|
1458
|
+
for name in names:
|
|
1459
|
+
candidate = details.get(name)
|
|
1460
|
+
if candidate is not None and candidate != "":
|
|
1461
|
+
return candidate
|
|
1462
|
+
return default
|
|
1463
|
+
|
|
1464
|
+
name = pick("commonName", "common_name", "name", default=target)
|
|
1465
|
+
formula = pick("molecularFormula", "molecular_formula", "formula", default="")
|
|
1466
|
+
weight = pick("molecularWeight", "molecular_weight", "averageMass", default=0)
|
|
1467
|
+
smiles = pick("smiles", "SMILES", "canonicalSmiles", default="")
|
|
1468
|
+
iupac_name = pick("iupacName", "iupac_name", "systematicName", default="")
|
|
1469
|
+
try:
|
|
1470
|
+
weight = float(weight or 0)
|
|
1471
|
+
except (TypeError, ValueError):
|
|
1472
|
+
weight = 0.0
|
|
1473
|
+
|
|
1474
|
+
return Molecule(
|
|
1475
|
+
name=str(name),
|
|
1476
|
+
formula=str(formula or ""),
|
|
1477
|
+
elements=[],
|
|
1478
|
+
molecular_weight=weight,
|
|
1479
|
+
iupac_name=str(iupac_name or ""),
|
|
1480
|
+
smiles=str(smiles or ""),
|
|
1481
|
+
cid=None,
|
|
1482
|
+
melting_point=None,
|
|
1483
|
+
boiling_point=None,
|
|
1484
|
+
density=None,
|
|
1485
|
+
state=None,
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1275
1488
|
def _fetch_nist_molecule(value):
|
|
1276
1489
|
"""Try NIST WebBook as a fallback chemistry source."""
|
|
1277
1490
|
target = _normalize_molecule_value(value)
|
|
@@ -1317,10 +1530,16 @@ def fetch_external_molecule(value):
|
|
|
1317
1530
|
# PubChem is the first external source because it is the most complete.
|
|
1318
1531
|
cid = _find_cid(target)
|
|
1319
1532
|
if cid is None:
|
|
1533
|
+
chemspider_molecule = _fetch_chemspider_molecule(target)
|
|
1534
|
+
if chemspider_molecule is not None:
|
|
1535
|
+
return chemspider_molecule
|
|
1320
1536
|
return _fetch_nist_molecule(target)
|
|
1321
1537
|
|
|
1322
1538
|
properties = _get_basic_properties(cid)
|
|
1323
1539
|
if properties is None:
|
|
1540
|
+
chemspider_molecule = _fetch_chemspider_molecule(target)
|
|
1541
|
+
if chemspider_molecule is not None:
|
|
1542
|
+
return chemspider_molecule
|
|
1324
1543
|
return _fetch_nist_molecule(target)
|
|
1325
1544
|
|
|
1326
1545
|
view_data = _get_view_data(cid)
|
|
@@ -1724,7 +1943,7 @@ def molecule_bond_energy(formula):
|
|
|
1724
1943
|
|
|
1725
1944
|
class Reaction:
|
|
1726
1945
|
__slots__ = ("id", "name", "equation", "reaction_type", "reversible", "reactants", "products", "conditions", "notes", "source")
|
|
1727
|
-
def __init__(self, id_, name, reaction_type, reversible, reactants, products, conditions, notes, source=""):
|
|
1946
|
+
def __init__(self, id_, name, reaction_type, reversible, reactants, products, conditions, notes, source="", equation=None):
|
|
1728
1947
|
self.id = id_
|
|
1729
1948
|
self.name = name
|
|
1730
1949
|
self.reaction_type = reaction_type
|
|
@@ -1734,34 +1953,39 @@ class Reaction:
|
|
|
1734
1953
|
self.conditions = conditions
|
|
1735
1954
|
self.notes = notes
|
|
1736
1955
|
self.source = source
|
|
1737
|
-
subscript_digits = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
|
|
1738
1956
|
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
arrow = " ⇌ " if self.reversible is True else " → " if self.reversible is False else " = "
|
|
1748
|
-
self.equation = f"{reactant_text}{arrow}{product_text}"
|
|
1749
|
-
|
|
1750
|
-
if reversible is not None:
|
|
1751
|
-
reactant_energy = sum(
|
|
1752
|
-
molecule_bond_energy(item["molecule"]) * item["stoichiometric_coefficient"]
|
|
1957
|
+
# Remote databases often provide a curated equation that should be preserved
|
|
1958
|
+
# verbatim rather than reconstructed from the local participant schema.
|
|
1959
|
+
if equation is not None:
|
|
1960
|
+
self.equation = str(equation)
|
|
1961
|
+
else:
|
|
1962
|
+
subscript_digits = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
|
|
1963
|
+
reactant_text = " + ".join(
|
|
1964
|
+
f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
|
|
1753
1965
|
for item in reactants
|
|
1754
1966
|
)
|
|
1755
|
-
|
|
1756
|
-
|
|
1967
|
+
product_text = " + ".join(
|
|
1968
|
+
f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
|
|
1757
1969
|
for item in products
|
|
1758
1970
|
)
|
|
1759
|
-
|
|
1760
|
-
self.equation
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1971
|
+
arrow = " ⇌ " if self.reversible is True else " → " if self.reversible is False else " = "
|
|
1972
|
+
self.equation = f"{reactant_text}{arrow}{product_text}"
|
|
1973
|
+
|
|
1974
|
+
if reversible is not None:
|
|
1975
|
+
reactant_energy = sum(
|
|
1976
|
+
molecule_bond_energy(item["molecule"]) * item["stoichiometric_coefficient"]
|
|
1977
|
+
for item in reactants
|
|
1978
|
+
)
|
|
1979
|
+
product_energy = sum(
|
|
1980
|
+
molecule_bond_energy(item["molecule"]) * item["stoichiometric_coefficient"]
|
|
1981
|
+
for item in products
|
|
1982
|
+
)
|
|
1983
|
+
total_energy = reactant_energy - product_energy
|
|
1984
|
+
self.equation += f"\tΔH={total_energy}kJ/mol"
|
|
1985
|
+
if self.conditions:
|
|
1986
|
+
self.equation += "\nConditions:\n"
|
|
1987
|
+
for i in self.conditions:
|
|
1988
|
+
self.equation += f"\t{i}: {self.conditions[i]}\n"
|
|
1765
1989
|
def __str__(self):
|
|
1766
1990
|
return self.equation
|
|
1767
1991
|
def __repr__(self):
|
|
@@ -1769,6 +1993,284 @@ class Reaction:
|
|
|
1769
1993
|
|
|
1770
1994
|
|
|
1771
1995
|
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]
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
def find_molecule(value):
|
|
1999
|
+
"""Find a built-in molecule by name, formula, or IUPAC name."""
|
|
2000
|
+
return _find_local_molecule(value)
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def find_reaction(value, external=True):
|
|
2004
|
+
"""Find a local reaction first, then optionally query Rhea and Chempirical."""
|
|
2005
|
+
target = str(value).strip()
|
|
2006
|
+
folded = target.casefold()
|
|
2007
|
+
if not folded:
|
|
2008
|
+
return None
|
|
2009
|
+
for reaction in REACTIONS:
|
|
2010
|
+
if str(reaction.id).casefold() == folded:
|
|
2011
|
+
return reaction
|
|
2012
|
+
local = next((r for r in REACTIONS if folded in r.name.casefold() or folded in r.equation.casefold()), None)
|
|
2013
|
+
if local is not None or not external:
|
|
2014
|
+
return local
|
|
2015
|
+
# External sources are strictly a fallback: the bundled Reactions.json remains
|
|
2016
|
+
# the first source and all existing offline searches continue to work.
|
|
2017
|
+
for lookup in (_fetch_chempirical_reaction, _fetch_rhea_reaction):
|
|
2018
|
+
try:
|
|
2019
|
+
found = lookup(target)
|
|
2020
|
+
except Exception:
|
|
2021
|
+
found = None
|
|
2022
|
+
if found is not None:
|
|
2023
|
+
return found
|
|
2024
|
+
return None
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
REACTION_API_TIMEOUT = 8
|
|
2028
|
+
RHEA_API_URL = "https://www.rhea-db.org/rhea/"
|
|
2029
|
+
CHEMPIRICAL_API_URL = "https://chempirical.com/api/reactions"
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def _reaction_api_json(url, timeout=REACTION_API_TIMEOUT):
|
|
2033
|
+
"""Fetch a JSON API response; failures are non-fatal for optional lookups."""
|
|
2034
|
+
request = urllib.request.Request(
|
|
2035
|
+
url,
|
|
2036
|
+
headers={
|
|
2037
|
+
"User-Agent": "ChemQL/1.3.0 (reaction lookup; contact: chemql-user)",
|
|
2038
|
+
"Accept": "application/json",
|
|
2039
|
+
},
|
|
2040
|
+
)
|
|
2041
|
+
try:
|
|
2042
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
2043
|
+
return json.loads(response.read().decode("utf-8", errors="replace"))
|
|
2044
|
+
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError, ValueError):
|
|
2045
|
+
return None
|
|
2046
|
+
|
|
2047
|
+
|
|
2048
|
+
def _reaction_api_text(url, timeout=REACTION_API_TIMEOUT):
|
|
2049
|
+
"""Fetch plain-text (TSV) data from Rhea."""
|
|
2050
|
+
request = urllib.request.Request(
|
|
2051
|
+
url,
|
|
2052
|
+
headers={
|
|
2053
|
+
"User-Agent": "ChemQL/1.3.0 (reaction lookup; contact: chemql-user)",
|
|
2054
|
+
"Accept": "text/tab-separated-values,text/plain,*/*",
|
|
2055
|
+
},
|
|
2056
|
+
)
|
|
2057
|
+
try:
|
|
2058
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
2059
|
+
return response.read().decode("utf-8", errors="replace")
|
|
2060
|
+
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError):
|
|
2061
|
+
return ""
|
|
2062
|
+
|
|
2063
|
+
|
|
2064
|
+
def _remote_reaction(reaction_id, name, equation, reaction_type, source, notes="", conditions=None, reversible=None, reactants=None, products=None):
|
|
2065
|
+
"""Adapt a remote reaction into ChemQL's Reaction type with participant data."""
|
|
2066
|
+
if not name and not equation:
|
|
2067
|
+
return None
|
|
2068
|
+
reactants = reactants if isinstance(reactants, list) else []
|
|
2069
|
+
products = products if isinstance(products, list) else []
|
|
2070
|
+
if reversible is None and equation:
|
|
2071
|
+
reversible = "⇌" in equation or "<=>" in equation or "<->" in equation
|
|
2072
|
+
return Reaction(
|
|
2073
|
+
str(reaction_id or f"{source}:unknown"),
|
|
2074
|
+
str(name or reaction_id or "External reaction"),
|
|
2075
|
+
str(reaction_type or "Unknown"),
|
|
2076
|
+
reversible,
|
|
2077
|
+
reactants,
|
|
2078
|
+
products,
|
|
2079
|
+
conditions if isinstance(conditions, dict) else {},
|
|
2080
|
+
str(notes or ""),
|
|
2081
|
+
str(source),
|
|
2082
|
+
equation=str(equation or "Equation unavailable"),
|
|
2083
|
+
)
|
|
2084
|
+
|
|
2085
|
+
|
|
2086
|
+
def _chempirical_participant(item):
|
|
2087
|
+
"""Convert a Chempirical participant to ChemQL's participant shape."""
|
|
2088
|
+
if not isinstance(item, dict):
|
|
2089
|
+
return None
|
|
2090
|
+
molecule = item.get("formula") or item.get("chemical_formula") or item.get("name") or item.get("molecule")
|
|
2091
|
+
if not molecule:
|
|
2092
|
+
return None
|
|
2093
|
+
coefficient = item.get("stoichiometry", item.get("coefficient", item.get("equivalents", 1)))
|
|
2094
|
+
try:
|
|
2095
|
+
coefficient = float(coefficient)
|
|
2096
|
+
if coefficient.is_integer():
|
|
2097
|
+
coefficient = int(coefficient)
|
|
2098
|
+
except (TypeError, ValueError):
|
|
2099
|
+
coefficient = 1
|
|
2100
|
+
return {"molecule": str(molecule), "stoichiometric_coefficient": coefficient}
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
def _fetch_chempirical_reaction(value):
|
|
2104
|
+
"""Search Chempirical, requiring a relevant, reaction-like result."""
|
|
2105
|
+
url = CHEMPIRICAL_API_URL + "?" + urllib.parse.urlencode({"q": value, "limit": 10, "offset": 0})
|
|
2106
|
+
data = _reaction_api_json(url)
|
|
2107
|
+
if not isinstance(data, dict):
|
|
2108
|
+
return None
|
|
2109
|
+
results = data.get("results", [])
|
|
2110
|
+
if not isinstance(results, list) or not results:
|
|
2111
|
+
return None
|
|
2112
|
+
target = str(value).strip().casefold()
|
|
2113
|
+
|
|
2114
|
+
# Never accept the API's first result blindly. Require the query to match a
|
|
2115
|
+
# meaningful reaction field, then prefer exact name/slug matches.
|
|
2116
|
+
candidates = [item for item in results if isinstance(item, dict)]
|
|
2117
|
+
def fields(item):
|
|
2118
|
+
return [str(item.get(key, "")).casefold() for key in ("name", "slug", "equation", "summary", "reaction_type")]
|
|
2119
|
+
relevant = [item for item in candidates if any(target in field for field in fields(item) if field)]
|
|
2120
|
+
if not relevant:
|
|
2121
|
+
return None
|
|
2122
|
+
row = next((item for item in relevant if target in {
|
|
2123
|
+
str(item.get("name", "")).casefold(), str(item.get("slug", "")).casefold()
|
|
2124
|
+
}), relevant[0])
|
|
2125
|
+
slug = row.get("slug")
|
|
2126
|
+
detail = _reaction_api_json(CHEMPIRICAL_API_URL + "/" + urllib.parse.quote(str(slug), safe="")) if slug else None
|
|
2127
|
+
record = detail if isinstance(detail, dict) else row
|
|
2128
|
+
participants = record.get("participants", [])
|
|
2129
|
+
if not isinstance(participants, list):
|
|
2130
|
+
participants = []
|
|
2131
|
+
reactants = []
|
|
2132
|
+
products = []
|
|
2133
|
+
for participant in participants:
|
|
2134
|
+
if not isinstance(participant, dict):
|
|
2135
|
+
continue
|
|
2136
|
+
role = str(participant.get("role", "")).casefold()
|
|
2137
|
+
converted = _chempirical_participant(participant)
|
|
2138
|
+
if converted is None:
|
|
2139
|
+
continue
|
|
2140
|
+
if role in {"reactant", "reagent", "substrate"}:
|
|
2141
|
+
reactants.append(converted)
|
|
2142
|
+
elif role in {"product", "products"}:
|
|
2143
|
+
products.append(converted)
|
|
2144
|
+
|
|
2145
|
+
equation = str(record.get("equation") or row.get("equation") or "").strip()
|
|
2146
|
+
if not equation and reactants and products:
|
|
2147
|
+
def term(item):
|
|
2148
|
+
coeff = item["stoichiometric_coefficient"]
|
|
2149
|
+
return (f"{coeff} " if coeff != 1 else "") + item["molecule"]
|
|
2150
|
+
equation = " + ".join(term(item) for item in reactants) + " → " + " + ".join(term(item) for item in products)
|
|
2151
|
+
# Some API responses provide a display equation but omit structured
|
|
2152
|
+
# participants. Parse the equation as a fallback so the object is useful.
|
|
2153
|
+
if equation and (not reactants or not products):
|
|
2154
|
+
parsed_reactants, parsed_products = _parse_remote_equation(equation)
|
|
2155
|
+
if not reactants:
|
|
2156
|
+
reactants = parsed_reactants
|
|
2157
|
+
if not products:
|
|
2158
|
+
products = parsed_products
|
|
2159
|
+
|
|
2160
|
+
# An external hit must actually describe a reaction. Reject records that
|
|
2161
|
+
# only have a product/name (which caused outputs like "→ (S)-1-Phenylpropanol").
|
|
2162
|
+
has_both_sides = bool(reactants and products)
|
|
2163
|
+
if not has_both_sides and equation:
|
|
2164
|
+
arrow = next((a for a in ("⇌", "<=>", "<->", "→", "->", "=") if a in equation), None)
|
|
2165
|
+
if arrow:
|
|
2166
|
+
left, right = equation.split(arrow, 1)
|
|
2167
|
+
has_both_sides = bool(left.strip() and right.strip())
|
|
2168
|
+
if not has_both_sides:
|
|
2169
|
+
return None
|
|
2170
|
+
|
|
2171
|
+
conditions = record.get("conditions", {})
|
|
2172
|
+
if isinstance(conditions, list):
|
|
2173
|
+
conditions = {f"condition_{i + 1}": item for i, item in enumerate(conditions)}
|
|
2174
|
+
notes = record.get("summary", row.get("summary", ""))
|
|
2175
|
+
source = record.get("source", row.get("source", "Chempirical"))
|
|
2176
|
+
if isinstance(source, dict):
|
|
2177
|
+
source = source.get("name") or source.get("label") or "Chempirical"
|
|
2178
|
+
return _remote_reaction(
|
|
2179
|
+
record.get("slug") or row.get("slug"), record.get("name") or row.get("name"),
|
|
2180
|
+
equation, record.get("reaction_type") or row.get("reaction_type"),
|
|
2181
|
+
"Chempirical: " + str(source or "Chempirical"), notes, conditions,
|
|
2182
|
+
reactants=reactants, products=products,
|
|
2183
|
+
)
|
|
2184
|
+
|
|
2185
|
+
|
|
2186
|
+
def _parse_remote_equation(equation):
|
|
2187
|
+
"""Best-effort participant extraction from a plain-text chemical equation."""
|
|
2188
|
+
if not equation:
|
|
2189
|
+
return [], []
|
|
2190
|
+
arrow = next((a for a in ("⇌", "<=>", "<->", "→", "->", "=", "⇄") if a in equation), None)
|
|
2191
|
+
if not arrow:
|
|
2192
|
+
return [], []
|
|
2193
|
+
left, right = equation.split(arrow, 1)
|
|
2194
|
+
def parse_side(side):
|
|
2195
|
+
output = []
|
|
2196
|
+
for raw_term in re.split(r"\s+\+\s+", side.strip()):
|
|
2197
|
+
term = raw_term.strip()
|
|
2198
|
+
if not term:
|
|
2199
|
+
continue
|
|
2200
|
+
match = re.match(r"^([0-9]+(?:\.[0-9]+)?)\s+(.+)$", term)
|
|
2201
|
+
if match:
|
|
2202
|
+
coefficient, molecule = match.groups()
|
|
2203
|
+
number = float(coefficient)
|
|
2204
|
+
if number.is_integer():
|
|
2205
|
+
number = int(number)
|
|
2206
|
+
else:
|
|
2207
|
+
number, molecule = 1, term
|
|
2208
|
+
output.append({"molecule": molecule.strip(), "stoichiometric_coefficient": number})
|
|
2209
|
+
return output
|
|
2210
|
+
return parse_side(left), parse_side(right)
|
|
2211
|
+
|
|
2212
|
+
|
|
2213
|
+
def _fetch_rhea_reaction(value):
|
|
2214
|
+
"""Search Rhea's TSV REST endpoint and only accept a relevant result."""
|
|
2215
|
+
target = value.strip()
|
|
2216
|
+
if target.casefold().startswith("rhea:"):
|
|
2217
|
+
query = "rhea-id:" + target.split(":", 1)[1]
|
|
2218
|
+
else:
|
|
2219
|
+
# Rhea supports fielded queries; a general query is useful for equation or
|
|
2220
|
+
# participant-name searches and keeps all query escaping in urlencode.
|
|
2221
|
+
query = target
|
|
2222
|
+
params = urllib.parse.urlencode({
|
|
2223
|
+
"query": query,
|
|
2224
|
+
"columns": "rhea-id,equation,chebi,ec",
|
|
2225
|
+
"format": "tsv",
|
|
2226
|
+
"limit": 5,
|
|
2227
|
+
})
|
|
2228
|
+
text = _reaction_api_text(RHEA_API_URL + "?" + params)
|
|
2229
|
+
lines = [line for line in text.splitlines() if line.strip()]
|
|
2230
|
+
if len(lines) < 2:
|
|
2231
|
+
return None
|
|
2232
|
+
headers = lines[0].split("\t")
|
|
2233
|
+
rows = [dict(zip(headers, line.split("\t"))) for line in lines[1:]]
|
|
2234
|
+
target_folded = target.casefold()
|
|
2235
|
+
row = next((item for item in rows if item.get("rhea-id", "").casefold() == target_folded), None)
|
|
2236
|
+
if row is None:
|
|
2237
|
+
row = next((item for item in rows if any(
|
|
2238
|
+
target_folded in item.get(field, "").casefold()
|
|
2239
|
+
for field in ("equation", "chebi", "ec")
|
|
2240
|
+
)), None)
|
|
2241
|
+
if row is None:
|
|
2242
|
+
return None
|
|
2243
|
+
reaction_id = row.get("rhea-id", "")
|
|
2244
|
+
ec = row.get("ec", "")
|
|
2245
|
+
chebi = row.get("chebi", "")
|
|
2246
|
+
notes = ""
|
|
2247
|
+
if ec:
|
|
2248
|
+
notes += "EC: " + ec
|
|
2249
|
+
if chebi:
|
|
2250
|
+
notes += ("; " if notes else "") + "Participants: " + chebi
|
|
2251
|
+
equation = row.get("equation", "")
|
|
2252
|
+
reactants, products = _parse_remote_equation(equation)
|
|
2253
|
+
if not reactants or not products:
|
|
2254
|
+
return None
|
|
2255
|
+
return _remote_reaction(
|
|
2256
|
+
reaction_id, reaction_id, equation, "Biochemical", "Rhea", notes,
|
|
2257
|
+
reactants=reactants, products=products,
|
|
2258
|
+
)
|
|
2259
|
+
|
|
2260
|
+
|
|
2261
|
+
def search_reactions(**criteria):
|
|
2262
|
+
"""Search reactions by fields; string criteria are case-insensitive substrings."""
|
|
2263
|
+
unknown = set(criteria) - set(reactionKeys)
|
|
2264
|
+
if unknown:
|
|
2265
|
+
raise ValueError("Unknown reaction field(s): " + ", ".join(sorted(unknown)))
|
|
2266
|
+
result = []
|
|
2267
|
+
for reaction in REACTIONS:
|
|
2268
|
+
if all((str(expected).casefold() in str(getattr(reaction, key)).casefold())
|
|
2269
|
+
if isinstance(expected, str) and isinstance(getattr(reaction, key), str)
|
|
2270
|
+
else getattr(reaction, key) == expected for key, expected in criteria.items()):
|
|
2271
|
+
result.append(reaction)
|
|
2272
|
+
return result
|
|
2273
|
+
|
|
1772
2274
|
reactionKeys = ("id", "name", "equation", "reaction_type", "reversible", "reactants", "products", "conditions", "notes", "source")
|
|
1773
2275
|
del elements
|
|
1774
2276
|
del reactions
|
|
@@ -2193,14 +2695,44 @@ def parse_condition(tokens, index, source, keys, positions=None):
|
|
|
2193
2695
|
return (lambda item, fn=like_condition: not fn(item)) if negated else like_condition, consumed
|
|
2194
2696
|
|
|
2195
2697
|
if operator == "has":
|
|
2196
|
-
def
|
|
2197
|
-
|
|
2698
|
+
def contains_nested(actual, expected, field_name=None):
|
|
2699
|
+
"""Search nested values, matching chemical molecule IDs exactly.
|
|
2700
|
+
|
|
2701
|
+
A molecule field must use equality rather than substring matching:
|
|
2702
|
+
``O2`` is not a reactant just because the formula ``CO2`` contains
|
|
2703
|
+
those characters. Other descriptive strings may use substring
|
|
2704
|
+
matching, preserving the general-purpose behavior of ``has``.
|
|
2705
|
+
"""
|
|
2706
|
+
if isinstance(actual, str):
|
|
2707
|
+
if isinstance(field_name, str) and field_name.casefold() == "molecule":
|
|
2708
|
+
return actual.casefold() == expected.casefold()
|
|
2709
|
+
return expected.casefold() in actual.casefold()
|
|
2710
|
+
|
|
2711
|
+
if isinstance(actual, dict):
|
|
2712
|
+
for nested_key, nested_value in actual.items():
|
|
2713
|
+
if contains_nested(nested_value, expected, nested_key):
|
|
2714
|
+
return True
|
|
2715
|
+
return any(
|
|
2716
|
+
isinstance(nested_key, str)
|
|
2717
|
+
and nested_key.casefold() == expected.casefold()
|
|
2718
|
+
for nested_key in actual.keys()
|
|
2719
|
+
)
|
|
2198
2720
|
|
|
2199
|
-
|
|
2200
|
-
return
|
|
2201
|
-
|
|
2721
|
+
if isinstance(actual, (list, tuple, set, frozenset)):
|
|
2722
|
+
return any(
|
|
2723
|
+
contains_nested(value_item, expected, field_name)
|
|
2724
|
+
for value_item in actual
|
|
2725
|
+
)
|
|
2726
|
+
|
|
2727
|
+
if actual is None:
|
|
2202
2728
|
return False
|
|
2203
2729
|
|
|
2730
|
+
return str(actual).casefold() == expected.casefold()
|
|
2731
|
+
|
|
2732
|
+
def has_condition(item):
|
|
2733
|
+
actual = getattr(item, key)
|
|
2734
|
+
return contains_nested(actual, value)
|
|
2735
|
+
|
|
2204
2736
|
return (lambda item, fn=has_condition: not fn(item)) if negated else has_condition, consumed
|
|
2205
2737
|
|
|
2206
2738
|
if operator not in functionMap:
|
|
@@ -2359,7 +2891,16 @@ reaction_state = {
|
|
|
2359
2891
|
}
|
|
2360
2892
|
|
|
2361
2893
|
# Python variables persist for the whole session.
|
|
2362
|
-
python_globals = {"__builtins__": __builtins__, "math": math}
|
|
2894
|
+
python_globals = {"__builtins__": __builtins__, "math": math, "error": None}
|
|
2895
|
+
STRUCTS = {}
|
|
2896
|
+
RECORDS = {}
|
|
2897
|
+
|
|
2898
|
+
class ChemQLSignal(Exception):
|
|
2899
|
+
"""A user-raised, recoverable ChemQL error."""
|
|
2900
|
+
def __init__(self, error_type):
|
|
2901
|
+
self.error_type = str(error_type)
|
|
2902
|
+
super().__init__(self.error_type)
|
|
2903
|
+
|
|
2363
2904
|
CHEMQL_VARIABLES = set()
|
|
2364
2905
|
|
|
2365
2906
|
def _chemql_value(expression):
|
|
@@ -2649,6 +3190,9 @@ def evaluate_python(code):
|
|
|
2649
3190
|
i += 1
|
|
2650
3191
|
|
|
2651
3192
|
rewritten = "".join(rewritten)
|
|
3193
|
+
for type_name in (*STRUCTS.keys(), *RECORDS.keys()):
|
|
3194
|
+
rewritten = re.sub(r"\b" + re.escape(type_name) + r"\s*\{([^{}]*)\}",
|
|
3195
|
+
lambda m, n=type_name: n + "(" + m.group(1) + ")", rewritten)
|
|
2652
3196
|
rewritten = _expand_function_calls(rewritten)
|
|
2653
3197
|
|
|
2654
3198
|
python_globals.update(query_values)
|
|
@@ -2676,7 +3220,12 @@ def is_python_block_header(text):
|
|
|
2676
3220
|
|
|
2677
3221
|
code = text[1:-1].strip()
|
|
2678
3222
|
|
|
2679
|
-
#
|
|
3223
|
+
# Function placeholders such as \1 are substituted only when the function
|
|
3224
|
+
# is called. Replace them with harmless identifiers for block detection so
|
|
3225
|
+
# they cannot make a real `if`/`for`/`while` header look like an expression.
|
|
3226
|
+
code = re.sub(r"\\\d+", "__chemql_arg", code)
|
|
3227
|
+
|
|
3228
|
+
# Replace {{...}} queries with valid Python variable names.
|
|
2680
3229
|
while "{{" in code:
|
|
2681
3230
|
start = code.index("{{")
|
|
2682
3231
|
end = find_matching(code, start, "{{", "}}")
|
|
@@ -2703,37 +3252,29 @@ def is_python_block_header(text):
|
|
|
2703
3252
|
|
|
2704
3253
|
|
|
2705
3254
|
def find_endblock(lines, start):
|
|
2706
|
-
"""Find the matching
|
|
3255
|
+
"""Find the matching terminator, including headers with \\N placeholders."""
|
|
2707
3256
|
depth = 1
|
|
2708
|
-
|
|
2709
3257
|
for i in range(start, len(lines)):
|
|
2710
|
-
stripped = lines[i].strip()
|
|
3258
|
+
stripped = lines[i].strip().casefold()
|
|
2711
3259
|
|
|
2712
|
-
#
|
|
2713
|
-
|
|
3260
|
+
# Typed terminators and the normalized generic terminator close the
|
|
3261
|
+
# current block. Older source forms may still contain typed closers.
|
|
3262
|
+
if stripped in {"[endblock]", "endfor", "endwhile", "endif", "[endfor]", "[endwhile]", "[endif]"}:
|
|
2714
3263
|
depth -= 1
|
|
2715
|
-
|
|
2716
3264
|
if depth == 0:
|
|
2717
3265
|
return i
|
|
2718
|
-
|
|
2719
3266
|
continue
|
|
2720
3267
|
|
|
2721
|
-
|
|
2722
|
-
if stripped in ("[else]", "[endif]", "[endfor]", "[endwhile]"):
|
|
3268
|
+
if stripped in {"[else]"} or stripped.startswith("[elif "):
|
|
2723
3269
|
continue
|
|
2724
3270
|
|
|
2725
|
-
#
|
|
2726
|
-
#
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
r"[A-Za-z_]\w*\b.*\]\s*$",
|
|
2730
|
-
stripped,
|
|
2731
|
-
)
|
|
2732
|
-
|
|
2733
|
-
if is_block_header(stripped):
|
|
3271
|
+
# Detect structural headers by keyword, not by parsing their complete
|
|
3272
|
+
# expression. At this stage \\1 may still be unresolved or may
|
|
3273
|
+
# expand to a ChemQL struct representation that is not Python syntax.
|
|
3274
|
+
if re.match(r"^\[\s*(?:if|for|while)\b", stripped):
|
|
2734
3275
|
depth += 1
|
|
3276
|
+
raise SyntaxError("Missing block terminator (`endblock`, `endfor`, `endwhile`, or `endif`)")
|
|
2735
3277
|
|
|
2736
|
-
raise SyntaxError("Missing `[endblock]`")
|
|
2737
3278
|
|
|
2738
3279
|
def remove_comments(line):
|
|
2739
3280
|
in_quote = None
|
|
@@ -3000,31 +3541,45 @@ def _dump_history(command):
|
|
|
3000
3541
|
if len(parts) == 2:
|
|
3001
3542
|
category, mode, filename = "all", "both", parts[1]
|
|
3002
3543
|
elif len(parts) == 4:
|
|
3003
|
-
category, mode, filename = parts
|
|
3004
|
-
if category not in {"all", "good", "bad"} or mode not in {
|
|
3005
|
-
|
|
3544
|
+
_, category, mode, filename = parts
|
|
3545
|
+
if category not in {"all", "good", "bad"} or mode not in {
|
|
3546
|
+
"queries", "results"
|
|
3547
|
+
}:
|
|
3548
|
+
raise ChemQLError(
|
|
3549
|
+
"Usage: dump [all|good|bad] [queries|results] FILE"
|
|
3550
|
+
)
|
|
3006
3551
|
elif len(parts) == 3 and parts[1] in {"all", "good", "bad"}:
|
|
3007
|
-
|
|
3552
|
+
_, category, filename = parts
|
|
3553
|
+
mode = "both"
|
|
3008
3554
|
else:
|
|
3009
|
-
raise ChemQLError(
|
|
3555
|
+
raise ChemQLError(
|
|
3556
|
+
"Usage: dump [all|good|bad] [queries|results] FILE"
|
|
3557
|
+
)
|
|
3010
3558
|
|
|
3011
3559
|
records = EXECUTION_HISTORY
|
|
3560
|
+
|
|
3012
3561
|
if category == "good":
|
|
3013
|
-
records = [
|
|
3562
|
+
records = [r for r in records if r.get("success") is True]
|
|
3014
3563
|
elif category == "bad":
|
|
3015
|
-
records = [
|
|
3564
|
+
records = [r for r in records if r.get("success") is False]
|
|
3016
3565
|
|
|
3017
3566
|
if mode == "queries":
|
|
3018
|
-
|
|
3567
|
+
lines = [str(r["query"]) for r in records]
|
|
3568
|
+
content = "\n".join(lines)
|
|
3019
3569
|
elif mode == "results":
|
|
3020
|
-
|
|
3570
|
+
lines = [str(r["result"]) for r in records]
|
|
3571
|
+
content = "\n".join(lines)
|
|
3021
3572
|
else:
|
|
3022
3573
|
content = "\n\n".join(
|
|
3023
|
-
f"QUERY:\n{
|
|
3024
|
-
for
|
|
3574
|
+
f"QUERY:\n{r['query']}\nRESULT:\n{r['result']}"
|
|
3575
|
+
for r in records
|
|
3025
3576
|
)
|
|
3026
3577
|
|
|
3027
|
-
|
|
3578
|
+
content = strip_ansi(content)
|
|
3579
|
+
if content:
|
|
3580
|
+
content += "\n"
|
|
3581
|
+
|
|
3582
|
+
Path(filename).write_text(content, encoding="utf-8")
|
|
3028
3583
|
return f"Dumped {len(records)} execution record(s) to {filename}"
|
|
3029
3584
|
|
|
3030
3585
|
|
|
@@ -3042,12 +3597,15 @@ def process_inline(text):
|
|
|
3042
3597
|
return
|
|
3043
3598
|
|
|
3044
3599
|
# Native dynamically typed variables: `let name = expression`.
|
|
3045
|
-
let_match = re.fullmatch(r"let\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)", stripped, re.DOTALL)
|
|
3600
|
+
let_match = re.fullmatch(r"let\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*=\s*(.+)", stripped, re.DOTALL)
|
|
3046
3601
|
if let_match:
|
|
3047
3602
|
name, expression = let_match.groups()
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3603
|
+
if "." in name:
|
|
3604
|
+
evaluate_python(name + " = (" + expression + ")")
|
|
3605
|
+
else:
|
|
3606
|
+
value = evaluate_python(expression)
|
|
3607
|
+
python_globals[name] = value
|
|
3608
|
+
CHEMQL_VARIABLES.add(name)
|
|
3051
3609
|
return None
|
|
3052
3610
|
|
|
3053
3611
|
if stripped.casefold() == "variables":
|
|
@@ -3066,10 +3624,25 @@ def process_inline(text):
|
|
|
3066
3624
|
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", stripped) and stripped in CHEMQL_VARIABLES:
|
|
3067
3625
|
return python_globals.get(stripped)
|
|
3068
3626
|
|
|
3627
|
+
# Attribute reads on ChemQL variables (e.g. `apple.price`).
|
|
3628
|
+
if re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+", stripped):
|
|
3629
|
+
root = stripped.split(".", 1)[0]
|
|
3630
|
+
if root in CHEMQL_VARIABLES:
|
|
3631
|
+
return evaluate_python(stripped)
|
|
3632
|
+
|
|
3069
3633
|
# History dump command.
|
|
3070
3634
|
if stripped.casefold().startswith("dump "):
|
|
3071
3635
|
return _dump_history(stripped)
|
|
3072
3636
|
|
|
3637
|
+
# A bare struct/record constructor is an expression, not a ChemQL query.
|
|
3638
|
+
# `let fruit = Fruit{...}` already reaches evaluate_python through the let
|
|
3639
|
+
# handler above; this handles the same constructor when entered by itself.
|
|
3640
|
+
type_call = re.fullmatch(
|
|
3641
|
+
r"([A-Za-z_][A-Za-z0-9_]*)\s*\{(.*)\}", stripped, re.DOTALL
|
|
3642
|
+
)
|
|
3643
|
+
if type_call and type_call.group(1) in (*STRUCTS.keys(), *RECORDS.keys()):
|
|
3644
|
+
return evaluate_python(stripped)
|
|
3645
|
+
|
|
3073
3646
|
# A standalone user-defined function call returns its value directly.
|
|
3074
3647
|
function_call = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?)\s*\{(.*)\}", stripped, re.DOTALL)
|
|
3075
3648
|
if function_call and function_call.group(1) in FUNCTIONS:
|
|
@@ -3093,8 +3666,47 @@ def process_inline(text):
|
|
|
3093
3666
|
return run_shell_command(command)
|
|
3094
3667
|
|
|
3095
3668
|
|
|
3096
|
-
#
|
|
3097
|
-
|
|
3669
|
+
# Native variables can participate in ordinary Python expressions without
|
|
3670
|
+
# square brackets (for example `q+w`). Try Python expression parsing before
|
|
3671
|
+
# the query parser whenever the line references a known ChemQL variable.
|
|
3672
|
+
# This handles compact operators as well as spaced expressions.
|
|
3673
|
+
known_expression_names = (CHEMQL_VARIABLES | {
|
|
3674
|
+
name for name in python_globals
|
|
3675
|
+
if isinstance(name, str) and name.isidentifier() and not name.startswith("__")
|
|
3676
|
+
})
|
|
3677
|
+
references_variable = any(
|
|
3678
|
+
re.search(r"\b" + re.escape(name) + r"\b", stripped)
|
|
3679
|
+
for name in known_expression_names
|
|
3680
|
+
)
|
|
3681
|
+
if references_variable:
|
|
3682
|
+
try:
|
|
3683
|
+
ast.parse(stripped, mode="eval")
|
|
3684
|
+
except SyntaxError:
|
|
3685
|
+
pass
|
|
3686
|
+
else:
|
|
3687
|
+
try:
|
|
3688
|
+
return evaluate_python(stripped)
|
|
3689
|
+
except (SyntaxError, NameError):
|
|
3690
|
+
pass
|
|
3691
|
+
|
|
3692
|
+
# Try ChemQL first. If the parser specifically reports an unknown command,
|
|
3693
|
+
# treat the input as a possible Python expression. Only Python SyntaxError
|
|
3694
|
+
# or NameError falls back to the original ChemQL error; other Python errors
|
|
3695
|
+
# (e.g. TypeError) are meaningful and should remain visible.
|
|
3696
|
+
result = execute_query_text(stripped)
|
|
3697
|
+
plain_result = strip_ansi(result) if isinstance(result, str) else ""
|
|
3698
|
+
# The query engine formats unknown-command failures as text (including a
|
|
3699
|
+
# caret line), so detect the diagnostic independently of any leading ANSI
|
|
3700
|
+
# styling or extra whitespace.
|
|
3701
|
+
if isinstance(result, str) and re.search(
|
|
3702
|
+
r"Error:\s*Unknown command\b", plain_result
|
|
3703
|
+
):
|
|
3704
|
+
try:
|
|
3705
|
+
return evaluate_python(stripped)
|
|
3706
|
+
except (SyntaxError, NameError):
|
|
3707
|
+
# If Python also rejects it, preserve ChemQL's more useful error.
|
|
3708
|
+
return result
|
|
3709
|
+
return result
|
|
3098
3710
|
|
|
3099
3711
|
|
|
3100
3712
|
def join_continuations(lines):
|
|
@@ -3150,6 +3762,27 @@ def _find_unquoted_marker(text, marker, start=0):
|
|
|
3150
3762
|
def process_lines(lines):
|
|
3151
3763
|
"""Process ChemQL source, including multiline Python, terminal, and comments."""
|
|
3152
3764
|
lines = join_continuations(list(lines))
|
|
3765
|
+
# Natural headers (for/if/while) and typed terminators are accepted in v1.3.
|
|
3766
|
+
normalized_lines = []
|
|
3767
|
+
in_python_block = False
|
|
3768
|
+
for source_line in lines:
|
|
3769
|
+
stripped_source = source_line.strip()
|
|
3770
|
+
if stripped_source == "[[[":
|
|
3771
|
+
in_python_block = True
|
|
3772
|
+
normalized_lines.append(source_line)
|
|
3773
|
+
continue
|
|
3774
|
+
if stripped_source == "]]]":
|
|
3775
|
+
in_python_block = False
|
|
3776
|
+
normalized_lines.append(source_line)
|
|
3777
|
+
continue
|
|
3778
|
+
if not in_python_block:
|
|
3779
|
+
if re.match(r"^(for|while|if)\s+.+$", stripped_source) and not stripped_source.startswith("["):
|
|
3780
|
+
# Existing block parser supplies the trailing colon to Python's AST.
|
|
3781
|
+
source_line = "[" + stripped_source + "]"
|
|
3782
|
+
elif stripped_source.casefold() in {"endfor", "endwhile", "endif"}:
|
|
3783
|
+
source_line = "[endblock]"
|
|
3784
|
+
normalized_lines.append(source_line)
|
|
3785
|
+
lines = normalized_lines
|
|
3153
3786
|
i = 0
|
|
3154
3787
|
|
|
3155
3788
|
while i < len(lines):
|
|
@@ -3160,6 +3793,110 @@ def process_lines(lines):
|
|
|
3160
3793
|
i += 1
|
|
3161
3794
|
continue
|
|
3162
3795
|
|
|
3796
|
+
type_match = re.fullmatch(r"(struct|record)\s+([A-Za-z_]\w*)", stripped, re.IGNORECASE)
|
|
3797
|
+
if type_match:
|
|
3798
|
+
kind, type_name = type_match.group(1).casefold(), type_match.group(2)
|
|
3799
|
+
terminator = "endstruct" if kind == "struct" else "endrecord"
|
|
3800
|
+
end = i + 1
|
|
3801
|
+
while end < len(lines) and lines[end].strip().casefold() != terminator:
|
|
3802
|
+
end += 1
|
|
3803
|
+
if end >= len(lines):
|
|
3804
|
+
raise SyntaxError(f"Missing `{terminator}` for {kind} `{type_name}`")
|
|
3805
|
+
fields, defaults = [], {}
|
|
3806
|
+
for field_line in lines[i + 1:end]:
|
|
3807
|
+
field_line = remove_comments(field_line).strip()
|
|
3808
|
+
if not field_line:
|
|
3809
|
+
continue
|
|
3810
|
+
match = re.fullmatch(r"([A-Za-z_]\w*)(?:\s*=\s*(.+))?", field_line)
|
|
3811
|
+
if not match:
|
|
3812
|
+
raise SyntaxError(f"Invalid {kind} field declaration: {field_line}")
|
|
3813
|
+
field, default = match.groups()
|
|
3814
|
+
if field in fields:
|
|
3815
|
+
raise SyntaxError(f"Duplicate field `{field}` in {type_name}")
|
|
3816
|
+
fields.append(field)
|
|
3817
|
+
if default is not None:
|
|
3818
|
+
defaults[field] = evaluate_python(_expand_definitions(default))
|
|
3819
|
+
if not fields:
|
|
3820
|
+
raise SyntaxError(f"{kind.title()} `{type_name}` must declare fields")
|
|
3821
|
+
if kind == "struct":
|
|
3822
|
+
def make_struct(class_name, field_names, field_defaults):
|
|
3823
|
+
def __init__(self, *args, **kwargs):
|
|
3824
|
+
if len(args) > len(field_names):
|
|
3825
|
+
raise TypeError(f"{class_name} expected at most {len(field_names)} positional arguments")
|
|
3826
|
+
values = dict(zip(field_names, args))
|
|
3827
|
+
overlap = values.keys() & kwargs.keys()
|
|
3828
|
+
if overlap:
|
|
3829
|
+
raise TypeError(f"Multiple values for field `{next(iter(overlap))}`")
|
|
3830
|
+
values.update(kwargs)
|
|
3831
|
+
extra = values.keys() - set(field_names)
|
|
3832
|
+
if extra:
|
|
3833
|
+
raise TypeError(f"Unexpected field(s): {', '.join(sorted(extra))}")
|
|
3834
|
+
for field_name in field_names:
|
|
3835
|
+
setattr(self, field_name, values.get(field_name, field_defaults.get(field_name)))
|
|
3836
|
+
def __repr__(self):
|
|
3837
|
+
return class_name + "{ " + ", ".join(f"{f}={getattr(self, f)!r}" for f in field_names) + " }"
|
|
3838
|
+
def __eq__(self, other):
|
|
3839
|
+
return type(other) is type(self) and all(getattr(self, f) == getattr(other, f) for f in field_names)
|
|
3840
|
+
return type(class_name, (), {"__init__": __init__, "__repr__": __repr__, "__str__": __repr__, "__eq__": __eq__, "__slots__": tuple(field_names)})
|
|
3841
|
+
generated = make_struct(type_name, fields, defaults)
|
|
3842
|
+
STRUCTS[type_name] = generated
|
|
3843
|
+
else:
|
|
3844
|
+
from collections import namedtuple
|
|
3845
|
+
first_default = next((n for n, f in enumerate(fields) if f in defaults), len(fields))
|
|
3846
|
+
if any(f not in defaults for f in fields[first_default:]):
|
|
3847
|
+
raise SyntaxError(f"Record `{type_name}` fields with defaults must be trailing fields")
|
|
3848
|
+
generated = namedtuple(type_name, fields, defaults=[defaults[f] for f in fields[first_default:]])
|
|
3849
|
+
generated.__str__ = lambda self, _name=type_name: _name + " -> " + ", ".join(f"{f}: {getattr(self, f)}" for f in self._fields)
|
|
3850
|
+
RECORDS[type_name] = generated
|
|
3851
|
+
python_globals[type_name] = generated
|
|
3852
|
+
i = end + 1
|
|
3853
|
+
continue
|
|
3854
|
+
|
|
3855
|
+
if stripped.casefold() == "risk":
|
|
3856
|
+
end = i + 1
|
|
3857
|
+
nesting = 1
|
|
3858
|
+
while end < len(lines):
|
|
3859
|
+
token = lines[end].strip().casefold()
|
|
3860
|
+
if token == "risk": nesting += 1
|
|
3861
|
+
elif token == "endrisk":
|
|
3862
|
+
nesting -= 1
|
|
3863
|
+
if nesting == 0: break
|
|
3864
|
+
end += 1
|
|
3865
|
+
if end >= len(lines):
|
|
3866
|
+
raise SyntaxError("Missing `endrisk`")
|
|
3867
|
+
sections = {"risk": [], "fix": [], "cleanup": []}
|
|
3868
|
+
section = "risk"
|
|
3869
|
+
for risk_line in lines[i + 1:end]:
|
|
3870
|
+
marker = risk_line.strip().casefold()
|
|
3871
|
+
if marker in {"fix", "cleanup"}:
|
|
3872
|
+
section = marker
|
|
3873
|
+
else:
|
|
3874
|
+
sections[section].append(risk_line)
|
|
3875
|
+
global _CHEMQL_RISK_DEPTH
|
|
3876
|
+
_CHEMQL_RISK_DEPTH += 1
|
|
3877
|
+
try:
|
|
3878
|
+
try:
|
|
3879
|
+
process_lines(sections["risk"])
|
|
3880
|
+
python_globals["error"] = None
|
|
3881
|
+
except Exception as exc:
|
|
3882
|
+
python_globals["error"] = exc.error_type if isinstance(exc, ChemQLSignal) else type(exc).__name__
|
|
3883
|
+
if sections["fix"]:
|
|
3884
|
+
process_lines(sections["fix"])
|
|
3885
|
+
elif not isinstance(exc, ChemQLSignal):
|
|
3886
|
+
raise
|
|
3887
|
+
finally:
|
|
3888
|
+
try:
|
|
3889
|
+
if sections["cleanup"]:
|
|
3890
|
+
process_lines(sections["cleanup"])
|
|
3891
|
+
finally:
|
|
3892
|
+
_CHEMQL_RISK_DEPTH = max(0, _CHEMQL_RISK_DEPTH - 1)
|
|
3893
|
+
i = end + 1
|
|
3894
|
+
continue
|
|
3895
|
+
|
|
3896
|
+
signal_match = re.fullmatch(r"signal\s+(.+)", stripped, re.IGNORECASE)
|
|
3897
|
+
if signal_match:
|
|
3898
|
+
raise ChemQLSignal(evaluate_python(signal_match.group(1)))
|
|
3899
|
+
|
|
3163
3900
|
# Definitions are processed in source order and affect only later lines.
|
|
3164
3901
|
define_match = re.fullmatch(r"define\s+(" + _DEFINITION_NAME + r")\s+(.+)", stripped)
|
|
3165
3902
|
if define_match and not stripped.endswith("{}"):
|
|
@@ -3191,7 +3928,7 @@ def process_lines(lines):
|
|
|
3191
3928
|
continue
|
|
3192
3929
|
|
|
3193
3930
|
# Function definitions use `func NAME ... endfunc`.
|
|
3194
|
-
function_match = re.fullmatch(r"func\s+(" + _DEFINITION_NAME + r")", stripped, re.IGNORECASE)
|
|
3931
|
+
function_match = re.fullmatch(r"func\s+(" + _DEFINITION_NAME + r"(?:\." + _DEFINITION_NAME + r")?)", stripped, re.IGNORECASE)
|
|
3195
3932
|
if function_match:
|
|
3196
3933
|
name = function_match.group(1)
|
|
3197
3934
|
end = i + 1
|
|
@@ -3200,6 +3937,36 @@ def process_lines(lines):
|
|
|
3200
3937
|
if end >= len(lines):
|
|
3201
3938
|
raise SyntaxError(f"Missing `endfunc` for function `{name}`")
|
|
3202
3939
|
FUNCTIONS[name] = {"body": [_expand_definitions(source_line) for source_line in lines[i + 1:end]]}
|
|
3940
|
+
if "." in name:
|
|
3941
|
+
owner_name, method_name = name.split(".", 1)
|
|
3942
|
+
owner = STRUCTS.get(owner_name)
|
|
3943
|
+
if owner is None:
|
|
3944
|
+
raise NameError(f"Cannot define method `{name}`: struct `{owner_name}` does not exist")
|
|
3945
|
+
def _make_struct_method(function_name):
|
|
3946
|
+
def _method(instance, *args):
|
|
3947
|
+
had_this = "this" in python_globals
|
|
3948
|
+
old_this = python_globals.get("this")
|
|
3949
|
+
# Pass method arguments through temporary Python globals.
|
|
3950
|
+
# repr(struct_instance) uses ChemQL's `Fruit{...}` display
|
|
3951
|
+
# syntax, which is not valid Python and breaks nested
|
|
3952
|
+
# constructor expressions when substituted into a method.
|
|
3953
|
+
temporary_names = []
|
|
3954
|
+
python_globals["this"] = instance
|
|
3955
|
+
try:
|
|
3956
|
+
for arg in args:
|
|
3957
|
+
temp_name = "__chemql_method_arg_" + uuid.uuid4().hex
|
|
3958
|
+
python_globals[temp_name] = arg
|
|
3959
|
+
temporary_names.append(temp_name)
|
|
3960
|
+
return _call_function(function_name, temporary_names)
|
|
3961
|
+
finally:
|
|
3962
|
+
for temp_name in temporary_names:
|
|
3963
|
+
python_globals.pop(temp_name, None)
|
|
3964
|
+
if had_this:
|
|
3965
|
+
python_globals["this"] = old_this
|
|
3966
|
+
else:
|
|
3967
|
+
python_globals.pop("this", None)
|
|
3968
|
+
return _method
|
|
3969
|
+
setattr(owner, method_name, _make_struct_method(name))
|
|
3203
3970
|
i = end + 1
|
|
3204
3971
|
continue
|
|
3205
3972
|
|
|
@@ -3367,6 +4134,13 @@ def process_lines(lines):
|
|
|
3367
4134
|
result = _dump_history(command)
|
|
3368
4135
|
else:
|
|
3369
4136
|
result = process_inline(command)
|
|
4137
|
+
# Query parsing can report an error as formatted text instead
|
|
4138
|
+
# of raising. Inside `risk`, promote that result to an exception
|
|
4139
|
+
# so `fix` runs and the `error` variable is populated.
|
|
4140
|
+
if _CHEMQL_RISK_DEPTH and isinstance(result, str):
|
|
4141
|
+
plain_result = strip_ansi(result).lstrip()
|
|
4142
|
+
if plain_result.startswith("Error:"):
|
|
4143
|
+
raise ChemQLError(plain_result[6:].strip())
|
|
3370
4144
|
rendered = "" if result is None else str(result)
|
|
3371
4145
|
if result is not None:
|
|
3372
4146
|
if NO_ANSI:
|
|
@@ -3381,9 +4155,15 @@ def process_lines(lines):
|
|
|
3381
4155
|
)
|
|
3382
4156
|
if not is_streamed_shell and not _shell_output_streamed:
|
|
3383
4157
|
print(rendered)
|
|
3384
|
-
|
|
4158
|
+
# Some commands return formatted errors instead of raising exceptions.
|
|
4159
|
+
plain_result = strip_ansi(rendered).lstrip()
|
|
4160
|
+
success = not plain_result.startswith("Error:")
|
|
4161
|
+
|
|
4162
|
+
_record_execution(command, rendered, success)
|
|
3385
4163
|
globals()["_shell_output_streamed"] = False
|
|
3386
4164
|
except Exception as exc:
|
|
4165
|
+
if _CHEMQL_RISK_DEPTH:
|
|
4166
|
+
raise
|
|
3387
4167
|
message = f"Error: {exc}"
|
|
3388
4168
|
print(strip_ansi(message) if NO_ANSI else message, file=sys.stderr)
|
|
3389
4169
|
_record_execution(command, message, False)
|
|
@@ -3812,7 +4592,9 @@ def execute(arguments, query_text=None, positions=None):
|
|
|
3812
4592
|
])
|
|
3813
4593
|
|
|
3814
4594
|
if result == [] or result == "No matching item":
|
|
3815
|
-
|
|
4595
|
+
result = find_reaction(value, external=True)
|
|
4596
|
+
if result is None:
|
|
4597
|
+
return f"Could not find reaction `{value}` in Reactions.json, Rhea, or Chempirical"
|
|
3816
4598
|
|
|
3817
4599
|
if return_attributes:
|
|
3818
4600
|
if isinstance(result, list):
|
|
@@ -3995,347 +4777,299 @@ def view_periodic_table():
|
|
|
3995
4777
|
|
|
3996
4778
|
|
|
3997
4779
|
class ChemqlLexer(Lexer):
|
|
3998
|
-
"""
|
|
4780
|
+
"""Dynamic syntax highlighting for ChemQL and its interactive objects."""
|
|
3999
4781
|
|
|
4000
4782
|
COMMANDS = {
|
|
4001
|
-
"search", "source", "list", "clear", "exit", "findel",
|
|
4002
|
-
"
|
|
4003
|
-
"
|
|
4004
|
-
"
|
|
4005
|
-
"
|
|
4783
|
+
"search", "source", "list", "clear", "exit", "findel", "findmol",
|
|
4784
|
+
"findre", "set", "add", "remove", "react", "balance", "conditions",
|
|
4785
|
+
"view", "help", "dump", "version", "return", "union", "intersection",
|
|
4786
|
+
"let", "unset", "variables", "macros", "definitions", "arguments",
|
|
4787
|
+
"define", "func", "struct", "record", "risk", "fix", "cleanup",
|
|
4788
|
+
"signal", "import", "remove", "install",
|
|
4006
4789
|
}
|
|
4007
|
-
|
|
4008
4790
|
OPERATORS = {
|
|
4009
|
-
"and", "or", "has", "like", "like!", "likep", "sort",
|
|
4010
|
-
"
|
|
4791
|
+
"and", "or", "has", "like", "like!", "likep", "sort", "limit",
|
|
4792
|
+
"count", "asc", "desc", "in", "not", "is",
|
|
4011
4793
|
}
|
|
4012
|
-
|
|
4013
4794
|
CONTROL_WORDS = {
|
|
4014
|
-
"if", "elif", "else", "for", "while", "
|
|
4015
|
-
"
|
|
4016
|
-
"define", "
|
|
4795
|
+
"if", "elif", "else", "for", "while", "break", "continue", "endblock",
|
|
4796
|
+
"endfor", "endwhile", "endif", "endrisk", "endstruct", "endrecord",
|
|
4797
|
+
"endfunc", "enddefine", "func", "define", "struct", "record", "risk",
|
|
4798
|
+
"fix", "cleanup", "signal", "import", "as", "return",
|
|
4017
4799
|
}
|
|
4018
|
-
|
|
4019
4800
|
CONDITIONS = {
|
|
4020
|
-
"temperature", "pressure", "catalyst", "catalysts",
|
|
4021
|
-
"
|
|
4801
|
+
"temperature", "pressure", "catalyst", "catalysts", "standard", "room",
|
|
4802
|
+
"c", "f", "k", "pa", "bar",
|
|
4022
4803
|
}
|
|
4023
4804
|
|
|
4805
|
+
@staticmethod
|
|
4806
|
+
def _dynamic_names():
|
|
4807
|
+
return {
|
|
4808
|
+
"variables": set(CHEMQL_VARIABLES) | {
|
|
4809
|
+
name for name in python_globals
|
|
4810
|
+
if isinstance(name, str) and not name.startswith("__")
|
|
4811
|
+
},
|
|
4812
|
+
"structs": set(STRUCTS),
|
|
4813
|
+
"records": set(RECORDS),
|
|
4814
|
+
"functions": set(FUNCTIONS),
|
|
4815
|
+
"macros": set(MACROS),
|
|
4816
|
+
"definitions": set(DEFINITIONS),
|
|
4817
|
+
}
|
|
4818
|
+
|
|
4024
4819
|
def lex_document(self, document):
|
|
4025
4820
|
lines = document.lines
|
|
4821
|
+
names = self._dynamic_names()
|
|
4822
|
+
function_names = {n.split(".")[-1] for n in names["functions"]}
|
|
4026
4823
|
|
|
4027
4824
|
def get_line(lineno):
|
|
4028
4825
|
if lineno >= len(lines):
|
|
4029
4826
|
return []
|
|
4030
|
-
|
|
4031
4827
|
line = lines[lineno]
|
|
4032
4828
|
tokens = []
|
|
4033
4829
|
i = 0
|
|
4034
4830
|
|
|
4035
|
-
#
|
|
4831
|
+
# Leading indentation is kept verbatim for prompt_toolkit.
|
|
4036
4832
|
while i < len(line) and line[i].isspace():
|
|
4037
4833
|
start = i
|
|
4038
4834
|
while i < len(line) and line[i].isspace():
|
|
4039
4835
|
i += 1
|
|
4040
4836
|
tokens.append(("", line[start:i]))
|
|
4041
4837
|
|
|
4042
|
-
#
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4838
|
+
# Mark declaration names according to the declaration being edited.
|
|
4839
|
+
declaration = re.match(
|
|
4840
|
+
r"(?:struct|record|func)\s+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?)", line[i:], re.I
|
|
4841
|
+
)
|
|
4842
|
+
macro_declaration = re.match(r"define\s+([A-Za-z_]\w*)\s*\{\s*\}", line[i:], re.I)
|
|
4843
|
+
definition_declaration = re.match(r"define\s+([A-Za-z_]\w*)\s+", line[i:], re.I)
|
|
4844
|
+
declared_name = None
|
|
4845
|
+
declared_style = None
|
|
4846
|
+
if declaration:
|
|
4847
|
+
prefix = line[i:]
|
|
4848
|
+
keyword = prefix.split(None, 1)[0].casefold()
|
|
4849
|
+
declared_name = declaration.group(1)
|
|
4850
|
+
declared_style = "struct" if keyword == "struct" else "record" if keyword == "record" else "function"
|
|
4851
|
+
elif macro_declaration:
|
|
4852
|
+
declared_name, declared_style = macro_declaration.group(1), "macro"
|
|
4853
|
+
elif definition_declaration:
|
|
4854
|
+
declared_name, declared_style = definition_declaration.group(1), "definition"
|
|
4049
4855
|
|
|
4050
4856
|
while i < len(line):
|
|
4051
|
-
|
|
4052
|
-
if
|
|
4053
|
-
|
|
4054
|
-
while
|
|
4055
|
-
|
|
4056
|
-
tokens.append(("", line[
|
|
4857
|
+
ch = line[i]
|
|
4858
|
+
if ch.isspace():
|
|
4859
|
+
j = i + 1
|
|
4860
|
+
while j < len(line) and line[j].isspace():
|
|
4861
|
+
j += 1
|
|
4862
|
+
tokens.append(("", line[i:j]))
|
|
4863
|
+
i = j
|
|
4057
4864
|
continue
|
|
4058
|
-
|
|
4059
|
-
# Comments
|
|
4060
4865
|
if line.startswith("//", i):
|
|
4061
4866
|
tokens.append(("class:comment", line[i:]))
|
|
4062
4867
|
break
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
# tokens.append(("class:interpolation", line[i:]))
|
|
4072
|
-
# break
|
|
4073
|
-
|
|
4074
|
-
# if end is None:
|
|
4075
|
-
# tokens.append(("class:interpolation", line[i:]))
|
|
4076
|
-
# break
|
|
4077
|
-
|
|
4078
|
-
# end += 2
|
|
4079
|
-
# tokens.append(("class:interpolation", line[i:end]))
|
|
4080
|
-
# i = end
|
|
4081
|
-
# continue
|
|
4082
|
-
|
|
4083
|
-
# Backtick shell commands
|
|
4084
|
-
if line[i] == "`":
|
|
4085
|
-
end = line.find("`", i + 1)
|
|
4086
|
-
if end == -1:
|
|
4087
|
-
end = len(line) - 1
|
|
4088
|
-
|
|
4089
|
-
tokens.append((
|
|
4090
|
-
"class:terminalcommand",
|
|
4091
|
-
line[i:end + 1]
|
|
4092
|
-
))
|
|
4093
|
-
i = end + 1
|
|
4868
|
+
if line.startswith("///", i):
|
|
4869
|
+
tokens.append(("class:comment", line[i:]))
|
|
4870
|
+
break
|
|
4871
|
+
if ch == "`":
|
|
4872
|
+
j = line.find("`", i + 1)
|
|
4873
|
+
j = len(line) - 1 if j < 0 else j
|
|
4874
|
+
tokens.append(("class:terminalcommand", line[i:j + 1]))
|
|
4875
|
+
i = j + 1
|
|
4094
4876
|
continue
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
# tokens.append(("class:python", line[i]))
|
|
4112
|
-
# i += 1
|
|
4113
|
-
# continue
|
|
4114
|
-
|
|
4115
|
-
# Strings
|
|
4116
|
-
if line[i] in "\"'":
|
|
4117
|
-
quote = line[i]
|
|
4118
|
-
start = i
|
|
4119
|
-
i += 1
|
|
4120
|
-
|
|
4121
|
-
while i < len(line):
|
|
4122
|
-
if line[i] == "\\":
|
|
4123
|
-
i += 2
|
|
4877
|
+
if line.startswith("[[[", i) or line.startswith("]]]", i):
|
|
4878
|
+
marker = line[i:i + 3]
|
|
4879
|
+
tokens.append(("class:python", marker))
|
|
4880
|
+
i += 3
|
|
4881
|
+
continue
|
|
4882
|
+
if line.startswith("{{", i):
|
|
4883
|
+
j = line.find("}}", i + 2)
|
|
4884
|
+
j = len(line) if j < 0 else j + 2
|
|
4885
|
+
tokens.append(("class:interpolation", line[i:j]))
|
|
4886
|
+
i = j
|
|
4887
|
+
continue
|
|
4888
|
+
if ch in "'\"":
|
|
4889
|
+
quote, j = ch, i + 1
|
|
4890
|
+
while j < len(line):
|
|
4891
|
+
if line[j] == "\\":
|
|
4892
|
+
j += 2
|
|
4124
4893
|
continue
|
|
4125
|
-
if line[
|
|
4126
|
-
|
|
4894
|
+
if line[j] == quote:
|
|
4895
|
+
j += 1
|
|
4127
4896
|
break
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4897
|
+
j += 1
|
|
4898
|
+
tokens.append(("class:string", line[i:j]))
|
|
4899
|
+
i = j
|
|
4131
4900
|
continue
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4901
|
+
if ch == "@":
|
|
4902
|
+
match = re.match(r"@[A-Za-z_]\w*(?:\{[^}]*\})?", line[i:])
|
|
4903
|
+
if match:
|
|
4904
|
+
value = match.group(0)
|
|
4905
|
+
key = value[1:].split("{", 1)[0]
|
|
4906
|
+
style = "class:macro" if key in names["macros"] else "class:definition" if key in names["definitions"] else "class:operator"
|
|
4907
|
+
tokens.append((style, value))
|
|
4908
|
+
i += len(value)
|
|
4909
|
+
continue
|
|
4910
|
+
if ch.isdigit():
|
|
4911
|
+
match = re.match(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?", line[i:])
|
|
4912
|
+
value = match.group(0) #type:ignore
|
|
4913
|
+
tokens.append(("class:number", value))
|
|
4914
|
+
i += len(value)
|
|
4143
4915
|
continue
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
(
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4916
|
+
if ch.isalpha() or ch == "_":
|
|
4917
|
+
match = re.match(r"[A-Za-z_]\w*", line[i:])
|
|
4918
|
+
word = match.group(0) #type:ignore
|
|
4919
|
+
folded = word.casefold()
|
|
4920
|
+
style = ""
|
|
4921
|
+
if declared_name and word == declared_name.split(".")[-1]:
|
|
4922
|
+
style = "class:" + declared_style #type:ignore
|
|
4923
|
+
declared_name = None
|
|
4924
|
+
elif not line[:i].strip():
|
|
4925
|
+
style = "class:command" if folded in self.COMMANDS else ""
|
|
4926
|
+
if not style:
|
|
4927
|
+
if word in names["structs"]:
|
|
4928
|
+
style = "class:struct"
|
|
4929
|
+
elif word in names["records"]:
|
|
4930
|
+
style = "class:record"
|
|
4931
|
+
elif word in names["functions"] or word in function_names:
|
|
4932
|
+
style = "class:function"
|
|
4933
|
+
elif word in names["macros"]:
|
|
4934
|
+
style = "class:macro"
|
|
4935
|
+
elif word in names["definitions"]:
|
|
4936
|
+
style = "class:definition"
|
|
4937
|
+
elif word in names["variables"]:
|
|
4938
|
+
style = "class:variable"
|
|
4939
|
+
elif folded in self.CONTROL_WORDS:
|
|
4940
|
+
style = "class:control"
|
|
4941
|
+
elif word in {"True", "False", "None"}:
|
|
4942
|
+
style = "class:number"
|
|
4943
|
+
elif word in {"print", "len", "str", "int", "float", "range", "enumerate", "zip", "list", "dict", "set", "tuple", "sum", "min", "max", "abs", "type", "isinstance"}:
|
|
4944
|
+
style = "class:python"
|
|
4945
|
+
elif word in SOURCES:
|
|
4946
|
+
style = "class:source"
|
|
4947
|
+
elif folded in self.CONDITIONS:
|
|
4948
|
+
style = "class:condition"
|
|
4949
|
+
elif folded in self.OPERATORS:
|
|
4950
|
+
style = "class:operator"
|
|
4951
|
+
elif word in elementKeys or word in moleculeKeys or word in reactionKeys:
|
|
4952
|
+
style = "class:key"
|
|
4953
|
+
tokens.append((style, word))
|
|
4954
|
+
i += len(word)
|
|
4955
|
+
continue
|
|
4956
|
+
operator = next((op for op in ("**", "//", "!=", ">=", "<=", "==", "+=", "-=", "*=", "/=", "->", "<->", "=", ">", "<", "+", "-", "*", "/", "%") if line.startswith(op, i)), None)
|
|
4157
4957
|
if operator:
|
|
4158
4958
|
tokens.append(("class:operator", operator))
|
|
4159
4959
|
i += len(operator)
|
|
4160
4960
|
continue
|
|
4161
|
-
|
|
4162
|
-
# Identifiers and keywords
|
|
4163
|
-
if line[i].isalpha() or line[i] == "_":
|
|
4164
|
-
start = i
|
|
4165
|
-
while i < len(line) and (
|
|
4166
|
-
line[i].isalnum() or line[i] == "_"
|
|
4167
|
-
):
|
|
4168
|
-
i += 1
|
|
4169
|
-
|
|
4170
|
-
word = line[start:i]
|
|
4171
|
-
|
|
4172
|
-
if word in self.COMMANDS:
|
|
4173
|
-
style = "class:command"
|
|
4174
|
-
elif word in self.CONTROL_WORDS:
|
|
4175
|
-
style = "class:control"
|
|
4176
|
-
elif word in {"True", "False", "None"}:
|
|
4177
|
-
style = "class:number"
|
|
4178
|
-
elif word in {"print", "len", "str", "int", "float", "range"}:
|
|
4179
|
-
style = "class:python"
|
|
4180
|
-
elif word in SOURCES:
|
|
4181
|
-
style = "class:source"
|
|
4182
|
-
elif word.casefold() in self.CONDITIONS:
|
|
4183
|
-
style = "class:condition"
|
|
4184
|
-
elif word in self.OPERATORS:
|
|
4185
|
-
style = "class:operator"
|
|
4186
|
-
elif (
|
|
4187
|
-
word in CHEMQL_VARIABLES
|
|
4188
|
-
or word in python_globals
|
|
4189
|
-
):
|
|
4190
|
-
style = "class:variable"
|
|
4191
|
-
elif (
|
|
4192
|
-
word in elementKeys
|
|
4193
|
-
or word in moleculeKeys
|
|
4194
|
-
or word in reactionKeys
|
|
4195
|
-
):
|
|
4196
|
-
style = "class:key"
|
|
4197
|
-
else:
|
|
4198
|
-
style = ""
|
|
4199
|
-
|
|
4200
|
-
tokens.append((style, word))
|
|
4201
|
-
continue
|
|
4202
|
-
|
|
4203
|
-
# Other punctuation
|
|
4204
|
-
tokens.append(("", line[i]))
|
|
4961
|
+
tokens.append(("", ch))
|
|
4205
4962
|
i += 1
|
|
4206
|
-
|
|
4207
4963
|
return tokens
|
|
4208
|
-
|
|
4209
4964
|
return get_line
|
|
4210
4965
|
|
|
4211
4966
|
|
|
4212
4967
|
class ChemqlCompleter(Completer):
|
|
4213
|
-
"""Context-aware completion for
|
|
4968
|
+
"""Context-aware completion for commands and dynamically created objects."""
|
|
4214
4969
|
|
|
4215
4970
|
COMMANDS = (
|
|
4216
|
-
"search", "source", "list", "clear", "return", "exit",
|
|
4217
|
-
"
|
|
4218
|
-
"
|
|
4219
|
-
"
|
|
4220
|
-
"
|
|
4971
|
+
"search", "source", "list", "clear", "return", "exit", "findel", "findmol",
|
|
4972
|
+
"findre", "set", "add", "remove", "react", "balance", "conditions", "version",
|
|
4973
|
+
"help", "view", "dump", "let", "unset", "variables", "func", "define", "struct",
|
|
4974
|
+
"record", "risk", "fix", "cleanup", "signal", "import", "definitions", "macros",
|
|
4975
|
+
"endfor", "endwhile", "endif", "endrisk", "endstruct", "endrecord", "endfunc", "enddefine",
|
|
4221
4976
|
)
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4977
|
+
QUERY_OPERATORS = ["=", "!=", ">", "<", ">=", "<=", "like", "like!", "likep", "has", "sort", "limit", "count", "asc", "desc", "return", "and", "or", "union", "intersection"]
|
|
4978
|
+
CONTROL_COMPLETIONS = ["if ", "for ", "while ", "endfor", "endwhile", "endif", "risk", "fix", "cleanup", "endrisk", "struct ", "endstruct", "record ", "endrecord", "func ", "endfunc", "define ", "enddefine"]
|
|
4979
|
+
|
|
4980
|
+
@staticmethod
|
|
4981
|
+
def _names():
|
|
4982
|
+
variables = set(CHEMQL_VARIABLES) | {
|
|
4983
|
+
name for name in python_globals
|
|
4984
|
+
if isinstance(name, str) and not name.startswith("__") and name.isidentifier()
|
|
4985
|
+
}
|
|
4986
|
+
return {
|
|
4987
|
+
"variables": variables,
|
|
4988
|
+
"structs": set(STRUCTS),
|
|
4989
|
+
"records": set(RECORDS),
|
|
4990
|
+
"functions": set(FUNCTIONS),
|
|
4991
|
+
"macros": set(MACROS),
|
|
4992
|
+
"definitions": set(DEFINITIONS),
|
|
4993
|
+
}
|
|
4228
4994
|
|
|
4229
4995
|
def get_completions(self, document, complete_event):
|
|
4230
4996
|
text = document.text_before_cursor
|
|
4231
|
-
|
|
4997
|
+
stripped = text.lstrip()
|
|
4998
|
+
parts = stripped.split()
|
|
4232
4999
|
trailing_space = bool(text and text[-1].isspace())
|
|
4233
|
-
|
|
4234
5000
|
current = "" if trailing_space or not parts else parts[-1]
|
|
4235
5001
|
command_words = parts if trailing_space else parts[:-1]
|
|
5002
|
+
names = self._names()
|
|
4236
5003
|
|
|
4237
|
-
def emit(
|
|
5004
|
+
def emit(values):
|
|
4238
5005
|
seen = set()
|
|
4239
|
-
for
|
|
4240
|
-
|
|
4241
|
-
key =
|
|
4242
|
-
|
|
4243
|
-
if (
|
|
4244
|
-
key not in seen
|
|
4245
|
-
and key.startswith(current.casefold())
|
|
4246
|
-
):
|
|
5006
|
+
for value in values:
|
|
5007
|
+
value = str(value)
|
|
5008
|
+
key = value.casefold()
|
|
5009
|
+
if key not in seen and key.startswith(current.casefold()):
|
|
4247
5010
|
seen.add(key)
|
|
4248
|
-
yield Completion(
|
|
4249
|
-
suggestion,
|
|
4250
|
-
start_position=-len(current),
|
|
4251
|
-
)
|
|
5011
|
+
yield Completion(value, start_position=-len(current))
|
|
4252
5012
|
|
|
4253
|
-
|
|
5013
|
+
dynamic = sorted(set().union(*names.values()), key=str.casefold)
|
|
4254
5014
|
if not command_words:
|
|
4255
|
-
|
|
4256
|
-
suggestions.extend(
|
|
4257
|
-
sorted(CHEMQL_VARIABLES)
|
|
4258
|
-
)
|
|
4259
|
-
yield from emit(suggestions)
|
|
5015
|
+
yield from emit(list(self.COMMANDS) + self.CONTROL_COMPLETIONS + dynamic)
|
|
4260
5016
|
return
|
|
4261
5017
|
|
|
4262
|
-
command = command_words[0]
|
|
5018
|
+
command = command_words[0].casefold()
|
|
4263
5019
|
suggestions = []
|
|
4264
|
-
|
|
4265
|
-
# Native variable syntax
|
|
4266
5020
|
if command == "let":
|
|
4267
|
-
if len(command_words) == 1
|
|
4268
|
-
suggestions = ["name = value"]
|
|
4269
|
-
elif len(command_words) >= 2:
|
|
4270
|
-
suggestions = ["="]
|
|
4271
|
-
|
|
5021
|
+
suggestions = ["name = value"] if len(command_words) == 1 else ["="]
|
|
4272
5022
|
elif command == "unset":
|
|
4273
|
-
suggestions = sorted(
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
# Source names
|
|
4279
|
-
elif command in {"search", "source"}:
|
|
5023
|
+
suggestions = sorted(names["variables"])
|
|
5024
|
+
elif command == "source":
|
|
5025
|
+
suggestions = list(SOURCES)
|
|
5026
|
+
elif command == "search":
|
|
4280
5027
|
if len(command_words) == 1:
|
|
4281
5028
|
suggestions = list(SOURCES)
|
|
4282
|
-
elif
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
_, keys = SOURCES[source_name]
|
|
4286
|
-
suggestions = list(keys) + self.QUERY_OPERATORS
|
|
4287
|
-
|
|
4288
|
-
# Reaction settings
|
|
5029
|
+
elif len(command_words) >= 2 and command_words[1] in SOURCES:
|
|
5030
|
+
_, keys = SOURCES[command_words[1]]
|
|
5031
|
+
suggestions = list(keys) + self.QUERY_OPERATORS
|
|
4289
5032
|
elif command == "set":
|
|
4290
5033
|
if len(command_words) == 1:
|
|
4291
|
-
suggestions = [
|
|
4292
|
-
"temperature", "pressure", "catalysts"
|
|
4293
|
-
]
|
|
5034
|
+
suggestions = ["temperature", "pressure", "catalysts"]
|
|
4294
5035
|
elif len(command_words) == 2:
|
|
4295
5036
|
setting = command_words[1].casefold()
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
elif setting == "pressure":
|
|
4302
|
-
suggestions = [
|
|
4303
|
-
"standard", "room", "0Pa",
|
|
4304
|
-
"0N/m^2", "0Nm^-2", "0bar",
|
|
4305
|
-
]
|
|
4306
|
-
elif setting == "catalysts":
|
|
4307
|
-
suggestions = list(
|
|
4308
|
-
reaction_state["catalysts"]
|
|
4309
|
-
)
|
|
4310
|
-
|
|
4311
|
-
# Catalyst management
|
|
5037
|
+
suggestions = {
|
|
5038
|
+
"temperature": ["standard", "room", "0C", "0F", "0K"],
|
|
5039
|
+
"pressure": ["standard", "room", "0Pa", "0N/m^2", "0Nm^-2", "0bar", "1atm"],
|
|
5040
|
+
"catalysts": list(reaction_state["catalysts"]),
|
|
5041
|
+
}.get(setting, [])
|
|
4312
5042
|
elif command in {"add", "remove"}:
|
|
4313
5043
|
if len(command_words) == 1:
|
|
4314
|
-
suggestions = ["catalyst"]
|
|
4315
|
-
elif (
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
elif command
|
|
4326
|
-
suggestions = sorted(
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
"
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
5044
|
+
suggestions = ["catalyst", "definition"] if command == "remove" else ["catalyst"]
|
|
5045
|
+
elif command == "remove" and command_words[1].casefold() == "catalyst":
|
|
5046
|
+
suggestions = list(reaction_state["catalysts"])
|
|
5047
|
+
elif command == "remove" and command_words[1].casefold() == "definition":
|
|
5048
|
+
suggestions = sorted(names["definitions"])
|
|
5049
|
+
elif command == "variables":
|
|
5050
|
+
suggestions = sorted(names["variables"])
|
|
5051
|
+
elif command == "definitions":
|
|
5052
|
+
suggestions = sorted(names["definitions"])
|
|
5053
|
+
elif command == "macros":
|
|
5054
|
+
suggestions = sorted(names["macros"])
|
|
5055
|
+
elif command == "func":
|
|
5056
|
+
suggestions = sorted(names["structs"])
|
|
5057
|
+
elif command == "struct":
|
|
5058
|
+
suggestions = sorted(names["structs"])
|
|
5059
|
+
elif command == "record":
|
|
5060
|
+
suggestions = sorted(names["records"])
|
|
5061
|
+
elif command in {"print", "return", "unset"}:
|
|
5062
|
+
suggestions = sorted(names["variables"])
|
|
5063
|
+
elif command in {"risk", "fix", "cleanup", "endrisk", "endstruct", "endrecord", "endfunc", "enddefine", "endfor", "endwhile", "endif"}:
|
|
5064
|
+
suggestions = self.CONTROL_COMPLETIONS
|
|
5065
|
+
|
|
5066
|
+
# In expressions, Python blocks, attribute access, and function calls,
|
|
5067
|
+
# offer all user-defined symbols, not just built-in commands.
|
|
5068
|
+
if not suggestions or "{{" in text or text.lstrip().startswith("[") or "." in current:
|
|
5069
|
+
suggestions += dynamic
|
|
4335
5070
|
yield from emit(suggestions)
|
|
4336
5071
|
|
|
4337
5072
|
|
|
4338
|
-
|
|
4339
5073
|
chemql_style = Style.from_dict({
|
|
4340
5074
|
"command": "ansicyan",
|
|
4341
5075
|
"terminalcommand": "ansipurple",
|
|
@@ -4348,11 +5082,16 @@ chemql_style = Style.from_dict({
|
|
|
4348
5082
|
"control": "ansired",
|
|
4349
5083
|
"python": "ansibrightcyan",
|
|
4350
5084
|
"condition": "ansibrightyellow",
|
|
5085
|
+
"variable": "ansibrightgreen",
|
|
5086
|
+
"struct": "ansibrightblue bold",
|
|
5087
|
+
"record": "ansicyan bold",
|
|
5088
|
+
"function": "ansiyellow bold",
|
|
5089
|
+
"macro": "ansimagenta bold",
|
|
5090
|
+
"definition": "ansibrightmagenta",
|
|
5091
|
+
"interpolation": "ansibrightcyan italic",
|
|
4351
5092
|
})
|
|
4352
5093
|
|
|
4353
5094
|
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
5095
|
class _ChemQLField:
|
|
4357
5096
|
"""Field selector used by the fluent Python query API."""
|
|
4358
5097
|
def __init__(self, query, field):
|
|
@@ -4495,6 +5234,83 @@ ChemQLQuery.__name__ = "ChemQLQuery"
|
|
|
4495
5234
|
ChemQLQuery.__qualname__ = "ChemQLQuery"
|
|
4496
5235
|
|
|
4497
5236
|
|
|
5237
|
+
|
|
5238
|
+
def _unclosed_expression_delimiters(lines):
|
|
5239
|
+
"""Return expected closing delimiters for an unfinished expression.
|
|
5240
|
+
|
|
5241
|
+
This scanner is intentionally for REPL continuation only. It recognizes
|
|
5242
|
+
ChemQL's {{...}} query delimiters before ordinary braces and ignores
|
|
5243
|
+
delimiters inside quoted strings and // comments. The dedicated [[[...]]]
|
|
5244
|
+
block is handled separately by the REPL and must not be treated as a stack
|
|
5245
|
+
of ordinary square brackets.
|
|
5246
|
+
"""
|
|
5247
|
+
expected = []
|
|
5248
|
+
quote = None
|
|
5249
|
+
escaped = False
|
|
5250
|
+
openers = ("{{", "{", "(", "[")
|
|
5251
|
+
closers = ("}}", "}", ")", "]")
|
|
5252
|
+
|
|
5253
|
+
for line in lines:
|
|
5254
|
+
i = 0
|
|
5255
|
+
while i < len(line):
|
|
5256
|
+
ch = line[i]
|
|
5257
|
+
|
|
5258
|
+
if quote is not None:
|
|
5259
|
+
if escaped:
|
|
5260
|
+
escaped = False
|
|
5261
|
+
elif ch == "\\":
|
|
5262
|
+
escaped = True
|
|
5263
|
+
elif ch == quote:
|
|
5264
|
+
quote = None
|
|
5265
|
+
i += 1
|
|
5266
|
+
continue
|
|
5267
|
+
|
|
5268
|
+
if ch in ("'", '"'):
|
|
5269
|
+
quote = ch
|
|
5270
|
+
i += 1
|
|
5271
|
+
continue
|
|
5272
|
+
|
|
5273
|
+
# ChemQL's single-line comments must not affect delimiter balance.
|
|
5274
|
+
if line.startswith("//", i):
|
|
5275
|
+
break
|
|
5276
|
+
|
|
5277
|
+
# The triple-bracket multiline Python form is managed elsewhere.
|
|
5278
|
+
if line.startswith("[[[", i):
|
|
5279
|
+
i += 3
|
|
5280
|
+
continue
|
|
5281
|
+
if line.startswith("]]]", i):
|
|
5282
|
+
i += 3
|
|
5283
|
+
continue
|
|
5284
|
+
|
|
5285
|
+
token = next((t for t in openers if line.startswith(t, i)), None)
|
|
5286
|
+
if token is not None:
|
|
5287
|
+
expected.append({"{{": "}}", "{": "}", "(": ")", "[": "]"}[token])
|
|
5288
|
+
i += len(token)
|
|
5289
|
+
continue
|
|
5290
|
+
|
|
5291
|
+
token = next((t for t in closers if line.startswith(t, i)), None)
|
|
5292
|
+
if token is not None:
|
|
5293
|
+
if expected:
|
|
5294
|
+
if expected[-1] != token:
|
|
5295
|
+
raise SyntaxError(
|
|
5296
|
+
f"Mismatched delimiter: expected {expected[-1]!r}, got {token!r}"
|
|
5297
|
+
)
|
|
5298
|
+
expected.pop()
|
|
5299
|
+
else:
|
|
5300
|
+
# An unmatched closer belongs to the normal ChemQL parser
|
|
5301
|
+
# to diagnose; it should not cause continuation prompting.
|
|
5302
|
+
return []
|
|
5303
|
+
i += len(token)
|
|
5304
|
+
continue
|
|
5305
|
+
|
|
5306
|
+
i += 1
|
|
5307
|
+
|
|
5308
|
+
# A single-line string cannot legitimately span REPL input lines in
|
|
5309
|
+
# ordinary ChemQL expressions; retain quote state only for completeness.
|
|
5310
|
+
|
|
5311
|
+
return expected
|
|
5312
|
+
|
|
5313
|
+
|
|
4498
5314
|
def main():
|
|
4499
5315
|
parser = argparse.ArgumentParser(
|
|
4500
5316
|
description="Chemql query and chemistry language"
|
|
@@ -4619,17 +5435,40 @@ def run_interactive():
|
|
|
4619
5435
|
if "///" in first:
|
|
4620
5436
|
return sum(line.count("///") for line in source_lines) < 2
|
|
4621
5437
|
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
5438
|
+
# Track every block that requires a closing keyword. Named declarations
|
|
5439
|
+
# such as `struct Fruit` and `record Point` are headers too.
|
|
5440
|
+
stack = []
|
|
5441
|
+
closers = {
|
|
5442
|
+
"endrisk": "risk", "endstruct": "struct", "endrecord": "record",
|
|
5443
|
+
"endfunc": "func", "enddefine": "macro", "endfor": "for",
|
|
5444
|
+
"endwhile": "while", "endif": "if", "[endblock]": "bracket",
|
|
5445
|
+
}
|
|
5446
|
+
for source_line in source_lines:
|
|
5447
|
+
stripped = source_line.strip()
|
|
5448
|
+
folded = stripped.casefold()
|
|
5449
|
+
if not stripped or stripped.startswith("//"):
|
|
5450
|
+
continue
|
|
4631
5451
|
|
|
4632
|
-
|
|
5452
|
+
if re.fullmatch(r"struct\s+[A-Za-z_]\w*", stripped, re.IGNORECASE):
|
|
5453
|
+
stack.append("struct")
|
|
5454
|
+
elif re.fullmatch(r"record\s+[A-Za-z_]\w*", stripped, re.IGNORECASE):
|
|
5455
|
+
stack.append("record")
|
|
5456
|
+
elif re.fullmatch(r"func\s+[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?", stripped, re.IGNORECASE):
|
|
5457
|
+
stack.append("func")
|
|
5458
|
+
elif re.fullmatch(r"define\s+[A-Za-z_]\w*\s*\{\s*\}", stripped, re.IGNORECASE):
|
|
5459
|
+
stack.append("macro")
|
|
5460
|
+
elif folded == "risk":
|
|
5461
|
+
stack.append("risk")
|
|
5462
|
+
elif re.match(r"^(for|while|if)\s+.+$", stripped, re.IGNORECASE) and not stripped.startswith("["):
|
|
5463
|
+
stack.append(folded.split(None, 1)[0])
|
|
5464
|
+
elif is_python_block_header(stripped):
|
|
5465
|
+
stack.append("bracket")
|
|
5466
|
+
elif folded in closers:
|
|
5467
|
+
expected = closers[folded]
|
|
5468
|
+
if stack and stack[-1] == expected:
|
|
5469
|
+
stack.pop()
|
|
5470
|
+
|
|
5471
|
+
return bool(stack)
|
|
4633
5472
|
|
|
4634
5473
|
@bindings.add(Keys.Enter)
|
|
4635
5474
|
def _handle_enter(event):
|
|
@@ -4757,6 +5596,44 @@ def run_interactive():
|
|
|
4757
5596
|
if not lines:
|
|
4758
5597
|
continue
|
|
4759
5598
|
|
|
5599
|
+
# Collect multiline expressions only when a physical line ends
|
|
5600
|
+
# with an expression opener. Structured constructs are handled by
|
|
5601
|
+
# their existing collectors above/below and must retain newlines.
|
|
5602
|
+
structured_start = bool(lines) and (
|
|
5603
|
+
lines[0].strip() == "[[["
|
|
5604
|
+
or lines[0].strip().startswith(("```", "func ", "define "))
|
|
5605
|
+
or "///" in lines[0]
|
|
5606
|
+
or is_python_block_header(lines[0].strip())
|
|
5607
|
+
)
|
|
5608
|
+
opens_at_line_end = bool(lines) and any(
|
|
5609
|
+
line.rstrip().endswith(("{{", "{", "(", "["))
|
|
5610
|
+
for line in lines
|
|
5611
|
+
)
|
|
5612
|
+
if lines and not structured_start and opens_at_line_end:
|
|
5613
|
+
try:
|
|
5614
|
+
pending = _unclosed_expression_delimiters(lines)
|
|
5615
|
+
while pending:
|
|
5616
|
+
next_line = session.prompt("... ")
|
|
5617
|
+
lines.extend(next_line.splitlines() or [next_line])
|
|
5618
|
+
pending = _unclosed_expression_delimiters(lines)
|
|
5619
|
+
if len(lines) > 1:
|
|
5620
|
+
# These delimiters represent one expression: collapse
|
|
5621
|
+
# physical lines before handing it to ChemQL evaluation.
|
|
5622
|
+
lines = [" ".join(part.strip() for part in lines if part.strip())]
|
|
5623
|
+
except KeyboardInterrupt:
|
|
5624
|
+
print()
|
|
5625
|
+
lines = []
|
|
5626
|
+
except EOFError:
|
|
5627
|
+
message = "Error: Incomplete expression; missing closing delimiter"
|
|
5628
|
+
print("\n" + (message if NO_ANSI else "\033[31m" + message + "\033[0m"))
|
|
5629
|
+
lines = []
|
|
5630
|
+
except SyntaxError as exc:
|
|
5631
|
+
message = f"Error: {exc}"
|
|
5632
|
+
print(message if NO_ANSI else f"\033[31m{message}\033[0m")
|
|
5633
|
+
lines = []
|
|
5634
|
+
if not lines:
|
|
5635
|
+
continue
|
|
5636
|
+
|
|
4760
5637
|
# Collect a Python control block only if the single editable buffer
|
|
4761
5638
|
# was submitted before its matching [endblock] was entered.
|
|
4762
5639
|
if lines and is_python_block_header(lines[0].strip()):
|