chemql-python 1.2.6__py3-none-any.whl → 1.3.1__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 +1415 -370
- chemql_python-1.3.1.dist-info/METADATA +904 -0
- {chemql_python-1.2.6.dist-info → chemql_python-1.3.1.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.1.dist-info}/WHEEL +0 -0
- {chemql_python-1.2.6.dist-info → chemql_python-1.3.1.dist-info}/entry_points.txt +0 -0
- {chemql_python-1.2.6.dist-info → chemql_python-1.3.1.dist-info}/licenses/LICENSE +0 -0
- {chemql_python-1.2.6.dist-info → chemql_python-1.3.1.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.1"
|
|
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": f"ChemQL/{__version__} (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": f"ChemQL/{__version__} (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
|
|
|
@@ -3278,7 +4045,7 @@ def process_lines(lines):
|
|
|
3278
4045
|
print(output, end="" if output.endswith("\n") else "\n")
|
|
3279
4046
|
_record_execution("[[[\n" + code + "\n]]]", output, True)
|
|
3280
4047
|
except Exception as exc:
|
|
3281
|
-
message = f"
|
|
4048
|
+
message = f"\033[31mError: {exc}\033[0m"
|
|
3282
4049
|
print(strip_ansi(message) if NO_ANSI else message, file=sys.stderr)
|
|
3283
4050
|
_record_execution("[[[\n" + code + "\n]]]", message, False)
|
|
3284
4051
|
i += 1
|
|
@@ -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,10 +4155,16 @@ 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:
|
|
3387
|
-
|
|
4165
|
+
if _CHEMQL_RISK_DEPTH:
|
|
4166
|
+
raise
|
|
4167
|
+
message = f"\033[31mError: {exc}\033[0m"
|
|
3388
4168
|
print(strip_ansi(message) if NO_ANSI else message, file=sys.stderr)
|
|
3389
4169
|
_record_execution(command, message, False)
|
|
3390
4170
|
|
|
@@ -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):
|
|
@@ -3984,7 +4766,175 @@ def execute(arguments, query_text=None, positions=None):
|
|
|
3984
4766
|
|
|
3985
4767
|
return format_error(f"Unknown command `{arguments[0]}`")
|
|
3986
4768
|
|
|
3987
|
-
HELP = '\x1b[1;94m╭──────────────────────────────────────────────────────────────────────────────╮\x1b[0m\n\x1b[1;94m│\x1b[0m \x1b[1;96m✦ \x1b[1mChemQL\x1b[0m \x1b[97mv1.2.0\x1b[0m \x1b[90m│\x1b[0m \x1b[1m\x1b[97mTHE PYTHON CHEMISTRY QUERY LANGUAGE\x1b[0m \x1b[1;94m│\x1b[0m\n\x1b[1;94m│\x1b[0m \x1b[90mQuery chemical datasets · balance equations · embed Python · automate workflows\x1b[0m \x1b[1;94m│\x1b[0m\n\x1b[1;94m╰──────────────────────────────────────────────────────────────────────────────╯\x1b[0m\n\n\x1b[1;93m\x1b[1m▶ QUICK START\x1b[0m\n \x1b[92msearch elements ... \x1b[0m \x1b[90m│\x1b[0m Query the periodic-table dataset\n \x1b[92msearch molecules ... \x1b[0m \x1b[90m│\x1b[0m Query molecule records\n \x1b[92msearch reactions ... \x1b[0m \x1b[90m│\x1b[0m Query stored reaction records\n \x1b[92mfindel "He" \x1b[0m \x1b[90m│\x1b[0m Find an element by name or symbol\n \x1b[92mfindmol "Water" \x1b[0m \x1b[90m│\x1b[0m Find a molecule by name or formula\n \x1b[92mfindre "Haber process" \x1b[0m \x1b[90m│\x1b[0m Find a reaction by name, ID, or equation\n \x1b[92mlist all \x1b[0m \x1b[90m│\x1b[0m List available fields and sources\n \x1b[92mhelp or ? \x1b[0m \x1b[90m│\x1b[0m Show this help reference\n \x1b[92mversion \x1b[0m \x1b[90m│\x1b[0m Show the installed version\n \x1b[92mexit \x1b[0m \x1b[90m│\x1b[0m Leave the interactive prompt\n\n\x1b[1;93m\x1b[1m◆ QUERY SOURCES & EXAMPLES\x1b[0m\n \x1b[92msearch elements [where] \x1b[0m \x1b[90m│\x1b[0m Search element data\n \x1b[92msearch molecules [where] \x1b[0m \x1b[90m│\x1b[0m Search molecule data\n \x1b[92msearch reactions [where] \x1b[0m \x1b[90m│\x1b[0m Search reaction data\n \x1b[1;96msource elements|molecules|reactions\x1b[0m \x1b[90m│\x1b[0m Set the current source\n \x1b[95msearch elements symbol = "Fe" return name symbol number atomic_mass\x1b[0m\n \x1b[95msearch molecules molecular_weight < 50 sort molecular_weight limit 10\x1b[0m\n \x1b[95msearch reactions reversible = true return name equation source\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ FILTERS & LOGIC\x1b[0m\n \x1b[1;96m= != > < >= <= \x1b[0m \x1b[90m│\x1b[0m Comparison operators\n \x1b[1;96mlike / like! \x1b[0m \x1b[90m│\x1b[0m Case-insensitive / case-sensitive wildcard matching\n \x1b[1;96mlikep / has \x1b[0m \x1b[90m│\x1b[0m Pattern matching / contains an item\n \x1b[1;96mand / or / ( ... ) \x1b[0m \x1b[90m│\x1b[0m Combine conditions; parentheses group logic\n \x1b[95msearch elements number > 10 and number < 20\x1b[0m\n \x1b[95msearch molecules name like "*water*"\x1b[0m\n \x1b[95msearch molecules (name like "*water*" or formula = "H2O") and molecular_weight < 25\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ RESULT OPERATIONS\x1b[0m\n \x1b[92mfirst / last \x1b[0m \x1b[90m│\x1b[0m Return the first or last match\n \x1b[92msort FIELD [asc|desc] \x1b[0m \x1b[90m│\x1b[0m Sort matching records\n \x1b[92mlimit N \x1b[0m \x1b[90m│\x1b[0m Limit the number of results\n \x1b[92mcount \x1b[0m \x1b[90m│\x1b[0m Count matches\n \x1b[92mreturn FIELD ... \x1b[0m \x1b[90m│\x1b[0m Choose fields; one field lists, multiple fields form a table\n \x1b[92munion / intersection \x1b[0m \x1b[90m│\x1b[0m Combine result sets\n \x1b[95msearch molecules molecular_weight < 20 sort molecular_weight asc limit 5\x1b[0m\n \x1b[95msearch elements number < 10 union search elements number > 90\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ REACTION TOOLS & CONDITIONS\x1b[0m\n \x1b[1;96mset temperature 723K|150C|300F|standard|room \x1b[0m \x1b[90m│\x1b[0m Set reaction temperature\n \x1b[1;96mset pressure 101325Pa|2bar|1atm|standard|room \x1b[0m \x1b[90m│\x1b[0m Set reaction pressure\n \x1b[1;96mset catalysts iron zinc \x1b[0m \x1b[90m│\x1b[0m Set the catalyst list\n \x1b[1;96mset catalysts \x1b[0m \x1b[90m│\x1b[0m Clear the catalyst list\n \x1b[1;96madd catalyst "Vanadium oxide"\x1b[0m \x1b[90m│\x1b[0m Add a catalyst\n \x1b[1;96mremove catalyst "Vanadium oxide"\x1b[0m \x1b[90m│\x1b[0m Remove a catalyst\n \x1b[1;96mconditions \x1b[0m \x1b[90m│\x1b[0m Show active temperature, pressure, and catalysts\n \x1b[1;96mreact N2 3H2 \x1b[0m \x1b[90m│\x1b[0m Find a stored reaction matching reactants\n \x1b[1;96mbalance H2 + O2 -> H2O \x1b[0m \x1b[90m│\x1b[0m Balance an irreversible equation\n \x1b[1;96mbalance N2 + H2 <-> NH3 \x1b[0m \x1b[90m│\x1b[0m Balance a reversible equation\n \x1b[2mTemperature units: C, F, K. Pressure units include Pa, N/m^2, Nm^-2, bar, and atm.\x1b[0m\n \x1b[2mPresets: standard = 0 C and 100000 Pa; room = 25 C and 101325 Pa.\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ PREPROCESSOR, FUNCTIONS & MACROS · V1.2.0\x1b[0m\n \x1b[95mdefine MAX 9 \x1b[0m \x1b[90m│\x1b[0m Define a literal replacement used as @MAX\n \x1b[95mdefinitions \x1b[0m \x1b[90m│\x1b[0m List current literal definitions\n \x1b[95mremove definition MAX \x1b[0m \x1b[90m│\x1b[0m Remove a definition\n \x1b[95mdefine MAX 9\x1b[0m\n \x1b[95msearch elements number <= @MAX\x1b[0m\n\n \x1b[95mfunc square \x1b[0m \x1b[90m│\x1b[0m Begin a function; finish it with endfunc\n \x1b[95mreturn [\\1 * \\1] \x1b[0m \x1b[90m│\x1b[0m Return a value using positional argument \\1\n \x1b[95mendfunc \x1b[0m \x1b[90m│\x1b[0m Close the function definition\n \x1b[95msquare{5} \x1b[0m \x1b[90m│\x1b[0m Call the function with brace-delimited arguments\n\n \x1b[95mdefine light{} \x1b[0m \x1b[90m│\x1b[0m Begin a macro; finish it with enddefine\n \x1b[95msearch molecules molecular_weight < \\1 return name formula\x1b[0m\n \x1b[95menddefine \x1b[0m \x1b[90m│\x1b[0m Close the macro definition\n \x1b[95m@light{50} \x1b[0m \x1b[90m│\x1b[0m Expand and execute a macro\n \x1b[2mFunction and macro arguments use positional placeholders: \\1, \\2, and so on.\x1b[0m\n \x1b[2mDefinitions are processed in source order and are local to the current session.\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ PYTHON INTEGRATION\x1b[0m\n \x1b[1;96m[x = 10] \x1b[0m \x1b[90m│\x1b[0m Execute a Python statement\n \x1b[1;96m[print(x)] \x1b[0m \x1b[90m│\x1b[0m Use Python variables across commands\n \x1b[1;96m[x * 2] \x1b[0m \x1b[90m│\x1b[0m Evaluate and display a Python expression\n \x1b[95m{{ search elements name = "Hydrogen" }}\x1b[0m\n \x1b[2mEmbedded {{ ... }} queries return their result as a value.\x1b[0m\n Python control-flow blocks:\n \x1b[95m[if condition] ... [elif condition] ... [else] ... [endblock]\x1b[0m\n \x1b[95m[for item in iterable] ... [endblock]\x1b[0m\n \x1b[95m[while condition] ... [endblock]\x1b[0m\n \x1b[95m[break] and [continue] inside loops\x1b[0m\n Multiline Python block:\n \x1b[95m[[[\x1b[0m\n \x1b[95mfor x in range(3):\x1b[0m\n \x1b[95m print(x)\x1b[0m\n \x1b[95m]]] # close on its own line\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ SHELL, COMMENTS & COMMAND COMPOSITION\x1b[0m\n \x1b[1;96m`command` \x1b[0m \x1b[90m│\x1b[0m Run a command in the persistent system shell\n \x1b[1;96m```bash ... ``` \x1b[0m \x1b[90m│\x1b[0m Run a multiline shell block (also sh, cmd, terminal)\n \x1b[1;96m// comment \x1b[0m \x1b[90m│\x1b[0m Single-line comment\n \x1b[1;96m/// ... /// \x1b[0m \x1b[90m│\x1b[0m Multiline comment\n \x1b[1;96mcommand1; command2 \x1b[0m \x1b[90m│\x1b[0m Run multiple commands on one line\n \x1b[1;96mtrailing backslash \x1b[0m \x1b[90m│\x1b[0m Continue a command on the next line\n \x1b[1;96mview \x1b[0m \x1b[90m│\x1b[0m Open the bundled periodic-table viewer\n \x1b[1;96mclear \x1b[0m \x1b[90m│\x1b[0m Clear the terminal\n\n\x1b[1;93m\x1b[1m◆ HISTORY EXPORT\x1b[0m\n \x1b[92mdump FILE \x1b[0m \x1b[90m│\x1b[0m Export all recorded queries and results\n \x1b[92mdump all|good|bad FILE \x1b[0m \x1b[90m│\x1b[0m Export all, successful, or failed executions\n \x1b[92mdump all|good|bad queries FILE\x1b[0m \x1b[90m│\x1b[0m Export query history\n \x1b[92mdump all|good|bad results FILE\x1b[0m \x1b[90m│\x1b[0m Export result history\n\n\x1b[1;93m\x1b[1m◆ COMMAND-LINE OPTIONS\x1b[0m\n \x1b[1;96mchemql \x1b[0m \x1b[90m│\x1b[0m Start the interactive prompt\n \x1b[1;96mchemql -c "COMMAND" \x1b[0m \x1b[90m│\x1b[0m Execute one command\n \x1b[1;96mchemql -f FILE \x1b[0m \x1b[90m│\x1b[0m Execute commands from a file\n \x1b[1;96mchemql --version \x1b[0m \x1b[90m│\x1b[0m Print the installed version\n \x1b[1;96mchemql --no-ansi \x1b[0m \x1b[90m│\x1b[0m Disable ANSI styling in output\n \x1b[1;96mchemql -c "COMMAND" -o FILE \x1b[0m \x1b[90m│\x1b[0m Write command output to a plain-text file\n \x1b[1;96mchemql --upgrade [VERSION] \x1b[0m \x1b[90m│\x1b[0m Upgrade to latest or a chosen release\n \x1b[1;96mchemql --force \x1b[0m \x1b[90m│\x1b[0m Force upgrade past system-package protections\n\n\x1b[1;93m\x1b[1m◆ PYTHON API\x1b[0m\n \x1b[95mfrom chemql import execute_query_text, execute_query\x1b[0m\n \x1b[95mresult = execute_query_text(\'search elements symbol = "O"\')\x1b[0m\n \x1b[2mDistribution package: chemql-python · import / CLI name: chemql\x1b[0m\n\n\x1b[1;93m\x1b[1m⚠ SAFETY & DATA NOTES\x1b[0m\n \x1b[2mPython blocks and shell commands run real code with your user permissions.\x1b[0m\n \x1b[2mRun only code and commands you trust. External lookups need network access\x1b[0m\n \x1b[2mand can fail when a remote service is unavailable.\x1b[0m\n\n\x1b[1;94m────────────────────────────────────────────────────────────────────────────────\x1b[0m\n\x1b[90mChemQL 1.2.0 · Type \x1b[92mhelp\x1b[90m for this guide, or \x1b[92mexit\x1b[90m to quit.\x1b[0m\n'
|
|
4769
|
+
|
|
4770
|
+
HELP = (
|
|
4771
|
+
"\x1b[1;94m╭──────────────────────────────────────────────────────────────────────────────╮\x1b[0m\n"
|
|
4772
|
+
f"\x1b[1;94m│\x1b[0m \x1b[1;96m✦ \x1b[1mChemQL\x1b[0m \x1b[97mv{__version__}\x1b[0m \x1b[90m│\x1b[0m \x1b[1m\x1b[97mTHE PYTHON CHEMISTRY QUERY LANGUAGE\x1b[0m \x1b[1;94m│\x1b[0m\n"
|
|
4773
|
+
"\x1b[1;94m│\x1b[0m \x1b[90mQuery chemistry · automate calculations · build reusable programs\x1b[0m \x1b[1;94m│\x1b[0m\n"
|
|
4774
|
+
"\x1b[1;94m╰──────────────────────────────────────────────────────────────────────────────╯\x1b[0m\n"
|
|
4775
|
+
"\n"
|
|
4776
|
+
"\x1b[1;93m\x1b[1m▶ QUICK START\x1b[0m\n"
|
|
4777
|
+
" \x1b[92msearch elements ... \x1b[0m \x1b[90m│\x1b[0m Search the periodic table\n"
|
|
4778
|
+
" \x1b[92msearch molecules ... \x1b[0m \x1b[90m│\x1b[0m Search molecule records\n"
|
|
4779
|
+
" \x1b[92msearch reactions ... \x1b[0m \x1b[90m│\x1b[0m Search stored reactions\n"
|
|
4780
|
+
" \x1b[92mfindel \"He\" \x1b[0m \x1b[90m│\x1b[0m Find an element\n"
|
|
4781
|
+
" \x1b[92mfindmol \"Water\" \x1b[0m \x1b[90m│\x1b[0m Find a molecule\n"
|
|
4782
|
+
" \x1b[92mfindre \"Haber process\" \x1b[0m \x1b[90m│\x1b[0m Find a reaction\n"
|
|
4783
|
+
" \x1b[92mbalance H2 + O2 -> H2O \x1b[0m \x1b[90m│\x1b[0m Balance a chemical equation\n"
|
|
4784
|
+
" \x1b[92mhelp or ? \x1b[0m \x1b[90m│\x1b[0m Display this help message\n"
|
|
4785
|
+
" \x1b[92mversion \x1b[0m \x1b[90m│\x1b[0m Display the installed version\n"
|
|
4786
|
+
" \x1b[92mexit \x1b[0m \x1b[90m│\x1b[0m Exit the interactive prompt\n"
|
|
4787
|
+
"\n"
|
|
4788
|
+
"\x1b[1;93m\x1b[1m◆ CHEMICAL DATA & SEARCH\x1b[0m\n"
|
|
4789
|
+
" \x1b[1;96msearch elements [conditions] \x1b[0m \x1b[90m│\x1b[0m Query element data\n"
|
|
4790
|
+
" \x1b[1;96msearch molecules [conditions] \x1b[0m \x1b[90m│\x1b[0m Query molecule data\n"
|
|
4791
|
+
" \x1b[1;96msearch reactions [conditions] \x1b[0m \x1b[90m│\x1b[0m Query stored reaction data\n"
|
|
4792
|
+
" \x1b[1;96msource elements|molecules|reactions\x1b[0m \x1b[90m│\x1b[0m Set the current source\n"
|
|
4793
|
+
" \x1b[1;96mlist all \x1b[0m \x1b[90m│\x1b[0m List available fields and sources\n"
|
|
4794
|
+
" \x1b[95msearch elements symbol = \"Fe\" return name symbol number\x1b[0m\n"
|
|
4795
|
+
" \x1b[95msearch molecules molecular_weight < 50 sort molecular_weight limit 10\x1b[0m\n"
|
|
4796
|
+
" \x1b[95msearch reactions reversible = true return name equation source\x1b[0m\n"
|
|
4797
|
+
"\n"
|
|
4798
|
+
"\x1b[1;93m\x1b[1m◆ FILTERS & LOGIC\x1b[0m\n"
|
|
4799
|
+
" \x1b[1;96m= != > < >= <= \x1b[0m \x1b[90m│\x1b[0m Comparison operators\n"
|
|
4800
|
+
" \x1b[1;96mlike \x1b[0m \x1b[90m│\x1b[0m Case-insensitive wildcard match\n"
|
|
4801
|
+
" \x1b[1;96mlike! \x1b[0m \x1b[90m│\x1b[0m Case-sensitive wildcard match\n"
|
|
4802
|
+
" \x1b[1;96mlikep \x1b[0m \x1b[90m│\x1b[0m Pattern-based matching\n"
|
|
4803
|
+
" \x1b[1;96mhas \x1b[0m \x1b[90m│\x1b[0m Check whether a collection contains an item\n"
|
|
4804
|
+
" \x1b[1;96mand / or \x1b[0m \x1b[90m│\x1b[0m Combine conditions\n"
|
|
4805
|
+
" \x1b[95msearch elements number > 10 and number < 20\x1b[0m\n"
|
|
4806
|
+
" \x1b[95msearch molecules name like \"*water*\"\x1b[0m\n"
|
|
4807
|
+
" \x1b[95msearch reactions reactants has \"O2\" and products has \"H2O\"\x1b[0m\n"
|
|
4808
|
+
"\n"
|
|
4809
|
+
"\x1b[1;93m\x1b[1m◆ RESULT OPERATIONS\x1b[0m\n"
|
|
4810
|
+
" \x1b[92mfirst / last \x1b[0m \x1b[90m│\x1b[0m Select the first or last result\n"
|
|
4811
|
+
" \x1b[92msort FIELD [asc|desc] \x1b[0m \x1b[90m│\x1b[0m Sort matching records\n"
|
|
4812
|
+
" \x1b[92mlimit N \x1b[0m \x1b[90m│\x1b[0m Limit the number of results\n"
|
|
4813
|
+
" \x1b[92mcount \x1b[0m \x1b[90m│\x1b[0m Count matching records\n"
|
|
4814
|
+
" \x1b[92mreturn FIELD ... \x1b[0m \x1b[90m│\x1b[0m Choose fields to display\n"
|
|
4815
|
+
" \x1b[92munion / intersection \x1b[0m \x1b[90m│\x1b[0m Combine result sets\n"
|
|
4816
|
+
" \x1b[95msearch molecules molecular_weight < 20 sort molecular_weight asc limit 5\x1b[0m\n"
|
|
4817
|
+
"\n"
|
|
4818
|
+
"\x1b[1;93m\x1b[1m◆ REACTIONS & CONDITIONS\x1b[0m\n"
|
|
4819
|
+
" \x1b[1;96mbalance H2 + O2 -> H2O \x1b[0m \x1b[90m│\x1b[0m Balance an equation\n"
|
|
4820
|
+
" \x1b[1;96mbalance N2 + H2 <-> NH3 \x1b[0m \x1b[90m│\x1b[0m Balance a reversible equation\n"
|
|
4821
|
+
" \x1b[1;96mreact N2 3H2 \x1b[0m \x1b[90m│\x1b[0m Find a matching stored reaction\n"
|
|
4822
|
+
" \x1b[1;96mset temperature 150C \x1b[0m \x1b[90m│\x1b[0m Set the reaction temperature\n"
|
|
4823
|
+
" \x1b[1;96mset pressure 1atm \x1b[0m \x1b[90m│\x1b[0m Set the reaction pressure\n"
|
|
4824
|
+
" \x1b[1;96mset catalysts iron zinc \x1b[0m \x1b[90m│\x1b[0m Set available catalysts\n"
|
|
4825
|
+
" \x1b[1;96mset catalysts \x1b[0m \x1b[90m│\x1b[0m Clear the catalyst list\n"
|
|
4826
|
+
" \x1b[1;96madd catalyst \"Iron\" \x1b[0m \x1b[90m│\x1b[0m Add a catalyst\n"
|
|
4827
|
+
" \x1b[1;96mremove catalyst \"Iron\" \x1b[0m \x1b[90m│\x1b[0m Remove a catalyst\n"
|
|
4828
|
+
" \x1b[1;96mconditions \x1b[0m \x1b[90m│\x1b[0m Display current conditions\n"
|
|
4829
|
+
" \x1b[2mTemperature units: K, C, F. Pressure units include Pa, bar, and atm.\x1b[0m\n"
|
|
4830
|
+
"\n"
|
|
4831
|
+
"\x1b[1;93m\x1b[1m◆ VARIABLES & DEFINITIONS\x1b[0m\n"
|
|
4832
|
+
" \x1b[95mlet answer = 42 \x1b[0m \x1b[90m│\x1b[0m Create or assign a variable\n"
|
|
4833
|
+
" \x1b[95munset answer \x1b[0m \x1b[90m│\x1b[0m Remove a variable\n"
|
|
4834
|
+
" \x1b[95mvariables \x1b[0m \x1b[90m│\x1b[0m List current variables\n"
|
|
4835
|
+
" \x1b[95mdefine MAX 9 \x1b[0m \x1b[90m│\x1b[0m Define a literal replacement\n"
|
|
4836
|
+
" \x1b[95m@MAX \x1b[0m \x1b[90m│\x1b[0m Use a definition\n"
|
|
4837
|
+
" \x1b[95mdefinitions \x1b[0m \x1b[90m│\x1b[0m List definitions\n"
|
|
4838
|
+
" \x1b[95mremove definition MAX \x1b[0m \x1b[90m│\x1b[0m Remove a definition\n"
|
|
4839
|
+
" \x1b[95mdefine MAX 9\x1b[0m\n"
|
|
4840
|
+
" \x1b[95msearch elements number <= @MAX\x1b[0m\n"
|
|
4841
|
+
"\n"
|
|
4842
|
+
"\x1b[1;93m\x1b[1m◆ FUNCTIONS & MACROS\x1b[0m\n"
|
|
4843
|
+
" \x1b[95mfunc square \x1b[0m \x1b[90m│\x1b[0m Start a function definition\n"
|
|
4844
|
+
" \x1b[95mreturn [\\1 * \\1] \x1b[0m \x1b[90m│\x1b[0m Return a value using argument \\1\n"
|
|
4845
|
+
" \x1b[95mendfunc \x1b[0m \x1b[90m│\x1b[0m Finish a function definition\n"
|
|
4846
|
+
" \x1b[95msquare{5} \x1b[0m \x1b[90m│\x1b[0m Call a function\n"
|
|
4847
|
+
" \x1b[95mdefine double{} \x1b[0m \x1b[90m│\x1b[0m Start a macro definition\n"
|
|
4848
|
+
" \x1b[95menddefine \x1b[0m \x1b[90m│\x1b[0m Finish a macro definition\n"
|
|
4849
|
+
" \x1b[95m@double{5} \x1b[0m \x1b[90m│\x1b[0m Expand and execute a macro\n"
|
|
4850
|
+
" \x1b[95mmacros \x1b[0m \x1b[90m│\x1b[0m List defined macros\n"
|
|
4851
|
+
" \x1b[2mFunction and macro arguments use positional placeholders: \\1, \\2, and so on.\x1b[0m\n"
|
|
4852
|
+
"\n"
|
|
4853
|
+
"\x1b[1;93m\x1b[1m◆ CONTROL FLOW\x1b[0m\n"
|
|
4854
|
+
" \x1b[95mif condition ... endif \x1b[0m \x1b[90m│\x1b[0m Conditional execution\n"
|
|
4855
|
+
" \x1b[95mif / elif / else / endif \x1b[0m \x1b[90m│\x1b[0m Branch between conditions\n"
|
|
4856
|
+
" \x1b[95mfor item in iterable ... endfor\x1b[0m \x1b[90m│\x1b[0m Iterate over values\n"
|
|
4857
|
+
" \x1b[95mwhile condition ... endwhile \x1b[0m \x1b[90m│\x1b[0m Repeat while a condition holds\n"
|
|
4858
|
+
" \x1b[95mbreak / continue \x1b[0m \x1b[90m│\x1b[0m Control loop execution\n"
|
|
4859
|
+
" \x1b[95mfor i in range(5)\x1b[0m\n"
|
|
4860
|
+
" \x1b[95mlet value = i\x1b[0m\n"
|
|
4861
|
+
" \x1b[95mendfor\x1b[0m\n"
|
|
4862
|
+
" \x1b[2mPython-style blocks are also available: [for ...] ... [endblock].\x1b[0m\n"
|
|
4863
|
+
"\n"
|
|
4864
|
+
"\x1b[1;93m\x1b[1m◆ ERROR HANDLING\x1b[0m\n"
|
|
4865
|
+
" \x1b[95mrisk ... endrisk \x1b[0m \x1b[90m│\x1b[0m Run a protected block\n"
|
|
4866
|
+
" \x1b[95mfix \x1b[0m \x1b[90m│\x1b[0m Handle an error from the risk block\n"
|
|
4867
|
+
" \x1b[95mcleanup \x1b[0m \x1b[90m│\x1b[0m Run cleanup whether successful or not\n"
|
|
4868
|
+
" \x1b[95msignal \"message\" \x1b[0m \x1b[90m│\x1b[0m Raise a ChemQL signal\n"
|
|
4869
|
+
" \x1b[95merror \x1b[0m \x1b[90m│\x1b[0m Inspect the error type in a fix block\n"
|
|
4870
|
+
" \x1b[95mrisk\x1b[0m\n"
|
|
4871
|
+
" \x1b[95msignal \"Something went wrong\"\x1b[0m\n"
|
|
4872
|
+
" \x1b[95mfix\x1b[0m\n"
|
|
4873
|
+
" \x1b[95mlet handled = error\x1b[0m\n"
|
|
4874
|
+
" \x1b[95mcleanup\x1b[0m\n"
|
|
4875
|
+
" \x1b[95mlet finished = true\x1b[0m\n"
|
|
4876
|
+
" \x1b[95mendrisk\x1b[0m\n"
|
|
4877
|
+
" \x1b[2mThe variable error contains the error type, or None after success.\x1b[0m\n"
|
|
4878
|
+
"\n"
|
|
4879
|
+
"\x1b[1;93m\x1b[1m◆ STRUCTS & METHODS\x1b[0m\n"
|
|
4880
|
+
" \x1b[95mstruct Name \x1b[0m \x1b[90m│\x1b[0m Define a structured data type\n"
|
|
4881
|
+
" \x1b[95mrecord Name \x1b[0m \x1b[90m│\x1b[0m Define or use record data\n"
|
|
4882
|
+
" \x1b[95mfunc Type.__add__ \x1b[0m \x1b[90m│\x1b[0m Define an operator method\n"
|
|
4883
|
+
" \x1b[2mStruct methods can implement custom behaviour and operator overloads.\x1b[0m\n"
|
|
4884
|
+
"\n"
|
|
4885
|
+
"\x1b[1;93m\x1b[1m◆ PYTHON INTEGRATION\x1b[0m\n"
|
|
4886
|
+
" \x1b[1;96m[x = 10] \x1b[0m \x1b[90m│\x1b[0m Execute a Python statement\n"
|
|
4887
|
+
" \x1b[1;96m[print(x)] \x1b[0m \x1b[90m│\x1b[0m Run Python code\n"
|
|
4888
|
+
" \x1b[1;96m[x * 2] \x1b[0m \x1b[90m│\x1b[0m Evaluate a Python expression\n"
|
|
4889
|
+
" \x1b[95m{{ search elements name = \"Hydrogen\" }}\x1b[0m\n"
|
|
4890
|
+
" \x1b[2mEmbedded {{ ... }} queries return results as values.\x1b[0m\n"
|
|
4891
|
+
" Multiline Python blocks:\n"
|
|
4892
|
+
" \x1b[95m[[[\x1b[0m\n"
|
|
4893
|
+
" \x1b[95mfor x in range(3):\x1b[0m\n"
|
|
4894
|
+
" \x1b[95m print(x)\x1b[0m\n"
|
|
4895
|
+
" \x1b[95m]]]\x1b[0m\n"
|
|
4896
|
+
"\n"
|
|
4897
|
+
"\x1b[1;93m\x1b[1m◆ SHELL, COMMENTS & COMPOSITION\x1b[0m\n"
|
|
4898
|
+
" \x1b[1;96m`command` \x1b[0m \x1b[90m│\x1b[0m Run a system-shell command\n"
|
|
4899
|
+
" \x1b[1;96m```bash ... ``` \x1b[0m \x1b[90m│\x1b[0m Run a multiline shell block\n"
|
|
4900
|
+
" \x1b[1;96m// comment \x1b[0m \x1b[90m│\x1b[0m Single-line comment\n"
|
|
4901
|
+
" \x1b[1;96m/// ... /// \x1b[0m \x1b[90m│\x1b[0m Multiline comment\n"
|
|
4902
|
+
" \x1b[1;96mcommand1; command2 \x1b[0m \x1b[90m│\x1b[0m Execute multiple commands\n"
|
|
4903
|
+
" \x1b[1;96m\\ \x1b[0m \x1b[90m│\x1b[0m Continue input on the next line\n"
|
|
4904
|
+
" \x1b[1;96mview \x1b[0m \x1b[90m│\x1b[0m Open the periodic-table viewer\n"
|
|
4905
|
+
" \x1b[1;96mclear \x1b[0m \x1b[90m│\x1b[0m Clear the terminal\n"
|
|
4906
|
+
"\n"
|
|
4907
|
+
"\x1b[1;93m\x1b[1m◆ EXECUTION HISTORY\x1b[0m\n"
|
|
4908
|
+
" \x1b[92mdump FILE \x1b[0m \x1b[90m│\x1b[0m Export queries and results\n"
|
|
4909
|
+
" \x1b[92mdump all FILE \x1b[0m \x1b[90m│\x1b[0m Export all execution records\n"
|
|
4910
|
+
" \x1b[92mdump good FILE \x1b[0m \x1b[90m│\x1b[0m Export successful executions\n"
|
|
4911
|
+
" \x1b[92mdump bad FILE \x1b[0m \x1b[90m│\x1b[0m Export failed executions\n"
|
|
4912
|
+
" \x1b[92mdump all|good|bad queries FILE\x1b[0m \x1b[90m│\x1b[0m Export query text only\n"
|
|
4913
|
+
" \x1b[92mdump all|good|bad results FILE\x1b[0m \x1b[90m│\x1b[0m Export results only\n"
|
|
4914
|
+
"\n"
|
|
4915
|
+
"\x1b[1;93m\x1b[1m◆ COMMAND-LINE OPTIONS\x1b[0m\n"
|
|
4916
|
+
" \x1b[1;96mchemql \x1b[0m \x1b[90m│\x1b[0m Start the interactive prompt\n"
|
|
4917
|
+
" \x1b[1;96mchemql -c \"COMMAND\" \x1b[0m \x1b[90m│\x1b[0m Execute one command\n"
|
|
4918
|
+
" \x1b[1;96mchemql -f FILE \x1b[0m \x1b[90m│\x1b[0m Execute a file\n"
|
|
4919
|
+
" \x1b[1;96mchemql --version \x1b[0m \x1b[90m│\x1b[0m Display version information\n"
|
|
4920
|
+
" \x1b[1;96mchemql --no-ansi \x1b[0m \x1b[90m│\x1b[0m Disable ANSI styling\n"
|
|
4921
|
+
" \x1b[1;96mchemql -c \"COMMAND\" -o FILE \x1b[0m \x1b[90m│\x1b[0m Save command output to a file\n"
|
|
4922
|
+
" \x1b[1;96mchemql --upgrade [VERSION] \x1b[0m \x1b[90m│\x1b[0m Upgrade ChemQL\n"
|
|
4923
|
+
" \x1b[1;96mchemql --force \x1b[0m \x1b[90m│\x1b[0m Override package-manager protections\n"
|
|
4924
|
+
"\n"
|
|
4925
|
+
"\x1b[1;93m\x1b[1m◆ PYTHON API\x1b[0m\n"
|
|
4926
|
+
" \x1b[95mfrom chemql import execute_query_text, execute_query\x1b[0m\n"
|
|
4927
|
+
" \x1b[95mresult = execute_query_text('search elements symbol = \"O\"')\x1b[0m\n"
|
|
4928
|
+
" \x1b[2mDistribution: chemql-python · Import and CLI name: chemql\x1b[0m\n"
|
|
4929
|
+
"\n"
|
|
4930
|
+
"\x1b[1;93m\x1b[1m⚠ SAFETY NOTES\x1b[0m\n"
|
|
4931
|
+
" \x1b[2mPython blocks and shell commands execute real code with your permissions.\x1b[0m\n"
|
|
4932
|
+
" \x1b[2mOnly run code and commands you trust. External lookups require network access.\x1b[0m\n"
|
|
4933
|
+
"\n"
|
|
4934
|
+
"\x1b[1;94m────────────────────────────────────────────────────────────────────────────────\x1b[0m\n"
|
|
4935
|
+
f"\x1b[90mChemQL {__version__} · Type \x1b[92mhelp\x1b[90m for help, or \x1b[92mexit\x1b[90m to quit.\x1b[0m\n"
|
|
4936
|
+
)
|
|
4937
|
+
|
|
3988
4938
|
|
|
3989
4939
|
import webbrowser
|
|
3990
4940
|
from pathlib import Path
|
|
@@ -3995,347 +4945,299 @@ def view_periodic_table():
|
|
|
3995
4945
|
|
|
3996
4946
|
|
|
3997
4947
|
class ChemqlLexer(Lexer):
|
|
3998
|
-
"""
|
|
4948
|
+
"""Dynamic syntax highlighting for ChemQL and its interactive objects."""
|
|
3999
4949
|
|
|
4000
4950
|
COMMANDS = {
|
|
4001
|
-
"search", "source", "list", "clear", "exit", "findel",
|
|
4002
|
-
"
|
|
4003
|
-
"
|
|
4004
|
-
"
|
|
4005
|
-
"
|
|
4951
|
+
"search", "source", "list", "clear", "exit", "findel", "findmol",
|
|
4952
|
+
"findre", "set", "add", "remove", "react", "balance", "conditions",
|
|
4953
|
+
"view", "help", "dump", "version", "return", "union", "intersection",
|
|
4954
|
+
"let", "unset", "variables", "macros", "definitions", "arguments",
|
|
4955
|
+
"define", "func", "struct", "record", "risk", "fix", "cleanup",
|
|
4956
|
+
"signal", "import", "remove", "install",
|
|
4006
4957
|
}
|
|
4007
|
-
|
|
4008
4958
|
OPERATORS = {
|
|
4009
|
-
"and", "or", "has", "like", "like!", "likep", "sort",
|
|
4010
|
-
"
|
|
4959
|
+
"and", "or", "has", "like", "like!", "likep", "sort", "limit",
|
|
4960
|
+
"count", "asc", "desc", "in", "not", "is",
|
|
4011
4961
|
}
|
|
4012
|
-
|
|
4013
4962
|
CONTROL_WORDS = {
|
|
4014
|
-
"if", "elif", "else", "for", "while", "
|
|
4015
|
-
"
|
|
4016
|
-
"define", "
|
|
4963
|
+
"if", "elif", "else", "for", "while", "break", "continue", "endblock",
|
|
4964
|
+
"endfor", "endwhile", "endif", "endrisk", "endstruct", "endrecord",
|
|
4965
|
+
"endfunc", "enddefine", "func", "define", "struct", "record", "risk",
|
|
4966
|
+
"fix", "cleanup", "signal", "import", "as", "return",
|
|
4017
4967
|
}
|
|
4018
|
-
|
|
4019
4968
|
CONDITIONS = {
|
|
4020
|
-
"temperature", "pressure", "catalyst", "catalysts",
|
|
4021
|
-
"
|
|
4969
|
+
"temperature", "pressure", "catalyst", "catalysts", "standard", "room",
|
|
4970
|
+
"c", "f", "k", "pa", "bar",
|
|
4022
4971
|
}
|
|
4023
4972
|
|
|
4973
|
+
@staticmethod
|
|
4974
|
+
def _dynamic_names():
|
|
4975
|
+
return {
|
|
4976
|
+
"variables": set(CHEMQL_VARIABLES) | {
|
|
4977
|
+
name for name in python_globals
|
|
4978
|
+
if isinstance(name, str) and not name.startswith("__")
|
|
4979
|
+
},
|
|
4980
|
+
"structs": set(STRUCTS),
|
|
4981
|
+
"records": set(RECORDS),
|
|
4982
|
+
"functions": set(FUNCTIONS),
|
|
4983
|
+
"macros": set(MACROS),
|
|
4984
|
+
"definitions": set(DEFINITIONS),
|
|
4985
|
+
}
|
|
4986
|
+
|
|
4024
4987
|
def lex_document(self, document):
|
|
4025
4988
|
lines = document.lines
|
|
4989
|
+
names = self._dynamic_names()
|
|
4990
|
+
function_names = {n.split(".")[-1] for n in names["functions"]}
|
|
4026
4991
|
|
|
4027
4992
|
def get_line(lineno):
|
|
4028
4993
|
if lineno >= len(lines):
|
|
4029
4994
|
return []
|
|
4030
|
-
|
|
4031
4995
|
line = lines[lineno]
|
|
4032
4996
|
tokens = []
|
|
4033
4997
|
i = 0
|
|
4034
4998
|
|
|
4035
|
-
#
|
|
4999
|
+
# Leading indentation is kept verbatim for prompt_toolkit.
|
|
4036
5000
|
while i < len(line) and line[i].isspace():
|
|
4037
5001
|
start = i
|
|
4038
5002
|
while i < len(line) and line[i].isspace():
|
|
4039
5003
|
i += 1
|
|
4040
5004
|
tokens.append(("", line[start:i]))
|
|
4041
5005
|
|
|
4042
|
-
#
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
5006
|
+
# Mark declaration names according to the declaration being edited.
|
|
5007
|
+
declaration = re.match(
|
|
5008
|
+
r"(?:struct|record|func)\s+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?)", line[i:], re.I
|
|
5009
|
+
)
|
|
5010
|
+
macro_declaration = re.match(r"define\s+([A-Za-z_]\w*)\s*\{\s*\}", line[i:], re.I)
|
|
5011
|
+
definition_declaration = re.match(r"define\s+([A-Za-z_]\w*)\s+", line[i:], re.I)
|
|
5012
|
+
declared_name = None
|
|
5013
|
+
declared_style = None
|
|
5014
|
+
if declaration:
|
|
5015
|
+
prefix = line[i:]
|
|
5016
|
+
keyword = prefix.split(None, 1)[0].casefold()
|
|
5017
|
+
declared_name = declaration.group(1)
|
|
5018
|
+
declared_style = "struct" if keyword == "struct" else "record" if keyword == "record" else "function"
|
|
5019
|
+
elif macro_declaration:
|
|
5020
|
+
declared_name, declared_style = macro_declaration.group(1), "macro"
|
|
5021
|
+
elif definition_declaration:
|
|
5022
|
+
declared_name, declared_style = definition_declaration.group(1), "definition"
|
|
4049
5023
|
|
|
4050
5024
|
while i < len(line):
|
|
4051
|
-
|
|
4052
|
-
if
|
|
4053
|
-
|
|
4054
|
-
while
|
|
4055
|
-
|
|
4056
|
-
tokens.append(("", line[
|
|
5025
|
+
ch = line[i]
|
|
5026
|
+
if ch.isspace():
|
|
5027
|
+
j = i + 1
|
|
5028
|
+
while j < len(line) and line[j].isspace():
|
|
5029
|
+
j += 1
|
|
5030
|
+
tokens.append(("", line[i:j]))
|
|
5031
|
+
i = j
|
|
4057
5032
|
continue
|
|
4058
|
-
|
|
4059
|
-
# Comments
|
|
4060
5033
|
if line.startswith("//", i):
|
|
4061
5034
|
tokens.append(("class:comment", line[i:]))
|
|
4062
5035
|
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
|
|
5036
|
+
if line.startswith("///", i):
|
|
5037
|
+
tokens.append(("class:comment", line[i:]))
|
|
5038
|
+
break
|
|
5039
|
+
if ch == "`":
|
|
5040
|
+
j = line.find("`", i + 1)
|
|
5041
|
+
j = len(line) - 1 if j < 0 else j
|
|
5042
|
+
tokens.append(("class:terminalcommand", line[i:j + 1]))
|
|
5043
|
+
i = j + 1
|
|
4094
5044
|
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
|
|
5045
|
+
if line.startswith("[[[", i) or line.startswith("]]]", i):
|
|
5046
|
+
marker = line[i:i + 3]
|
|
5047
|
+
tokens.append(("class:python", marker))
|
|
5048
|
+
i += 3
|
|
5049
|
+
continue
|
|
5050
|
+
if line.startswith("{{", i):
|
|
5051
|
+
j = line.find("}}", i + 2)
|
|
5052
|
+
j = len(line) if j < 0 else j + 2
|
|
5053
|
+
tokens.append(("class:interpolation", line[i:j]))
|
|
5054
|
+
i = j
|
|
5055
|
+
continue
|
|
5056
|
+
if ch in "'\"":
|
|
5057
|
+
quote, j = ch, i + 1
|
|
5058
|
+
while j < len(line):
|
|
5059
|
+
if line[j] == "\\":
|
|
5060
|
+
j += 2
|
|
4124
5061
|
continue
|
|
4125
|
-
if line[
|
|
4126
|
-
|
|
5062
|
+
if line[j] == quote:
|
|
5063
|
+
j += 1
|
|
4127
5064
|
break
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
5065
|
+
j += 1
|
|
5066
|
+
tokens.append(("class:string", line[i:j]))
|
|
5067
|
+
i = j
|
|
4131
5068
|
continue
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
5069
|
+
if ch == "@":
|
|
5070
|
+
match = re.match(r"@[A-Za-z_]\w*(?:\{[^}]*\})?", line[i:])
|
|
5071
|
+
if match:
|
|
5072
|
+
value = match.group(0)
|
|
5073
|
+
key = value[1:].split("{", 1)[0]
|
|
5074
|
+
style = "class:macro" if key in names["macros"] else "class:definition" if key in names["definitions"] else "class:operator"
|
|
5075
|
+
tokens.append((style, value))
|
|
5076
|
+
i += len(value)
|
|
5077
|
+
continue
|
|
5078
|
+
if ch.isdigit():
|
|
5079
|
+
match = re.match(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?", line[i:])
|
|
5080
|
+
value = match.group(0) #type:ignore
|
|
5081
|
+
tokens.append(("class:number", value))
|
|
5082
|
+
i += len(value)
|
|
4143
5083
|
continue
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
(
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
5084
|
+
if ch.isalpha() or ch == "_":
|
|
5085
|
+
match = re.match(r"[A-Za-z_]\w*", line[i:])
|
|
5086
|
+
word = match.group(0) #type:ignore
|
|
5087
|
+
folded = word.casefold()
|
|
5088
|
+
style = ""
|
|
5089
|
+
if declared_name and word == declared_name.split(".")[-1]:
|
|
5090
|
+
style = "class:" + declared_style #type:ignore
|
|
5091
|
+
declared_name = None
|
|
5092
|
+
elif not line[:i].strip():
|
|
5093
|
+
style = "class:command" if folded in self.COMMANDS else ""
|
|
5094
|
+
if not style:
|
|
5095
|
+
if word in names["structs"]:
|
|
5096
|
+
style = "class:struct"
|
|
5097
|
+
elif word in names["records"]:
|
|
5098
|
+
style = "class:record"
|
|
5099
|
+
elif word in names["functions"] or word in function_names:
|
|
5100
|
+
style = "class:function"
|
|
5101
|
+
elif word in names["macros"]:
|
|
5102
|
+
style = "class:macro"
|
|
5103
|
+
elif word in names["definitions"]:
|
|
5104
|
+
style = "class:definition"
|
|
5105
|
+
elif word in names["variables"]:
|
|
5106
|
+
style = "class:variable"
|
|
5107
|
+
elif folded in self.CONTROL_WORDS:
|
|
5108
|
+
style = "class:control"
|
|
5109
|
+
elif word in {"True", "False", "None"}:
|
|
5110
|
+
style = "class:number"
|
|
5111
|
+
elif word in {"print", "len", "str", "int", "float", "range", "enumerate", "zip", "list", "dict", "set", "tuple", "sum", "min", "max", "abs", "type", "isinstance"}:
|
|
5112
|
+
style = "class:python"
|
|
5113
|
+
elif word in SOURCES:
|
|
5114
|
+
style = "class:source"
|
|
5115
|
+
elif folded in self.CONDITIONS:
|
|
5116
|
+
style = "class:condition"
|
|
5117
|
+
elif folded in self.OPERATORS:
|
|
5118
|
+
style = "class:operator"
|
|
5119
|
+
elif word in elementKeys or word in moleculeKeys or word in reactionKeys:
|
|
5120
|
+
style = "class:key"
|
|
5121
|
+
tokens.append((style, word))
|
|
5122
|
+
i += len(word)
|
|
5123
|
+
continue
|
|
5124
|
+
operator = next((op for op in ("**", "//", "!=", ">=", "<=", "==", "+=", "-=", "*=", "/=", "->", "<->", "=", ">", "<", "+", "-", "*", "/", "%") if line.startswith(op, i)), None)
|
|
4157
5125
|
if operator:
|
|
4158
5126
|
tokens.append(("class:operator", operator))
|
|
4159
5127
|
i += len(operator)
|
|
4160
5128
|
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]))
|
|
5129
|
+
tokens.append(("", ch))
|
|
4205
5130
|
i += 1
|
|
4206
|
-
|
|
4207
5131
|
return tokens
|
|
4208
|
-
|
|
4209
5132
|
return get_line
|
|
4210
5133
|
|
|
4211
5134
|
|
|
4212
5135
|
class ChemqlCompleter(Completer):
|
|
4213
|
-
"""Context-aware completion for
|
|
5136
|
+
"""Context-aware completion for commands and dynamically created objects."""
|
|
4214
5137
|
|
|
4215
5138
|
COMMANDS = (
|
|
4216
|
-
"search", "source", "list", "clear", "return", "exit",
|
|
4217
|
-
"
|
|
4218
|
-
"
|
|
4219
|
-
"
|
|
4220
|
-
"
|
|
5139
|
+
"search", "source", "list", "clear", "return", "exit", "findel", "findmol",
|
|
5140
|
+
"findre", "set", "add", "remove", "react", "balance", "conditions", "version",
|
|
5141
|
+
"help", "view", "dump", "let", "unset", "variables", "func", "define", "struct",
|
|
5142
|
+
"record", "risk", "fix", "cleanup", "signal", "import", "definitions", "macros",
|
|
5143
|
+
"endfor", "endwhile", "endif", "endrisk", "endstruct", "endrecord", "endfunc", "enddefine",
|
|
4221
5144
|
)
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
5145
|
+
QUERY_OPERATORS = ["=", "!=", ">", "<", ">=", "<=", "like", "like!", "likep", "has", "sort", "limit", "count", "asc", "desc", "return", "and", "or", "union", "intersection"]
|
|
5146
|
+
CONTROL_COMPLETIONS = ["if ", "for ", "while ", "endfor", "endwhile", "endif", "risk", "fix", "cleanup", "endrisk", "struct ", "endstruct", "record ", "endrecord", "func ", "endfunc", "define ", "enddefine"]
|
|
5147
|
+
|
|
5148
|
+
@staticmethod
|
|
5149
|
+
def _names():
|
|
5150
|
+
variables = set(CHEMQL_VARIABLES) | {
|
|
5151
|
+
name for name in python_globals
|
|
5152
|
+
if isinstance(name, str) and not name.startswith("__") and name.isidentifier()
|
|
5153
|
+
}
|
|
5154
|
+
return {
|
|
5155
|
+
"variables": variables,
|
|
5156
|
+
"structs": set(STRUCTS),
|
|
5157
|
+
"records": set(RECORDS),
|
|
5158
|
+
"functions": set(FUNCTIONS),
|
|
5159
|
+
"macros": set(MACROS),
|
|
5160
|
+
"definitions": set(DEFINITIONS),
|
|
5161
|
+
}
|
|
4228
5162
|
|
|
4229
5163
|
def get_completions(self, document, complete_event):
|
|
4230
5164
|
text = document.text_before_cursor
|
|
4231
|
-
|
|
5165
|
+
stripped = text.lstrip()
|
|
5166
|
+
parts = stripped.split()
|
|
4232
5167
|
trailing_space = bool(text and text[-1].isspace())
|
|
4233
|
-
|
|
4234
5168
|
current = "" if trailing_space or not parts else parts[-1]
|
|
4235
5169
|
command_words = parts if trailing_space else parts[:-1]
|
|
5170
|
+
names = self._names()
|
|
4236
5171
|
|
|
4237
|
-
def emit(
|
|
5172
|
+
def emit(values):
|
|
4238
5173
|
seen = set()
|
|
4239
|
-
for
|
|
4240
|
-
|
|
4241
|
-
key =
|
|
4242
|
-
|
|
4243
|
-
if (
|
|
4244
|
-
key not in seen
|
|
4245
|
-
and key.startswith(current.casefold())
|
|
4246
|
-
):
|
|
5174
|
+
for value in values:
|
|
5175
|
+
value = str(value)
|
|
5176
|
+
key = value.casefold()
|
|
5177
|
+
if key not in seen and key.startswith(current.casefold()):
|
|
4247
5178
|
seen.add(key)
|
|
4248
|
-
yield Completion(
|
|
4249
|
-
suggestion,
|
|
4250
|
-
start_position=-len(current),
|
|
4251
|
-
)
|
|
5179
|
+
yield Completion(value, start_position=-len(current))
|
|
4252
5180
|
|
|
4253
|
-
|
|
5181
|
+
dynamic = sorted(set().union(*names.values()), key=str.casefold)
|
|
4254
5182
|
if not command_words:
|
|
4255
|
-
|
|
4256
|
-
suggestions.extend(
|
|
4257
|
-
sorted(CHEMQL_VARIABLES)
|
|
4258
|
-
)
|
|
4259
|
-
yield from emit(suggestions)
|
|
5183
|
+
yield from emit(list(self.COMMANDS) + self.CONTROL_COMPLETIONS + dynamic)
|
|
4260
5184
|
return
|
|
4261
5185
|
|
|
4262
|
-
command = command_words[0]
|
|
5186
|
+
command = command_words[0].casefold()
|
|
4263
5187
|
suggestions = []
|
|
4264
|
-
|
|
4265
|
-
# Native variable syntax
|
|
4266
5188
|
if command == "let":
|
|
4267
|
-
if len(command_words) == 1
|
|
4268
|
-
suggestions = ["name = value"]
|
|
4269
|
-
elif len(command_words) >= 2:
|
|
4270
|
-
suggestions = ["="]
|
|
4271
|
-
|
|
5189
|
+
suggestions = ["name = value"] if len(command_words) == 1 else ["="]
|
|
4272
5190
|
elif command == "unset":
|
|
4273
|
-
suggestions = sorted(
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
# Source names
|
|
4279
|
-
elif command in {"search", "source"}:
|
|
5191
|
+
suggestions = sorted(names["variables"])
|
|
5192
|
+
elif command == "source":
|
|
5193
|
+
suggestions = list(SOURCES)
|
|
5194
|
+
elif command == "search":
|
|
4280
5195
|
if len(command_words) == 1:
|
|
4281
5196
|
suggestions = list(SOURCES)
|
|
4282
|
-
elif
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
_, keys = SOURCES[source_name]
|
|
4286
|
-
suggestions = list(keys) + self.QUERY_OPERATORS
|
|
4287
|
-
|
|
4288
|
-
# Reaction settings
|
|
5197
|
+
elif len(command_words) >= 2 and command_words[1] in SOURCES:
|
|
5198
|
+
_, keys = SOURCES[command_words[1]]
|
|
5199
|
+
suggestions = list(keys) + self.QUERY_OPERATORS
|
|
4289
5200
|
elif command == "set":
|
|
4290
5201
|
if len(command_words) == 1:
|
|
4291
|
-
suggestions = [
|
|
4292
|
-
"temperature", "pressure", "catalysts"
|
|
4293
|
-
]
|
|
5202
|
+
suggestions = ["temperature", "pressure", "catalysts"]
|
|
4294
5203
|
elif len(command_words) == 2:
|
|
4295
5204
|
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
|
|
5205
|
+
suggestions = {
|
|
5206
|
+
"temperature": ["standard", "room", "0C", "0F", "0K"],
|
|
5207
|
+
"pressure": ["standard", "room", "0Pa", "0N/m^2", "0Nm^-2", "0bar", "1atm"],
|
|
5208
|
+
"catalysts": list(reaction_state["catalysts"]),
|
|
5209
|
+
}.get(setting, [])
|
|
4312
5210
|
elif command in {"add", "remove"}:
|
|
4313
5211
|
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
|
-
|
|
5212
|
+
suggestions = ["catalyst", "definition"] if command == "remove" else ["catalyst"]
|
|
5213
|
+
elif command == "remove" and command_words[1].casefold() == "catalyst":
|
|
5214
|
+
suggestions = list(reaction_state["catalysts"])
|
|
5215
|
+
elif command == "remove" and command_words[1].casefold() == "definition":
|
|
5216
|
+
suggestions = sorted(names["definitions"])
|
|
5217
|
+
elif command == "variables":
|
|
5218
|
+
suggestions = sorted(names["variables"])
|
|
5219
|
+
elif command == "definitions":
|
|
5220
|
+
suggestions = sorted(names["definitions"])
|
|
5221
|
+
elif command == "macros":
|
|
5222
|
+
suggestions = sorted(names["macros"])
|
|
5223
|
+
elif command == "func":
|
|
5224
|
+
suggestions = sorted(names["structs"])
|
|
5225
|
+
elif command == "struct":
|
|
5226
|
+
suggestions = sorted(names["structs"])
|
|
5227
|
+
elif command == "record":
|
|
5228
|
+
suggestions = sorted(names["records"])
|
|
5229
|
+
elif command in {"print", "return", "unset"}:
|
|
5230
|
+
suggestions = sorted(names["variables"])
|
|
5231
|
+
elif command in {"risk", "fix", "cleanup", "endrisk", "endstruct", "endrecord", "endfunc", "enddefine", "endfor", "endwhile", "endif"}:
|
|
5232
|
+
suggestions = self.CONTROL_COMPLETIONS
|
|
5233
|
+
|
|
5234
|
+
# In expressions, Python blocks, attribute access, and function calls,
|
|
5235
|
+
# offer all user-defined symbols, not just built-in commands.
|
|
5236
|
+
if not suggestions or "{{" in text or text.lstrip().startswith("[") or "." in current:
|
|
5237
|
+
suggestions += dynamic
|
|
4335
5238
|
yield from emit(suggestions)
|
|
4336
5239
|
|
|
4337
5240
|
|
|
4338
|
-
|
|
4339
5241
|
chemql_style = Style.from_dict({
|
|
4340
5242
|
"command": "ansicyan",
|
|
4341
5243
|
"terminalcommand": "ansipurple",
|
|
@@ -4348,11 +5250,16 @@ chemql_style = Style.from_dict({
|
|
|
4348
5250
|
"control": "ansired",
|
|
4349
5251
|
"python": "ansibrightcyan",
|
|
4350
5252
|
"condition": "ansibrightyellow",
|
|
5253
|
+
"variable": "ansibrightgreen",
|
|
5254
|
+
"struct": "ansibrightblue bold",
|
|
5255
|
+
"record": "ansicyan bold",
|
|
5256
|
+
"function": "ansiyellow bold",
|
|
5257
|
+
"macro": "ansimagenta bold",
|
|
5258
|
+
"definition": "ansibrightmagenta",
|
|
5259
|
+
"interpolation": "ansibrightcyan italic",
|
|
4351
5260
|
})
|
|
4352
5261
|
|
|
4353
5262
|
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
5263
|
class _ChemQLField:
|
|
4357
5264
|
"""Field selector used by the fluent Python query API."""
|
|
4358
5265
|
def __init__(self, query, field):
|
|
@@ -4495,6 +5402,83 @@ ChemQLQuery.__name__ = "ChemQLQuery"
|
|
|
4495
5402
|
ChemQLQuery.__qualname__ = "ChemQLQuery"
|
|
4496
5403
|
|
|
4497
5404
|
|
|
5405
|
+
|
|
5406
|
+
def _unclosed_expression_delimiters(lines):
|
|
5407
|
+
"""Return expected closing delimiters for an unfinished expression.
|
|
5408
|
+
|
|
5409
|
+
This scanner is intentionally for REPL continuation only. It recognizes
|
|
5410
|
+
ChemQL's {{...}} query delimiters before ordinary braces and ignores
|
|
5411
|
+
delimiters inside quoted strings and // comments. The dedicated [[[...]]]
|
|
5412
|
+
block is handled separately by the REPL and must not be treated as a stack
|
|
5413
|
+
of ordinary square brackets.
|
|
5414
|
+
"""
|
|
5415
|
+
expected = []
|
|
5416
|
+
quote = None
|
|
5417
|
+
escaped = False
|
|
5418
|
+
openers = ("{{", "{", "(", "[")
|
|
5419
|
+
closers = ("}}", "}", ")", "]")
|
|
5420
|
+
|
|
5421
|
+
for line in lines:
|
|
5422
|
+
i = 0
|
|
5423
|
+
while i < len(line):
|
|
5424
|
+
ch = line[i]
|
|
5425
|
+
|
|
5426
|
+
if quote is not None:
|
|
5427
|
+
if escaped:
|
|
5428
|
+
escaped = False
|
|
5429
|
+
elif ch == "\\":
|
|
5430
|
+
escaped = True
|
|
5431
|
+
elif ch == quote:
|
|
5432
|
+
quote = None
|
|
5433
|
+
i += 1
|
|
5434
|
+
continue
|
|
5435
|
+
|
|
5436
|
+
if ch in ("'", '"'):
|
|
5437
|
+
quote = ch
|
|
5438
|
+
i += 1
|
|
5439
|
+
continue
|
|
5440
|
+
|
|
5441
|
+
# ChemQL's single-line comments must not affect delimiter balance.
|
|
5442
|
+
if line.startswith("//", i):
|
|
5443
|
+
break
|
|
5444
|
+
|
|
5445
|
+
# The triple-bracket multiline Python form is managed elsewhere.
|
|
5446
|
+
if line.startswith("[[[", i):
|
|
5447
|
+
i += 3
|
|
5448
|
+
continue
|
|
5449
|
+
if line.startswith("]]]", i):
|
|
5450
|
+
i += 3
|
|
5451
|
+
continue
|
|
5452
|
+
|
|
5453
|
+
token = next((t for t in openers if line.startswith(t, i)), None)
|
|
5454
|
+
if token is not None:
|
|
5455
|
+
expected.append({"{{": "}}", "{": "}", "(": ")", "[": "]"}[token])
|
|
5456
|
+
i += len(token)
|
|
5457
|
+
continue
|
|
5458
|
+
|
|
5459
|
+
token = next((t for t in closers if line.startswith(t, i)), None)
|
|
5460
|
+
if token is not None:
|
|
5461
|
+
if expected:
|
|
5462
|
+
if expected[-1] != token:
|
|
5463
|
+
raise SyntaxError(
|
|
5464
|
+
f"Mismatched delimiter: expected {expected[-1]!r}, got {token!r}"
|
|
5465
|
+
)
|
|
5466
|
+
expected.pop()
|
|
5467
|
+
else:
|
|
5468
|
+
# An unmatched closer belongs to the normal ChemQL parser
|
|
5469
|
+
# to diagnose; it should not cause continuation prompting.
|
|
5470
|
+
return []
|
|
5471
|
+
i += len(token)
|
|
5472
|
+
continue
|
|
5473
|
+
|
|
5474
|
+
i += 1
|
|
5475
|
+
|
|
5476
|
+
# A single-line string cannot legitimately span REPL input lines in
|
|
5477
|
+
# ordinary ChemQL expressions; retain quote state only for completeness.
|
|
5478
|
+
|
|
5479
|
+
return expected
|
|
5480
|
+
|
|
5481
|
+
|
|
4498
5482
|
def main():
|
|
4499
5483
|
parser = argparse.ArgumentParser(
|
|
4500
5484
|
description="Chemql query and chemistry language"
|
|
@@ -4619,17 +5603,40 @@ def run_interactive():
|
|
|
4619
5603
|
if "///" in first:
|
|
4620
5604
|
return sum(line.count("///") for line in source_lines) < 2
|
|
4621
5605
|
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
5606
|
+
# Track every block that requires a closing keyword. Named declarations
|
|
5607
|
+
# such as `struct Fruit` and `record Point` are headers too.
|
|
5608
|
+
stack = []
|
|
5609
|
+
closers = {
|
|
5610
|
+
"endrisk": "risk", "endstruct": "struct", "endrecord": "record",
|
|
5611
|
+
"endfunc": "func", "enddefine": "macro", "endfor": "for",
|
|
5612
|
+
"endwhile": "while", "endif": "if", "[endblock]": "bracket",
|
|
5613
|
+
}
|
|
5614
|
+
for source_line in source_lines:
|
|
5615
|
+
stripped = source_line.strip()
|
|
5616
|
+
folded = stripped.casefold()
|
|
5617
|
+
if not stripped or stripped.startswith("//"):
|
|
5618
|
+
continue
|
|
4631
5619
|
|
|
4632
|
-
|
|
5620
|
+
if re.fullmatch(r"struct\s+[A-Za-z_]\w*", stripped, re.IGNORECASE):
|
|
5621
|
+
stack.append("struct")
|
|
5622
|
+
elif re.fullmatch(r"record\s+[A-Za-z_]\w*", stripped, re.IGNORECASE):
|
|
5623
|
+
stack.append("record")
|
|
5624
|
+
elif re.fullmatch(r"func\s+[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?", stripped, re.IGNORECASE):
|
|
5625
|
+
stack.append("func")
|
|
5626
|
+
elif re.fullmatch(r"define\s+[A-Za-z_]\w*\s*\{\s*\}", stripped, re.IGNORECASE):
|
|
5627
|
+
stack.append("macro")
|
|
5628
|
+
elif folded == "risk":
|
|
5629
|
+
stack.append("risk")
|
|
5630
|
+
elif re.match(r"^(for|while|if)\s+.+$", stripped, re.IGNORECASE) and not stripped.startswith("["):
|
|
5631
|
+
stack.append(folded.split(None, 1)[0])
|
|
5632
|
+
elif is_python_block_header(stripped):
|
|
5633
|
+
stack.append("bracket")
|
|
5634
|
+
elif folded in closers:
|
|
5635
|
+
expected = closers[folded]
|
|
5636
|
+
if stack and stack[-1] == expected:
|
|
5637
|
+
stack.pop()
|
|
5638
|
+
|
|
5639
|
+
return bool(stack)
|
|
4633
5640
|
|
|
4634
5641
|
@bindings.add(Keys.Enter)
|
|
4635
5642
|
def _handle_enter(event):
|
|
@@ -4678,7 +5685,7 @@ def run_interactive():
|
|
|
4678
5685
|
try:
|
|
4679
5686
|
print(_dump_history(first_line.strip()))
|
|
4680
5687
|
except Exception as exc:
|
|
4681
|
-
print(f"Error: {exc}")
|
|
5688
|
+
print(f"Error: {exc}" if NO_ANSI else "\033[31m" + f"Error: {exc}" + "\033[0m")
|
|
4682
5689
|
continue
|
|
4683
5690
|
|
|
4684
5691
|
if not first_line.strip():
|
|
@@ -4757,6 +5764,44 @@ def run_interactive():
|
|
|
4757
5764
|
if not lines:
|
|
4758
5765
|
continue
|
|
4759
5766
|
|
|
5767
|
+
# Collect multiline expressions only when a physical line ends
|
|
5768
|
+
# with an expression opener. Structured constructs are handled by
|
|
5769
|
+
# their existing collectors above/below and must retain newlines.
|
|
5770
|
+
structured_start = bool(lines) and (
|
|
5771
|
+
lines[0].strip() == "[[["
|
|
5772
|
+
or lines[0].strip().startswith(("```", "func ", "define "))
|
|
5773
|
+
or "///" in lines[0]
|
|
5774
|
+
or is_python_block_header(lines[0].strip())
|
|
5775
|
+
)
|
|
5776
|
+
opens_at_line_end = bool(lines) and any(
|
|
5777
|
+
line.rstrip().endswith(("{{", "{", "(", "["))
|
|
5778
|
+
for line in lines
|
|
5779
|
+
)
|
|
5780
|
+
if lines and not structured_start and opens_at_line_end:
|
|
5781
|
+
try:
|
|
5782
|
+
pending = _unclosed_expression_delimiters(lines)
|
|
5783
|
+
while pending:
|
|
5784
|
+
next_line = session.prompt("... ")
|
|
5785
|
+
lines.extend(next_line.splitlines() or [next_line])
|
|
5786
|
+
pending = _unclosed_expression_delimiters(lines)
|
|
5787
|
+
if len(lines) > 1:
|
|
5788
|
+
# These delimiters represent one expression: collapse
|
|
5789
|
+
# physical lines before handing it to ChemQL evaluation.
|
|
5790
|
+
lines = [" ".join(part.strip() for part in lines if part.strip())]
|
|
5791
|
+
except KeyboardInterrupt:
|
|
5792
|
+
print()
|
|
5793
|
+
lines = []
|
|
5794
|
+
except EOFError:
|
|
5795
|
+
message = "Error: Incomplete expression; missing closing delimiter"
|
|
5796
|
+
print("\n" + (message if NO_ANSI else "\033[31m" + message + "\033[0m"))
|
|
5797
|
+
lines = []
|
|
5798
|
+
except SyntaxError as exc:
|
|
5799
|
+
message = f"Error: {exc}"
|
|
5800
|
+
print(message if NO_ANSI else f"\033[31m{message}\033[0m")
|
|
5801
|
+
lines = []
|
|
5802
|
+
if not lines:
|
|
5803
|
+
continue
|
|
5804
|
+
|
|
4760
5805
|
# Collect a Python control block only if the single editable buffer
|
|
4761
5806
|
# was submitted before its matching [endblock] was entered.
|
|
4762
5807
|
if lines and is_python_block_header(lines[0].strip()):
|