chemql-python 1.2.5__py3-none-any.whl → 1.3.0__py3-none-any.whl

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