chemql-python 1.2.5__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 +1253 -368
- chemql_python-1.3.0.dist-info/METADATA +904 -0
- {chemql_python-1.2.5.dist-info → chemql_python-1.3.0.dist-info}/RECORD +7 -7
- chemql_python-1.2.5.dist-info/METADATA +0 -1067
- {chemql_python-1.2.5.dist-info → chemql_python-1.3.0.dist-info}/WHEEL +0 -0
- {chemql_python-1.2.5.dist-info → chemql_python-1.3.0.dist-info}/entry_points.txt +0 -0
- {chemql_python-1.2.5.dist-info → chemql_python-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {chemql_python-1.2.5.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,21 +268,41 @@ 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()
|
|
292
|
+
|
|
293
|
+
# Function parameters must also be substituted in block
|
|
294
|
+
# headers, e.g. [for i in range(2, \1 + 1)].
|
|
295
|
+
resolved_header = (
|
|
296
|
+
"["
|
|
297
|
+
+ _expand_definitions(substitute_parameters(code))
|
|
298
|
+
+ "]"
|
|
299
|
+
)
|
|
224
300
|
|
|
225
301
|
if code.startswith("for "):
|
|
226
|
-
execute_for_block(
|
|
302
|
+
execute_for_block(resolved_header, block_body)
|
|
227
303
|
|
|
228
304
|
elif code.startswith("while "):
|
|
229
|
-
execute_while_block(
|
|
305
|
+
execute_while_block(resolved_header, block_body)
|
|
230
306
|
|
|
231
307
|
elif code.startswith("if "):
|
|
232
308
|
# Expand parameters and definitions before evaluating.
|
|
@@ -234,15 +310,24 @@ def _call_function(name, arguments):
|
|
|
234
310
|
condition = substitute_parameters(condition)
|
|
235
311
|
condition = _expand_definitions(condition)
|
|
236
312
|
|
|
237
|
-
# Find the
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
246
331
|
|
|
247
332
|
if evaluate_python(condition):
|
|
248
333
|
selected_body = (
|
|
@@ -658,7 +743,15 @@ def _run_shell_command_posix(command, marker=None):
|
|
|
658
743
|
_shell_prompt_ready = True
|
|
659
744
|
|
|
660
745
|
try:
|
|
661
|
-
|
|
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())
|
|
662
755
|
result = wait_for_prompt(forward_keyboard=True)
|
|
663
756
|
if result is None:
|
|
664
757
|
close_shell()
|
|
@@ -896,6 +989,9 @@ class ChemQLError(Exception):
|
|
|
896
989
|
self.length = length
|
|
897
990
|
|
|
898
991
|
|
|
992
|
+
_CHEMQL_RISK_DEPTH = 0
|
|
993
|
+
|
|
994
|
+
|
|
899
995
|
def format_error(message, text=None, position=None, length=1):
|
|
900
996
|
output = f"Error: {message}"
|
|
901
997
|
|
|
@@ -1264,6 +1360,131 @@ def _find_local_molecule(value):
|
|
|
1264
1360
|
return None
|
|
1265
1361
|
|
|
1266
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
|
+
|
|
1267
1488
|
def _fetch_nist_molecule(value):
|
|
1268
1489
|
"""Try NIST WebBook as a fallback chemistry source."""
|
|
1269
1490
|
target = _normalize_molecule_value(value)
|
|
@@ -1309,10 +1530,16 @@ def fetch_external_molecule(value):
|
|
|
1309
1530
|
# PubChem is the first external source because it is the most complete.
|
|
1310
1531
|
cid = _find_cid(target)
|
|
1311
1532
|
if cid is None:
|
|
1533
|
+
chemspider_molecule = _fetch_chemspider_molecule(target)
|
|
1534
|
+
if chemspider_molecule is not None:
|
|
1535
|
+
return chemspider_molecule
|
|
1312
1536
|
return _fetch_nist_molecule(target)
|
|
1313
1537
|
|
|
1314
1538
|
properties = _get_basic_properties(cid)
|
|
1315
1539
|
if properties is None:
|
|
1540
|
+
chemspider_molecule = _fetch_chemspider_molecule(target)
|
|
1541
|
+
if chemspider_molecule is not None:
|
|
1542
|
+
return chemspider_molecule
|
|
1316
1543
|
return _fetch_nist_molecule(target)
|
|
1317
1544
|
|
|
1318
1545
|
view_data = _get_view_data(cid)
|
|
@@ -1716,7 +1943,7 @@ def molecule_bond_energy(formula):
|
|
|
1716
1943
|
|
|
1717
1944
|
class Reaction:
|
|
1718
1945
|
__slots__ = ("id", "name", "equation", "reaction_type", "reversible", "reactants", "products", "conditions", "notes", "source")
|
|
1719
|
-
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):
|
|
1720
1947
|
self.id = id_
|
|
1721
1948
|
self.name = name
|
|
1722
1949
|
self.reaction_type = reaction_type
|
|
@@ -1726,34 +1953,39 @@ class Reaction:
|
|
|
1726
1953
|
self.conditions = conditions
|
|
1727
1954
|
self.notes = notes
|
|
1728
1955
|
self.source = source
|
|
1729
|
-
subscript_digits = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
|
|
1730
|
-
|
|
1731
|
-
reactant_text = " + ".join(
|
|
1732
|
-
f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
|
|
1733
|
-
for item in reactants
|
|
1734
|
-
)
|
|
1735
|
-
product_text = " + ".join(
|
|
1736
|
-
f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
|
|
1737
|
-
for item in products
|
|
1738
|
-
)
|
|
1739
|
-
arrow = " ⇌ " if self.reversible is True else " → " if self.reversible is False else " = "
|
|
1740
|
-
self.equation = f"{reactant_text}{arrow}{product_text}"
|
|
1741
1956
|
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
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)}"
|
|
1745
1965
|
for item in reactants
|
|
1746
1966
|
)
|
|
1747
|
-
|
|
1748
|
-
|
|
1967
|
+
product_text = " + ".join(
|
|
1968
|
+
f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
|
|
1749
1969
|
for item in products
|
|
1750
1970
|
)
|
|
1751
|
-
|
|
1752
|
-
self.equation
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
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"
|
|
1757
1989
|
def __str__(self):
|
|
1758
1990
|
return self.equation
|
|
1759
1991
|
def __repr__(self):
|
|
@@ -1761,6 +1993,284 @@ class Reaction:
|
|
|
1761
1993
|
|
|
1762
1994
|
|
|
1763
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
|
+
|
|
1764
2274
|
reactionKeys = ("id", "name", "equation", "reaction_type", "reversible", "reactants", "products", "conditions", "notes", "source")
|
|
1765
2275
|
del elements
|
|
1766
2276
|
del reactions
|
|
@@ -2185,14 +2695,44 @@ def parse_condition(tokens, index, source, keys, positions=None):
|
|
|
2185
2695
|
return (lambda item, fn=like_condition: not fn(item)) if negated else like_condition, consumed
|
|
2186
2696
|
|
|
2187
2697
|
if operator == "has":
|
|
2188
|
-
def
|
|
2189
|
-
|
|
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
|
+
)
|
|
2190
2720
|
|
|
2191
|
-
|
|
2192
|
-
return
|
|
2193
|
-
|
|
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:
|
|
2194
2728
|
return False
|
|
2195
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
|
+
|
|
2196
2736
|
return (lambda item, fn=has_condition: not fn(item)) if negated else has_condition, consumed
|
|
2197
2737
|
|
|
2198
2738
|
if operator not in functionMap:
|
|
@@ -2351,7 +2891,16 @@ reaction_state = {
|
|
|
2351
2891
|
}
|
|
2352
2892
|
|
|
2353
2893
|
# Python variables persist for the whole session.
|
|
2354
|
-
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
|
+
|
|
2355
2904
|
CHEMQL_VARIABLES = set()
|
|
2356
2905
|
|
|
2357
2906
|
def _chemql_value(expression):
|
|
@@ -2641,6 +3190,9 @@ def evaluate_python(code):
|
|
|
2641
3190
|
i += 1
|
|
2642
3191
|
|
|
2643
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)
|
|
2644
3196
|
rewritten = _expand_function_calls(rewritten)
|
|
2645
3197
|
|
|
2646
3198
|
python_globals.update(query_values)
|
|
@@ -2668,7 +3220,12 @@ def is_python_block_header(text):
|
|
|
2668
3220
|
|
|
2669
3221
|
code = text[1:-1].strip()
|
|
2670
3222
|
|
|
2671
|
-
#
|
|
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.
|
|
2672
3229
|
while "{{" in code:
|
|
2673
3230
|
start = code.index("{{")
|
|
2674
3231
|
end = find_matching(code, start, "{{", "}}")
|
|
@@ -2695,37 +3252,29 @@ def is_python_block_header(text):
|
|
|
2695
3252
|
|
|
2696
3253
|
|
|
2697
3254
|
def find_endblock(lines, start):
|
|
2698
|
-
"""Find the matching
|
|
3255
|
+
"""Find the matching terminator, including headers with \\N placeholders."""
|
|
2699
3256
|
depth = 1
|
|
2700
|
-
|
|
2701
3257
|
for i in range(start, len(lines)):
|
|
2702
|
-
stripped = lines[i].strip()
|
|
3258
|
+
stripped = lines[i].strip().casefold()
|
|
2703
3259
|
|
|
2704
|
-
#
|
|
2705
|
-
|
|
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]"}:
|
|
2706
3263
|
depth -= 1
|
|
2707
|
-
|
|
2708
3264
|
if depth == 0:
|
|
2709
3265
|
return i
|
|
2710
|
-
|
|
2711
3266
|
continue
|
|
2712
3267
|
|
|
2713
|
-
|
|
2714
|
-
if stripped in ("[else]", "[endif]", "[endfor]", "[endwhile]"):
|
|
3268
|
+
if stripped in {"[else]"} or stripped.startswith("[elif "):
|
|
2715
3269
|
continue
|
|
2716
3270
|
|
|
2717
|
-
#
|
|
2718
|
-
#
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
r"[A-Za-z_]\w*\b.*\]\s*$",
|
|
2722
|
-
stripped,
|
|
2723
|
-
)
|
|
2724
|
-
|
|
2725
|
-
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):
|
|
2726
3275
|
depth += 1
|
|
3276
|
+
raise SyntaxError("Missing block terminator (`endblock`, `endfor`, `endwhile`, or `endif`)")
|
|
2727
3277
|
|
|
2728
|
-
raise SyntaxError("Missing `[endblock]`")
|
|
2729
3278
|
|
|
2730
3279
|
def remove_comments(line):
|
|
2731
3280
|
in_quote = None
|
|
@@ -2992,31 +3541,45 @@ def _dump_history(command):
|
|
|
2992
3541
|
if len(parts) == 2:
|
|
2993
3542
|
category, mode, filename = "all", "both", parts[1]
|
|
2994
3543
|
elif len(parts) == 4:
|
|
2995
|
-
category, mode, filename = parts
|
|
2996
|
-
if category not in {"all", "good", "bad"} or mode not in {
|
|
2997
|
-
|
|
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
|
+
)
|
|
2998
3551
|
elif len(parts) == 3 and parts[1] in {"all", "good", "bad"}:
|
|
2999
|
-
|
|
3552
|
+
_, category, filename = parts
|
|
3553
|
+
mode = "both"
|
|
3000
3554
|
else:
|
|
3001
|
-
raise ChemQLError(
|
|
3555
|
+
raise ChemQLError(
|
|
3556
|
+
"Usage: dump [all|good|bad] [queries|results] FILE"
|
|
3557
|
+
)
|
|
3002
3558
|
|
|
3003
3559
|
records = EXECUTION_HISTORY
|
|
3560
|
+
|
|
3004
3561
|
if category == "good":
|
|
3005
|
-
records = [
|
|
3562
|
+
records = [r for r in records if r.get("success") is True]
|
|
3006
3563
|
elif category == "bad":
|
|
3007
|
-
records = [
|
|
3564
|
+
records = [r for r in records if r.get("success") is False]
|
|
3008
3565
|
|
|
3009
3566
|
if mode == "queries":
|
|
3010
|
-
|
|
3567
|
+
lines = [str(r["query"]) for r in records]
|
|
3568
|
+
content = "\n".join(lines)
|
|
3011
3569
|
elif mode == "results":
|
|
3012
|
-
|
|
3570
|
+
lines = [str(r["result"]) for r in records]
|
|
3571
|
+
content = "\n".join(lines)
|
|
3013
3572
|
else:
|
|
3014
3573
|
content = "\n\n".join(
|
|
3015
|
-
f"QUERY:\n{
|
|
3016
|
-
for
|
|
3574
|
+
f"QUERY:\n{r['query']}\nRESULT:\n{r['result']}"
|
|
3575
|
+
for r in records
|
|
3017
3576
|
)
|
|
3018
3577
|
|
|
3019
|
-
|
|
3578
|
+
content = strip_ansi(content)
|
|
3579
|
+
if content:
|
|
3580
|
+
content += "\n"
|
|
3581
|
+
|
|
3582
|
+
Path(filename).write_text(content, encoding="utf-8")
|
|
3020
3583
|
return f"Dumped {len(records)} execution record(s) to {filename}"
|
|
3021
3584
|
|
|
3022
3585
|
|
|
@@ -3034,12 +3597,15 @@ def process_inline(text):
|
|
|
3034
3597
|
return
|
|
3035
3598
|
|
|
3036
3599
|
# Native dynamically typed variables: `let name = expression`.
|
|
3037
|
-
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)
|
|
3038
3601
|
if let_match:
|
|
3039
3602
|
name, expression = let_match.groups()
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
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)
|
|
3043
3609
|
return None
|
|
3044
3610
|
|
|
3045
3611
|
if stripped.casefold() == "variables":
|
|
@@ -3058,10 +3624,25 @@ def process_inline(text):
|
|
|
3058
3624
|
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", stripped) and stripped in CHEMQL_VARIABLES:
|
|
3059
3625
|
return python_globals.get(stripped)
|
|
3060
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
|
+
|
|
3061
3633
|
# History dump command.
|
|
3062
3634
|
if stripped.casefold().startswith("dump "):
|
|
3063
3635
|
return _dump_history(stripped)
|
|
3064
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
|
+
|
|
3065
3646
|
# A standalone user-defined function call returns its value directly.
|
|
3066
3647
|
function_call = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?)\s*\{(.*)\}", stripped, re.DOTALL)
|
|
3067
3648
|
if function_call and function_call.group(1) in FUNCTIONS:
|
|
@@ -3085,8 +3666,47 @@ def process_inline(text):
|
|
|
3085
3666
|
return run_shell_command(command)
|
|
3086
3667
|
|
|
3087
3668
|
|
|
3088
|
-
#
|
|
3089
|
-
|
|
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
|
|
3090
3710
|
|
|
3091
3711
|
|
|
3092
3712
|
def join_continuations(lines):
|
|
@@ -3142,6 +3762,27 @@ def _find_unquoted_marker(text, marker, start=0):
|
|
|
3142
3762
|
def process_lines(lines):
|
|
3143
3763
|
"""Process ChemQL source, including multiline Python, terminal, and comments."""
|
|
3144
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
|
|
3145
3786
|
i = 0
|
|
3146
3787
|
|
|
3147
3788
|
while i < len(lines):
|
|
@@ -3152,6 +3793,110 @@ def process_lines(lines):
|
|
|
3152
3793
|
i += 1
|
|
3153
3794
|
continue
|
|
3154
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
|
+
|
|
3155
3900
|
# Definitions are processed in source order and affect only later lines.
|
|
3156
3901
|
define_match = re.fullmatch(r"define\s+(" + _DEFINITION_NAME + r")\s+(.+)", stripped)
|
|
3157
3902
|
if define_match and not stripped.endswith("{}"):
|
|
@@ -3183,7 +3928,7 @@ def process_lines(lines):
|
|
|
3183
3928
|
continue
|
|
3184
3929
|
|
|
3185
3930
|
# Function definitions use `func NAME ... endfunc`.
|
|
3186
|
-
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)
|
|
3187
3932
|
if function_match:
|
|
3188
3933
|
name = function_match.group(1)
|
|
3189
3934
|
end = i + 1
|
|
@@ -3192,6 +3937,36 @@ def process_lines(lines):
|
|
|
3192
3937
|
if end >= len(lines):
|
|
3193
3938
|
raise SyntaxError(f"Missing `endfunc` for function `{name}`")
|
|
3194
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))
|
|
3195
3970
|
i = end + 1
|
|
3196
3971
|
continue
|
|
3197
3972
|
|
|
@@ -3359,6 +4134,13 @@ def process_lines(lines):
|
|
|
3359
4134
|
result = _dump_history(command)
|
|
3360
4135
|
else:
|
|
3361
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())
|
|
3362
4144
|
rendered = "" if result is None else str(result)
|
|
3363
4145
|
if result is not None:
|
|
3364
4146
|
if NO_ANSI:
|
|
@@ -3373,9 +4155,15 @@ def process_lines(lines):
|
|
|
3373
4155
|
)
|
|
3374
4156
|
if not is_streamed_shell and not _shell_output_streamed:
|
|
3375
4157
|
print(rendered)
|
|
3376
|
-
|
|
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)
|
|
3377
4163
|
globals()["_shell_output_streamed"] = False
|
|
3378
4164
|
except Exception as exc:
|
|
4165
|
+
if _CHEMQL_RISK_DEPTH:
|
|
4166
|
+
raise
|
|
3379
4167
|
message = f"Error: {exc}"
|
|
3380
4168
|
print(strip_ansi(message) if NO_ANSI else message, file=sys.stderr)
|
|
3381
4169
|
_record_execution(command, message, False)
|
|
@@ -3804,7 +4592,9 @@ def execute(arguments, query_text=None, positions=None):
|
|
|
3804
4592
|
])
|
|
3805
4593
|
|
|
3806
4594
|
if result == [] or result == "No matching item":
|
|
3807
|
-
|
|
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"
|
|
3808
4598
|
|
|
3809
4599
|
if return_attributes:
|
|
3810
4600
|
if isinstance(result, list):
|
|
@@ -3987,347 +4777,299 @@ def view_periodic_table():
|
|
|
3987
4777
|
|
|
3988
4778
|
|
|
3989
4779
|
class ChemqlLexer(Lexer):
|
|
3990
|
-
"""
|
|
4780
|
+
"""Dynamic syntax highlighting for ChemQL and its interactive objects."""
|
|
3991
4781
|
|
|
3992
4782
|
COMMANDS = {
|
|
3993
|
-
"search", "source", "list", "clear", "exit", "findel",
|
|
3994
|
-
"
|
|
3995
|
-
"
|
|
3996
|
-
"
|
|
3997
|
-
"
|
|
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",
|
|
3998
4789
|
}
|
|
3999
|
-
|
|
4000
4790
|
OPERATORS = {
|
|
4001
|
-
"and", "or", "has", "like", "like!", "likep", "sort",
|
|
4002
|
-
"
|
|
4791
|
+
"and", "or", "has", "like", "like!", "likep", "sort", "limit",
|
|
4792
|
+
"count", "asc", "desc", "in", "not", "is",
|
|
4003
4793
|
}
|
|
4004
|
-
|
|
4005
4794
|
CONTROL_WORDS = {
|
|
4006
|
-
"if", "elif", "else", "for", "while", "
|
|
4007
|
-
"
|
|
4008
|
-
"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",
|
|
4009
4799
|
}
|
|
4010
|
-
|
|
4011
4800
|
CONDITIONS = {
|
|
4012
|
-
"temperature", "pressure", "catalyst", "catalysts",
|
|
4013
|
-
"
|
|
4801
|
+
"temperature", "pressure", "catalyst", "catalysts", "standard", "room",
|
|
4802
|
+
"c", "f", "k", "pa", "bar",
|
|
4014
4803
|
}
|
|
4015
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
|
+
|
|
4016
4819
|
def lex_document(self, document):
|
|
4017
4820
|
lines = document.lines
|
|
4821
|
+
names = self._dynamic_names()
|
|
4822
|
+
function_names = {n.split(".")[-1] for n in names["functions"]}
|
|
4018
4823
|
|
|
4019
4824
|
def get_line(lineno):
|
|
4020
4825
|
if lineno >= len(lines):
|
|
4021
4826
|
return []
|
|
4022
|
-
|
|
4023
4827
|
line = lines[lineno]
|
|
4024
4828
|
tokens = []
|
|
4025
4829
|
i = 0
|
|
4026
4830
|
|
|
4027
|
-
#
|
|
4831
|
+
# Leading indentation is kept verbatim for prompt_toolkit.
|
|
4028
4832
|
while i < len(line) and line[i].isspace():
|
|
4029
4833
|
start = i
|
|
4030
4834
|
while i < len(line) and line[i].isspace():
|
|
4031
4835
|
i += 1
|
|
4032
4836
|
tokens.append(("", line[start:i]))
|
|
4033
4837
|
|
|
4034
|
-
#
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
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"
|
|
4041
4855
|
|
|
4042
4856
|
while i < len(line):
|
|
4043
|
-
|
|
4044
|
-
if
|
|
4045
|
-
|
|
4046
|
-
while
|
|
4047
|
-
|
|
4048
|
-
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
|
|
4049
4864
|
continue
|
|
4050
|
-
|
|
4051
|
-
# Comments
|
|
4052
4865
|
if line.startswith("//", i):
|
|
4053
4866
|
tokens.append(("class:comment", line[i:]))
|
|
4054
4867
|
break
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
# tokens.append(("class:interpolation", line[i:]))
|
|
4064
|
-
# break
|
|
4065
|
-
|
|
4066
|
-
# if end is None:
|
|
4067
|
-
# tokens.append(("class:interpolation", line[i:]))
|
|
4068
|
-
# break
|
|
4069
|
-
|
|
4070
|
-
# end += 2
|
|
4071
|
-
# tokens.append(("class:interpolation", line[i:end]))
|
|
4072
|
-
# i = end
|
|
4073
|
-
# continue
|
|
4074
|
-
|
|
4075
|
-
# Backtick shell commands
|
|
4076
|
-
if line[i] == "`":
|
|
4077
|
-
end = line.find("`", i + 1)
|
|
4078
|
-
if end == -1:
|
|
4079
|
-
end = len(line) - 1
|
|
4080
|
-
|
|
4081
|
-
tokens.append((
|
|
4082
|
-
"class:terminalcommand",
|
|
4083
|
-
line[i:end + 1]
|
|
4084
|
-
))
|
|
4085
|
-
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
|
|
4086
4876
|
continue
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
# tokens.append(("class:python", line[i]))
|
|
4104
|
-
# i += 1
|
|
4105
|
-
# continue
|
|
4106
|
-
|
|
4107
|
-
# Strings
|
|
4108
|
-
if line[i] in "\"'":
|
|
4109
|
-
quote = line[i]
|
|
4110
|
-
start = i
|
|
4111
|
-
i += 1
|
|
4112
|
-
|
|
4113
|
-
while i < len(line):
|
|
4114
|
-
if line[i] == "\\":
|
|
4115
|
-
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
|
|
4116
4893
|
continue
|
|
4117
|
-
if line[
|
|
4118
|
-
|
|
4894
|
+
if line[j] == quote:
|
|
4895
|
+
j += 1
|
|
4119
4896
|
break
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4897
|
+
j += 1
|
|
4898
|
+
tokens.append(("class:string", line[i:j]))
|
|
4899
|
+
i = j
|
|
4123
4900
|
continue
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
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)
|
|
4135
4915
|
continue
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
(
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
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)
|
|
4149
4957
|
if operator:
|
|
4150
4958
|
tokens.append(("class:operator", operator))
|
|
4151
4959
|
i += len(operator)
|
|
4152
4960
|
continue
|
|
4153
|
-
|
|
4154
|
-
# Identifiers and keywords
|
|
4155
|
-
if line[i].isalpha() or line[i] == "_":
|
|
4156
|
-
start = i
|
|
4157
|
-
while i < len(line) and (
|
|
4158
|
-
line[i].isalnum() or line[i] == "_"
|
|
4159
|
-
):
|
|
4160
|
-
i += 1
|
|
4161
|
-
|
|
4162
|
-
word = line[start:i]
|
|
4163
|
-
|
|
4164
|
-
if word in self.COMMANDS:
|
|
4165
|
-
style = "class:command"
|
|
4166
|
-
elif word in self.CONTROL_WORDS:
|
|
4167
|
-
style = "class:control"
|
|
4168
|
-
elif word in {"True", "False", "None"}:
|
|
4169
|
-
style = "class:number"
|
|
4170
|
-
elif word in {"print", "len", "str", "int", "float", "range"}:
|
|
4171
|
-
style = "class:python"
|
|
4172
|
-
elif word in SOURCES:
|
|
4173
|
-
style = "class:source"
|
|
4174
|
-
elif word.casefold() in self.CONDITIONS:
|
|
4175
|
-
style = "class:condition"
|
|
4176
|
-
elif word in self.OPERATORS:
|
|
4177
|
-
style = "class:operator"
|
|
4178
|
-
elif (
|
|
4179
|
-
word in CHEMQL_VARIABLES
|
|
4180
|
-
or word in python_globals
|
|
4181
|
-
):
|
|
4182
|
-
style = "class:variable"
|
|
4183
|
-
elif (
|
|
4184
|
-
word in elementKeys
|
|
4185
|
-
or word in moleculeKeys
|
|
4186
|
-
or word in reactionKeys
|
|
4187
|
-
):
|
|
4188
|
-
style = "class:key"
|
|
4189
|
-
else:
|
|
4190
|
-
style = ""
|
|
4191
|
-
|
|
4192
|
-
tokens.append((style, word))
|
|
4193
|
-
continue
|
|
4194
|
-
|
|
4195
|
-
# Other punctuation
|
|
4196
|
-
tokens.append(("", line[i]))
|
|
4961
|
+
tokens.append(("", ch))
|
|
4197
4962
|
i += 1
|
|
4198
|
-
|
|
4199
4963
|
return tokens
|
|
4200
|
-
|
|
4201
4964
|
return get_line
|
|
4202
4965
|
|
|
4203
4966
|
|
|
4204
4967
|
class ChemqlCompleter(Completer):
|
|
4205
|
-
"""Context-aware completion for
|
|
4968
|
+
"""Context-aware completion for commands and dynamically created objects."""
|
|
4206
4969
|
|
|
4207
4970
|
COMMANDS = (
|
|
4208
|
-
"search", "source", "list", "clear", "return", "exit",
|
|
4209
|
-
"
|
|
4210
|
-
"
|
|
4211
|
-
"
|
|
4212
|
-
"
|
|
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",
|
|
4213
4976
|
)
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
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
|
+
}
|
|
4220
4994
|
|
|
4221
4995
|
def get_completions(self, document, complete_event):
|
|
4222
4996
|
text = document.text_before_cursor
|
|
4223
|
-
|
|
4997
|
+
stripped = text.lstrip()
|
|
4998
|
+
parts = stripped.split()
|
|
4224
4999
|
trailing_space = bool(text and text[-1].isspace())
|
|
4225
|
-
|
|
4226
5000
|
current = "" if trailing_space or not parts else parts[-1]
|
|
4227
5001
|
command_words = parts if trailing_space else parts[:-1]
|
|
5002
|
+
names = self._names()
|
|
4228
5003
|
|
|
4229
|
-
def emit(
|
|
5004
|
+
def emit(values):
|
|
4230
5005
|
seen = set()
|
|
4231
|
-
for
|
|
4232
|
-
|
|
4233
|
-
key =
|
|
4234
|
-
|
|
4235
|
-
if (
|
|
4236
|
-
key not in seen
|
|
4237
|
-
and key.startswith(current.casefold())
|
|
4238
|
-
):
|
|
5006
|
+
for value in values:
|
|
5007
|
+
value = str(value)
|
|
5008
|
+
key = value.casefold()
|
|
5009
|
+
if key not in seen and key.startswith(current.casefold()):
|
|
4239
5010
|
seen.add(key)
|
|
4240
|
-
yield Completion(
|
|
4241
|
-
suggestion,
|
|
4242
|
-
start_position=-len(current),
|
|
4243
|
-
)
|
|
5011
|
+
yield Completion(value, start_position=-len(current))
|
|
4244
5012
|
|
|
4245
|
-
|
|
5013
|
+
dynamic = sorted(set().union(*names.values()), key=str.casefold)
|
|
4246
5014
|
if not command_words:
|
|
4247
|
-
|
|
4248
|
-
suggestions.extend(
|
|
4249
|
-
sorted(CHEMQL_VARIABLES)
|
|
4250
|
-
)
|
|
4251
|
-
yield from emit(suggestions)
|
|
5015
|
+
yield from emit(list(self.COMMANDS) + self.CONTROL_COMPLETIONS + dynamic)
|
|
4252
5016
|
return
|
|
4253
5017
|
|
|
4254
|
-
command = command_words[0]
|
|
5018
|
+
command = command_words[0].casefold()
|
|
4255
5019
|
suggestions = []
|
|
4256
|
-
|
|
4257
|
-
# Native variable syntax
|
|
4258
5020
|
if command == "let":
|
|
4259
|
-
if len(command_words) == 1
|
|
4260
|
-
suggestions = ["name = value"]
|
|
4261
|
-
elif len(command_words) >= 2:
|
|
4262
|
-
suggestions = ["="]
|
|
4263
|
-
|
|
5021
|
+
suggestions = ["name = value"] if len(command_words) == 1 else ["="]
|
|
4264
5022
|
elif command == "unset":
|
|
4265
|
-
suggestions = sorted(
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
# Source names
|
|
4271
|
-
elif command in {"search", "source"}:
|
|
5023
|
+
suggestions = sorted(names["variables"])
|
|
5024
|
+
elif command == "source":
|
|
5025
|
+
suggestions = list(SOURCES)
|
|
5026
|
+
elif command == "search":
|
|
4272
5027
|
if len(command_words) == 1:
|
|
4273
5028
|
suggestions = list(SOURCES)
|
|
4274
|
-
elif
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
_, keys = SOURCES[source_name]
|
|
4278
|
-
suggestions = list(keys) + self.QUERY_OPERATORS
|
|
4279
|
-
|
|
4280
|
-
# 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
|
|
4281
5032
|
elif command == "set":
|
|
4282
5033
|
if len(command_words) == 1:
|
|
4283
|
-
suggestions = [
|
|
4284
|
-
"temperature", "pressure", "catalysts"
|
|
4285
|
-
]
|
|
5034
|
+
suggestions = ["temperature", "pressure", "catalysts"]
|
|
4286
5035
|
elif len(command_words) == 2:
|
|
4287
5036
|
setting = command_words[1].casefold()
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
elif setting == "pressure":
|
|
4294
|
-
suggestions = [
|
|
4295
|
-
"standard", "room", "0Pa",
|
|
4296
|
-
"0N/m^2", "0Nm^-2", "0bar",
|
|
4297
|
-
]
|
|
4298
|
-
elif setting == "catalysts":
|
|
4299
|
-
suggestions = list(
|
|
4300
|
-
reaction_state["catalysts"]
|
|
4301
|
-
)
|
|
4302
|
-
|
|
4303
|
-
# 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, [])
|
|
4304
5042
|
elif command in {"add", "remove"}:
|
|
4305
5043
|
if len(command_words) == 1:
|
|
4306
|
-
suggestions = ["catalyst"]
|
|
4307
|
-
elif (
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
elif command
|
|
4318
|
-
suggestions = sorted(
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
"
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
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
|
|
4327
5070
|
yield from emit(suggestions)
|
|
4328
5071
|
|
|
4329
5072
|
|
|
4330
|
-
|
|
4331
5073
|
chemql_style = Style.from_dict({
|
|
4332
5074
|
"command": "ansicyan",
|
|
4333
5075
|
"terminalcommand": "ansipurple",
|
|
@@ -4340,11 +5082,16 @@ chemql_style = Style.from_dict({
|
|
|
4340
5082
|
"control": "ansired",
|
|
4341
5083
|
"python": "ansibrightcyan",
|
|
4342
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",
|
|
4343
5092
|
})
|
|
4344
5093
|
|
|
4345
5094
|
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
5095
|
class _ChemQLField:
|
|
4349
5096
|
"""Field selector used by the fluent Python query API."""
|
|
4350
5097
|
def __init__(self, query, field):
|
|
@@ -4487,6 +5234,83 @@ ChemQLQuery.__name__ = "ChemQLQuery"
|
|
|
4487
5234
|
ChemQLQuery.__qualname__ = "ChemQLQuery"
|
|
4488
5235
|
|
|
4489
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
|
+
|
|
4490
5314
|
def main():
|
|
4491
5315
|
parser = argparse.ArgumentParser(
|
|
4492
5316
|
description="Chemql query and chemistry language"
|
|
@@ -4611,17 +5435,40 @@ def run_interactive():
|
|
|
4611
5435
|
if "///" in first:
|
|
4612
5436
|
return sum(line.count("///") for line in source_lines) < 2
|
|
4613
5437
|
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
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
|
|
4623
5451
|
|
|
4624
|
-
|
|
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)
|
|
4625
5472
|
|
|
4626
5473
|
@bindings.add(Keys.Enter)
|
|
4627
5474
|
def _handle_enter(event):
|
|
@@ -4749,6 +5596,44 @@ def run_interactive():
|
|
|
4749
5596
|
if not lines:
|
|
4750
5597
|
continue
|
|
4751
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
|
+
|
|
4752
5637
|
# Collect a Python control block only if the single editable buffer
|
|
4753
5638
|
# was submitted before its matching [endblock] was entered.
|
|
4754
5639
|
if lines and is_python_block_header(lines[0].strip()):
|