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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
chemql/__init__.py 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.6"
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,15 +268,27 @@ 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()
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 top-level else branch.
246
- else_index = next(
247
- (
248
- j
249
- for j, source_line in enumerate(block_body)
250
- if source_line.strip() == "[else]"
251
- ),
252
- None,
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
- 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())
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
- reactant_text = " + ".join(
1740
- f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
1741
- for item in reactants
1742
- )
1743
- product_text = " + ".join(
1744
- f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
1745
- for item in products
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
- product_energy = sum(
1756
- molecule_bond_energy(item["molecule"]) * item["stoichiometric_coefficient"]
1967
+ product_text = " + ".join(
1968
+ f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
1757
1969
  for item in products
1758
1970
  )
1759
- total_energy = reactant_energy - product_energy
1760
- self.equation += f"\tΔH={total_energy}kJ/mol"
1761
- if self.conditions:
1762
- self.equation += "\nConditions:\n"
1763
- for i in self.conditions:
1764
- 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"
1765
1989
  def __str__(self):
1766
1990
  return self.equation
1767
1991
  def __repr__(self):
@@ -1769,6 +1993,284 @@ class Reaction:
1769
1993
 
1770
1994
 
1771
1995
  REACTIONS = [Reaction(item["id"], item["name"], item["reaction_type"], item["reversible"], item["reactants"], item["products"], item["conditions"], item["notes"], item.get("source", "")) for item in reactions]
1996
+
1997
+
1998
+ def find_molecule(value):
1999
+ """Find a built-in molecule by name, formula, or IUPAC name."""
2000
+ return _find_local_molecule(value)
2001
+
2002
+
2003
+ def find_reaction(value, external=True):
2004
+ """Find a local reaction first, then optionally query Rhea and Chempirical."""
2005
+ target = str(value).strip()
2006
+ folded = target.casefold()
2007
+ if not folded:
2008
+ return None
2009
+ for reaction in REACTIONS:
2010
+ if str(reaction.id).casefold() == folded:
2011
+ return reaction
2012
+ local = next((r for r in REACTIONS if folded in r.name.casefold() or folded in r.equation.casefold()), None)
2013
+ if local is not None or not external:
2014
+ return local
2015
+ # External sources are strictly a fallback: the bundled Reactions.json remains
2016
+ # the first source and all existing offline searches continue to work.
2017
+ for lookup in (_fetch_chempirical_reaction, _fetch_rhea_reaction):
2018
+ try:
2019
+ found = lookup(target)
2020
+ except Exception:
2021
+ found = None
2022
+ if found is not None:
2023
+ return found
2024
+ return None
2025
+
2026
+
2027
+ REACTION_API_TIMEOUT = 8
2028
+ RHEA_API_URL = "https://www.rhea-db.org/rhea/"
2029
+ CHEMPIRICAL_API_URL = "https://chempirical.com/api/reactions"
2030
+
2031
+
2032
+ def _reaction_api_json(url, timeout=REACTION_API_TIMEOUT):
2033
+ """Fetch a JSON API response; failures are non-fatal for optional lookups."""
2034
+ request = urllib.request.Request(
2035
+ url,
2036
+ headers={
2037
+ "User-Agent": "ChemQL/1.3.0 (reaction lookup; contact: chemql-user)",
2038
+ "Accept": "application/json",
2039
+ },
2040
+ )
2041
+ try:
2042
+ with urllib.request.urlopen(request, timeout=timeout) as response:
2043
+ return json.loads(response.read().decode("utf-8", errors="replace"))
2044
+ except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError, ValueError):
2045
+ return None
2046
+
2047
+
2048
+ def _reaction_api_text(url, timeout=REACTION_API_TIMEOUT):
2049
+ """Fetch plain-text (TSV) data from Rhea."""
2050
+ request = urllib.request.Request(
2051
+ url,
2052
+ headers={
2053
+ "User-Agent": "ChemQL/1.3.0 (reaction lookup; contact: chemql-user)",
2054
+ "Accept": "text/tab-separated-values,text/plain,*/*",
2055
+ },
2056
+ )
2057
+ try:
2058
+ with urllib.request.urlopen(request, timeout=timeout) as response:
2059
+ return response.read().decode("utf-8", errors="replace")
2060
+ except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError):
2061
+ return ""
2062
+
2063
+
2064
+ def _remote_reaction(reaction_id, name, equation, reaction_type, source, notes="", conditions=None, reversible=None, reactants=None, products=None):
2065
+ """Adapt a remote reaction into ChemQL's Reaction type with participant data."""
2066
+ if not name and not equation:
2067
+ return None
2068
+ reactants = reactants if isinstance(reactants, list) else []
2069
+ products = products if isinstance(products, list) else []
2070
+ if reversible is None and equation:
2071
+ reversible = "⇌" in equation or "<=>" in equation or "<->" in equation
2072
+ return Reaction(
2073
+ str(reaction_id or f"{source}:unknown"),
2074
+ str(name or reaction_id or "External reaction"),
2075
+ str(reaction_type or "Unknown"),
2076
+ reversible,
2077
+ reactants,
2078
+ products,
2079
+ conditions if isinstance(conditions, dict) else {},
2080
+ str(notes or ""),
2081
+ str(source),
2082
+ equation=str(equation or "Equation unavailable"),
2083
+ )
2084
+
2085
+
2086
+ def _chempirical_participant(item):
2087
+ """Convert a Chempirical participant to ChemQL's participant shape."""
2088
+ if not isinstance(item, dict):
2089
+ return None
2090
+ molecule = item.get("formula") or item.get("chemical_formula") or item.get("name") or item.get("molecule")
2091
+ if not molecule:
2092
+ return None
2093
+ coefficient = item.get("stoichiometry", item.get("coefficient", item.get("equivalents", 1)))
2094
+ try:
2095
+ coefficient = float(coefficient)
2096
+ if coefficient.is_integer():
2097
+ coefficient = int(coefficient)
2098
+ except (TypeError, ValueError):
2099
+ coefficient = 1
2100
+ return {"molecule": str(molecule), "stoichiometric_coefficient": coefficient}
2101
+
2102
+
2103
+ def _fetch_chempirical_reaction(value):
2104
+ """Search Chempirical, requiring a relevant, reaction-like result."""
2105
+ url = CHEMPIRICAL_API_URL + "?" + urllib.parse.urlencode({"q": value, "limit": 10, "offset": 0})
2106
+ data = _reaction_api_json(url)
2107
+ if not isinstance(data, dict):
2108
+ return None
2109
+ results = data.get("results", [])
2110
+ if not isinstance(results, list) or not results:
2111
+ return None
2112
+ target = str(value).strip().casefold()
2113
+
2114
+ # Never accept the API's first result blindly. Require the query to match a
2115
+ # meaningful reaction field, then prefer exact name/slug matches.
2116
+ candidates = [item for item in results if isinstance(item, dict)]
2117
+ def fields(item):
2118
+ return [str(item.get(key, "")).casefold() for key in ("name", "slug", "equation", "summary", "reaction_type")]
2119
+ relevant = [item for item in candidates if any(target in field for field in fields(item) if field)]
2120
+ if not relevant:
2121
+ return None
2122
+ row = next((item for item in relevant if target in {
2123
+ str(item.get("name", "")).casefold(), str(item.get("slug", "")).casefold()
2124
+ }), relevant[0])
2125
+ slug = row.get("slug")
2126
+ detail = _reaction_api_json(CHEMPIRICAL_API_URL + "/" + urllib.parse.quote(str(slug), safe="")) if slug else None
2127
+ record = detail if isinstance(detail, dict) else row
2128
+ participants = record.get("participants", [])
2129
+ if not isinstance(participants, list):
2130
+ participants = []
2131
+ reactants = []
2132
+ products = []
2133
+ for participant in participants:
2134
+ if not isinstance(participant, dict):
2135
+ continue
2136
+ role = str(participant.get("role", "")).casefold()
2137
+ converted = _chempirical_participant(participant)
2138
+ if converted is None:
2139
+ continue
2140
+ if role in {"reactant", "reagent", "substrate"}:
2141
+ reactants.append(converted)
2142
+ elif role in {"product", "products"}:
2143
+ products.append(converted)
2144
+
2145
+ equation = str(record.get("equation") or row.get("equation") or "").strip()
2146
+ if not equation and reactants and products:
2147
+ def term(item):
2148
+ coeff = item["stoichiometric_coefficient"]
2149
+ return (f"{coeff} " if coeff != 1 else "") + item["molecule"]
2150
+ equation = " + ".join(term(item) for item in reactants) + " → " + " + ".join(term(item) for item in products)
2151
+ # Some API responses provide a display equation but omit structured
2152
+ # participants. Parse the equation as a fallback so the object is useful.
2153
+ if equation and (not reactants or not products):
2154
+ parsed_reactants, parsed_products = _parse_remote_equation(equation)
2155
+ if not reactants:
2156
+ reactants = parsed_reactants
2157
+ if not products:
2158
+ products = parsed_products
2159
+
2160
+ # An external hit must actually describe a reaction. Reject records that
2161
+ # only have a product/name (which caused outputs like "→ (S)-1-Phenylpropanol").
2162
+ has_both_sides = bool(reactants and products)
2163
+ if not has_both_sides and equation:
2164
+ arrow = next((a for a in ("⇌", "<=>", "<->", "→", "->", "=") if a in equation), None)
2165
+ if arrow:
2166
+ left, right = equation.split(arrow, 1)
2167
+ has_both_sides = bool(left.strip() and right.strip())
2168
+ if not has_both_sides:
2169
+ return None
2170
+
2171
+ conditions = record.get("conditions", {})
2172
+ if isinstance(conditions, list):
2173
+ conditions = {f"condition_{i + 1}": item for i, item in enumerate(conditions)}
2174
+ notes = record.get("summary", row.get("summary", ""))
2175
+ source = record.get("source", row.get("source", "Chempirical"))
2176
+ if isinstance(source, dict):
2177
+ source = source.get("name") or source.get("label") or "Chempirical"
2178
+ return _remote_reaction(
2179
+ record.get("slug") or row.get("slug"), record.get("name") or row.get("name"),
2180
+ equation, record.get("reaction_type") or row.get("reaction_type"),
2181
+ "Chempirical: " + str(source or "Chempirical"), notes, conditions,
2182
+ reactants=reactants, products=products,
2183
+ )
2184
+
2185
+
2186
+ def _parse_remote_equation(equation):
2187
+ """Best-effort participant extraction from a plain-text chemical equation."""
2188
+ if not equation:
2189
+ return [], []
2190
+ arrow = next((a for a in ("⇌", "<=>", "<->", "→", "->", "=", "⇄") if a in equation), None)
2191
+ if not arrow:
2192
+ return [], []
2193
+ left, right = equation.split(arrow, 1)
2194
+ def parse_side(side):
2195
+ output = []
2196
+ for raw_term in re.split(r"\s+\+\s+", side.strip()):
2197
+ term = raw_term.strip()
2198
+ if not term:
2199
+ continue
2200
+ match = re.match(r"^([0-9]+(?:\.[0-9]+)?)\s+(.+)$", term)
2201
+ if match:
2202
+ coefficient, molecule = match.groups()
2203
+ number = float(coefficient)
2204
+ if number.is_integer():
2205
+ number = int(number)
2206
+ else:
2207
+ number, molecule = 1, term
2208
+ output.append({"molecule": molecule.strip(), "stoichiometric_coefficient": number})
2209
+ return output
2210
+ return parse_side(left), parse_side(right)
2211
+
2212
+
2213
+ def _fetch_rhea_reaction(value):
2214
+ """Search Rhea's TSV REST endpoint and only accept a relevant result."""
2215
+ target = value.strip()
2216
+ if target.casefold().startswith("rhea:"):
2217
+ query = "rhea-id:" + target.split(":", 1)[1]
2218
+ else:
2219
+ # Rhea supports fielded queries; a general query is useful for equation or
2220
+ # participant-name searches and keeps all query escaping in urlencode.
2221
+ query = target
2222
+ params = urllib.parse.urlencode({
2223
+ "query": query,
2224
+ "columns": "rhea-id,equation,chebi,ec",
2225
+ "format": "tsv",
2226
+ "limit": 5,
2227
+ })
2228
+ text = _reaction_api_text(RHEA_API_URL + "?" + params)
2229
+ lines = [line for line in text.splitlines() if line.strip()]
2230
+ if len(lines) < 2:
2231
+ return None
2232
+ headers = lines[0].split("\t")
2233
+ rows = [dict(zip(headers, line.split("\t"))) for line in lines[1:]]
2234
+ target_folded = target.casefold()
2235
+ row = next((item for item in rows if item.get("rhea-id", "").casefold() == target_folded), None)
2236
+ if row is None:
2237
+ row = next((item for item in rows if any(
2238
+ target_folded in item.get(field, "").casefold()
2239
+ for field in ("equation", "chebi", "ec")
2240
+ )), None)
2241
+ if row is None:
2242
+ return None
2243
+ reaction_id = row.get("rhea-id", "")
2244
+ ec = row.get("ec", "")
2245
+ chebi = row.get("chebi", "")
2246
+ notes = ""
2247
+ if ec:
2248
+ notes += "EC: " + ec
2249
+ if chebi:
2250
+ notes += ("; " if notes else "") + "Participants: " + chebi
2251
+ equation = row.get("equation", "")
2252
+ reactants, products = _parse_remote_equation(equation)
2253
+ if not reactants or not products:
2254
+ return None
2255
+ return _remote_reaction(
2256
+ reaction_id, reaction_id, equation, "Biochemical", "Rhea", notes,
2257
+ reactants=reactants, products=products,
2258
+ )
2259
+
2260
+
2261
+ def search_reactions(**criteria):
2262
+ """Search reactions by fields; string criteria are case-insensitive substrings."""
2263
+ unknown = set(criteria) - set(reactionKeys)
2264
+ if unknown:
2265
+ raise ValueError("Unknown reaction field(s): " + ", ".join(sorted(unknown)))
2266
+ result = []
2267
+ for reaction in REACTIONS:
2268
+ if all((str(expected).casefold() in str(getattr(reaction, key)).casefold())
2269
+ if isinstance(expected, str) and isinstance(getattr(reaction, key), str)
2270
+ else getattr(reaction, key) == expected for key, expected in criteria.items()):
2271
+ result.append(reaction)
2272
+ return result
2273
+
1772
2274
  reactionKeys = ("id", "name", "equation", "reaction_type", "reversible", "reactants", "products", "conditions", "notes", "source")
1773
2275
  del elements
1774
2276
  del reactions
@@ -2193,14 +2695,44 @@ def parse_condition(tokens, index, source, keys, positions=None):
2193
2695
  return (lambda item, fn=like_condition: not fn(item)) if negated else like_condition, consumed
2194
2696
 
2195
2697
  if operator == "has":
2196
- def has_condition(item):
2197
- 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
+ )
2198
2720
 
2199
- try:
2200
- return value in actual
2201
- 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:
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
- # 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.
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 [endblock], allowing nested blocks."""
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
- # Closing block.
2713
- 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]"}:
2714
3263
  depth -= 1
2715
-
2716
3264
  if depth == 0:
2717
3265
  return i
2718
-
2719
3266
  continue
2720
3267
 
2721
- # Branch markers do not start new blocks.
2722
- if stripped in ("[else]", "[endif]", "[endfor]", "[endwhile]"):
3268
+ if stripped in {"[else]"} or stripped.startswith("[elif "):
2723
3269
  continue
2724
3270
 
2725
- # Recognize both validated Python headers and generic
2726
- # ChemQL block headers, consistently with _call_function().
2727
- generic_header = re.match(
2728
- r"^\[\s*(?!endblock\b|else\b|elif\b)"
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[1:]
3004
- if category not in {"all", "good", "bad"} or mode not in {"queries", "results"}:
3005
- 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
+ )
3006
3551
  elif len(parts) == 3 and parts[1] in {"all", "good", "bad"}:
3007
- category, mode, filename = parts[1], "both", parts[2]
3552
+ _, category, filename = parts
3553
+ mode = "both"
3008
3554
  else:
3009
- raise ChemQLError("Usage: dump [all|good|bad] [queries|results] FILE")
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 = [record for record in records if record["success"]]
3562
+ records = [r for r in records if r.get("success") is True]
3014
3563
  elif category == "bad":
3015
- records = [record for record in records if not record["success"]]
3564
+ records = [r for r in records if r.get("success") is False]
3016
3565
 
3017
3566
  if mode == "queries":
3018
- content = "\n".join(record["query"] for record in records)
3567
+ lines = [str(r["query"]) for r in records]
3568
+ content = "\n".join(lines)
3019
3569
  elif mode == "results":
3020
- content = "\n".join(record["result"] for record in records)
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{record['query']}\nRESULT:\n{record['result']}"
3024
- for record in records
3574
+ f"QUERY:\n{r['query']}\nRESULT:\n{r['result']}"
3575
+ for r in records
3025
3576
  )
3026
3577
 
3027
- 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")
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
- value = evaluate_python(expression)
3049
- python_globals[name] = value
3050
- 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)
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
- # Everything else is query language.
3097
- 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
3098
3710
 
3099
3711
 
3100
3712
  def join_continuations(lines):
@@ -3150,6 +3762,27 @@ def _find_unquoted_marker(text, marker, start=0):
3150
3762
  def process_lines(lines):
3151
3763
  """Process ChemQL source, including multiline Python, terminal, and comments."""
3152
3764
  lines = join_continuations(list(lines))
3765
+ # Natural headers (for/if/while) and typed terminators are accepted in v1.3.
3766
+ normalized_lines = []
3767
+ in_python_block = False
3768
+ for source_line in lines:
3769
+ stripped_source = source_line.strip()
3770
+ if stripped_source == "[[[":
3771
+ in_python_block = True
3772
+ normalized_lines.append(source_line)
3773
+ continue
3774
+ if stripped_source == "]]]":
3775
+ in_python_block = False
3776
+ normalized_lines.append(source_line)
3777
+ continue
3778
+ if not in_python_block:
3779
+ if re.match(r"^(for|while|if)\s+.+$", stripped_source) and not stripped_source.startswith("["):
3780
+ # Existing block parser supplies the trailing colon to Python's AST.
3781
+ source_line = "[" + stripped_source + "]"
3782
+ elif stripped_source.casefold() in {"endfor", "endwhile", "endif"}:
3783
+ source_line = "[endblock]"
3784
+ normalized_lines.append(source_line)
3785
+ lines = normalized_lines
3153
3786
  i = 0
3154
3787
 
3155
3788
  while i < len(lines):
@@ -3160,6 +3793,110 @@ def process_lines(lines):
3160
3793
  i += 1
3161
3794
  continue
3162
3795
 
3796
+ type_match = re.fullmatch(r"(struct|record)\s+([A-Za-z_]\w*)", stripped, re.IGNORECASE)
3797
+ if type_match:
3798
+ kind, type_name = type_match.group(1).casefold(), type_match.group(2)
3799
+ terminator = "endstruct" if kind == "struct" else "endrecord"
3800
+ end = i + 1
3801
+ while end < len(lines) and lines[end].strip().casefold() != terminator:
3802
+ end += 1
3803
+ if end >= len(lines):
3804
+ raise SyntaxError(f"Missing `{terminator}` for {kind} `{type_name}`")
3805
+ fields, defaults = [], {}
3806
+ for field_line in lines[i + 1:end]:
3807
+ field_line = remove_comments(field_line).strip()
3808
+ if not field_line:
3809
+ continue
3810
+ match = re.fullmatch(r"([A-Za-z_]\w*)(?:\s*=\s*(.+))?", field_line)
3811
+ if not match:
3812
+ raise SyntaxError(f"Invalid {kind} field declaration: {field_line}")
3813
+ field, default = match.groups()
3814
+ if field in fields:
3815
+ raise SyntaxError(f"Duplicate field `{field}` in {type_name}")
3816
+ fields.append(field)
3817
+ if default is not None:
3818
+ defaults[field] = evaluate_python(_expand_definitions(default))
3819
+ if not fields:
3820
+ raise SyntaxError(f"{kind.title()} `{type_name}` must declare fields")
3821
+ if kind == "struct":
3822
+ def make_struct(class_name, field_names, field_defaults):
3823
+ def __init__(self, *args, **kwargs):
3824
+ if len(args) > len(field_names):
3825
+ raise TypeError(f"{class_name} expected at most {len(field_names)} positional arguments")
3826
+ values = dict(zip(field_names, args))
3827
+ overlap = values.keys() & kwargs.keys()
3828
+ if overlap:
3829
+ raise TypeError(f"Multiple values for field `{next(iter(overlap))}`")
3830
+ values.update(kwargs)
3831
+ extra = values.keys() - set(field_names)
3832
+ if extra:
3833
+ raise TypeError(f"Unexpected field(s): {', '.join(sorted(extra))}")
3834
+ for field_name in field_names:
3835
+ setattr(self, field_name, values.get(field_name, field_defaults.get(field_name)))
3836
+ def __repr__(self):
3837
+ return class_name + "{ " + ", ".join(f"{f}={getattr(self, f)!r}" for f in field_names) + " }"
3838
+ def __eq__(self, other):
3839
+ return type(other) is type(self) and all(getattr(self, f) == getattr(other, f) for f in field_names)
3840
+ return type(class_name, (), {"__init__": __init__, "__repr__": __repr__, "__str__": __repr__, "__eq__": __eq__, "__slots__": tuple(field_names)})
3841
+ generated = make_struct(type_name, fields, defaults)
3842
+ STRUCTS[type_name] = generated
3843
+ else:
3844
+ from collections import namedtuple
3845
+ first_default = next((n for n, f in enumerate(fields) if f in defaults), len(fields))
3846
+ if any(f not in defaults for f in fields[first_default:]):
3847
+ raise SyntaxError(f"Record `{type_name}` fields with defaults must be trailing fields")
3848
+ generated = namedtuple(type_name, fields, defaults=[defaults[f] for f in fields[first_default:]])
3849
+ generated.__str__ = lambda self, _name=type_name: _name + " -> " + ", ".join(f"{f}: {getattr(self, f)}" for f in self._fields)
3850
+ RECORDS[type_name] = generated
3851
+ python_globals[type_name] = generated
3852
+ i = end + 1
3853
+ continue
3854
+
3855
+ if stripped.casefold() == "risk":
3856
+ end = i + 1
3857
+ nesting = 1
3858
+ while end < len(lines):
3859
+ token = lines[end].strip().casefold()
3860
+ if token == "risk": nesting += 1
3861
+ elif token == "endrisk":
3862
+ nesting -= 1
3863
+ if nesting == 0: break
3864
+ end += 1
3865
+ if end >= len(lines):
3866
+ raise SyntaxError("Missing `endrisk`")
3867
+ sections = {"risk": [], "fix": [], "cleanup": []}
3868
+ section = "risk"
3869
+ for risk_line in lines[i + 1:end]:
3870
+ marker = risk_line.strip().casefold()
3871
+ if marker in {"fix", "cleanup"}:
3872
+ section = marker
3873
+ else:
3874
+ sections[section].append(risk_line)
3875
+ global _CHEMQL_RISK_DEPTH
3876
+ _CHEMQL_RISK_DEPTH += 1
3877
+ try:
3878
+ try:
3879
+ process_lines(sections["risk"])
3880
+ python_globals["error"] = None
3881
+ except Exception as exc:
3882
+ python_globals["error"] = exc.error_type if isinstance(exc, ChemQLSignal) else type(exc).__name__
3883
+ if sections["fix"]:
3884
+ process_lines(sections["fix"])
3885
+ elif not isinstance(exc, ChemQLSignal):
3886
+ raise
3887
+ finally:
3888
+ try:
3889
+ if sections["cleanup"]:
3890
+ process_lines(sections["cleanup"])
3891
+ finally:
3892
+ _CHEMQL_RISK_DEPTH = max(0, _CHEMQL_RISK_DEPTH - 1)
3893
+ i = end + 1
3894
+ continue
3895
+
3896
+ signal_match = re.fullmatch(r"signal\s+(.+)", stripped, re.IGNORECASE)
3897
+ if signal_match:
3898
+ raise ChemQLSignal(evaluate_python(signal_match.group(1)))
3899
+
3163
3900
  # Definitions are processed in source order and affect only later lines.
3164
3901
  define_match = re.fullmatch(r"define\s+(" + _DEFINITION_NAME + r")\s+(.+)", stripped)
3165
3902
  if define_match and not stripped.endswith("{}"):
@@ -3191,7 +3928,7 @@ def process_lines(lines):
3191
3928
  continue
3192
3929
 
3193
3930
  # Function definitions use `func NAME ... endfunc`.
3194
- function_match = re.fullmatch(r"func\s+(" + _DEFINITION_NAME + r")", stripped, re.IGNORECASE)
3931
+ function_match = re.fullmatch(r"func\s+(" + _DEFINITION_NAME + r"(?:\." + _DEFINITION_NAME + r")?)", stripped, re.IGNORECASE)
3195
3932
  if function_match:
3196
3933
  name = function_match.group(1)
3197
3934
  end = i + 1
@@ -3200,6 +3937,36 @@ def process_lines(lines):
3200
3937
  if end >= len(lines):
3201
3938
  raise SyntaxError(f"Missing `endfunc` for function `{name}`")
3202
3939
  FUNCTIONS[name] = {"body": [_expand_definitions(source_line) for source_line in lines[i + 1:end]]}
3940
+ if "." in name:
3941
+ owner_name, method_name = name.split(".", 1)
3942
+ owner = STRUCTS.get(owner_name)
3943
+ if owner is None:
3944
+ raise NameError(f"Cannot define method `{name}`: struct `{owner_name}` does not exist")
3945
+ def _make_struct_method(function_name):
3946
+ def _method(instance, *args):
3947
+ had_this = "this" in python_globals
3948
+ old_this = python_globals.get("this")
3949
+ # Pass method arguments through temporary Python globals.
3950
+ # repr(struct_instance) uses ChemQL's `Fruit{...}` display
3951
+ # syntax, which is not valid Python and breaks nested
3952
+ # constructor expressions when substituted into a method.
3953
+ temporary_names = []
3954
+ python_globals["this"] = instance
3955
+ try:
3956
+ for arg in args:
3957
+ temp_name = "__chemql_method_arg_" + uuid.uuid4().hex
3958
+ python_globals[temp_name] = arg
3959
+ temporary_names.append(temp_name)
3960
+ return _call_function(function_name, temporary_names)
3961
+ finally:
3962
+ for temp_name in temporary_names:
3963
+ python_globals.pop(temp_name, None)
3964
+ if had_this:
3965
+ python_globals["this"] = old_this
3966
+ else:
3967
+ python_globals.pop("this", None)
3968
+ return _method
3969
+ setattr(owner, method_name, _make_struct_method(name))
3203
3970
  i = end + 1
3204
3971
  continue
3205
3972
 
@@ -3367,6 +4134,13 @@ def process_lines(lines):
3367
4134
  result = _dump_history(command)
3368
4135
  else:
3369
4136
  result = process_inline(command)
4137
+ # Query parsing can report an error as formatted text instead
4138
+ # of raising. Inside `risk`, promote that result to an exception
4139
+ # so `fix` runs and the `error` variable is populated.
4140
+ if _CHEMQL_RISK_DEPTH and isinstance(result, str):
4141
+ plain_result = strip_ansi(result).lstrip()
4142
+ if plain_result.startswith("Error:"):
4143
+ raise ChemQLError(plain_result[6:].strip())
3370
4144
  rendered = "" if result is None else str(result)
3371
4145
  if result is not None:
3372
4146
  if NO_ANSI:
@@ -3381,9 +4155,15 @@ def process_lines(lines):
3381
4155
  )
3382
4156
  if not is_streamed_shell and not _shell_output_streamed:
3383
4157
  print(rendered)
3384
- _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)
3385
4163
  globals()["_shell_output_streamed"] = False
3386
4164
  except Exception as exc:
4165
+ if _CHEMQL_RISK_DEPTH:
4166
+ raise
3387
4167
  message = f"Error: {exc}"
3388
4168
  print(strip_ansi(message) if NO_ANSI else message, file=sys.stderr)
3389
4169
  _record_execution(command, message, False)
@@ -3812,7 +4592,9 @@ def execute(arguments, query_text=None, positions=None):
3812
4592
  ])
3813
4593
 
3814
4594
  if result == [] or result == "No matching item":
3815
- 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"
3816
4598
 
3817
4599
  if return_attributes:
3818
4600
  if isinstance(result, list):
@@ -3995,347 +4777,299 @@ def view_periodic_table():
3995
4777
 
3996
4778
 
3997
4779
  class ChemqlLexer(Lexer):
3998
- """Syntax highlighting for ChemQL, native variables, and embedded Python."""
4780
+ """Dynamic syntax highlighting for ChemQL and its interactive objects."""
3999
4781
 
4000
4782
  COMMANDS = {
4001
- "search", "source", "list", "clear", "exit", "findel",
4002
- "findmol", "findre", "set", "add", "remove", "react",
4003
- "balance", "conditions", "view", "help", "dump", "version",
4004
- "return", "union", "intersection", "let", "unset", "variables",
4005
- "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",
4006
4789
  }
4007
-
4008
4790
  OPERATORS = {
4009
- "and", "or", "has", "like", "like!", "likep", "sort",
4010
- "limit", "count", "asc", "desc",
4791
+ "and", "or", "has", "like", "like!", "likep", "sort", "limit",
4792
+ "count", "asc", "desc", "in", "not", "is",
4011
4793
  }
4012
-
4013
4794
  CONTROL_WORDS = {
4014
- "if", "elif", "else", "for", "while", "in", "break",
4015
- "continue", "endblock", "endfunc", "func", "enddefine",
4016
- "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",
4017
4799
  }
4018
-
4019
4800
  CONDITIONS = {
4020
- "temperature", "pressure", "catalyst", "catalysts",
4021
- "standard", "room", "c", "f", "k", "pa", "bar",
4801
+ "temperature", "pressure", "catalyst", "catalysts", "standard", "room",
4802
+ "c", "f", "k", "pa", "bar",
4022
4803
  }
4023
4804
 
4805
+ @staticmethod
4806
+ def _dynamic_names():
4807
+ return {
4808
+ "variables": set(CHEMQL_VARIABLES) | {
4809
+ name for name in python_globals
4810
+ if isinstance(name, str) and not name.startswith("__")
4811
+ },
4812
+ "structs": set(STRUCTS),
4813
+ "records": set(RECORDS),
4814
+ "functions": set(FUNCTIONS),
4815
+ "macros": set(MACROS),
4816
+ "definitions": set(DEFINITIONS),
4817
+ }
4818
+
4024
4819
  def lex_document(self, document):
4025
4820
  lines = document.lines
4821
+ names = self._dynamic_names()
4822
+ function_names = {n.split(".")[-1] for n in names["functions"]}
4026
4823
 
4027
4824
  def get_line(lineno):
4028
4825
  if lineno >= len(lines):
4029
4826
  return []
4030
-
4031
4827
  line = lines[lineno]
4032
4828
  tokens = []
4033
4829
  i = 0
4034
4830
 
4035
- # Preserve leading indentation.
4831
+ # Leading indentation is kept verbatim for prompt_toolkit.
4036
4832
  while i < len(line) and line[i].isspace():
4037
4833
  start = i
4038
4834
  while i < len(line) and line[i].isspace():
4039
4835
  i += 1
4040
4836
  tokens.append(("", line[start:i]))
4041
4837
 
4042
- # Highlight a leading ChemQL command.
4043
- first_match = re.match(r"[A-Za-z_][A-Za-z0-9_]*", line[i:])
4044
- if first_match:
4045
- word = first_match.group(0)
4046
- if word in self.COMMANDS:
4047
- tokens.append(("class:command", word))
4048
- 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"
4049
4855
 
4050
4856
  while i < len(line):
4051
- # Whitespace
4052
- if line[i].isspace():
4053
- start = i
4054
- while i < len(line) and line[i].isspace():
4055
- i += 1
4056
- 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
4057
4864
  continue
4058
-
4059
- # Comments
4060
4865
  if line.startswith("//", i):
4061
4866
  tokens.append(("class:comment", line[i:]))
4062
4867
  break
4063
-
4064
- # # Embedded ChemQL expressions: {{ ... }}
4065
- # # Embedded ChemQL expressions: {{ ... }}
4066
- # if line.startswith("{{", i):
4067
- # try:
4068
- # end = find_matching(line, i + 2, "{{", "}}")
4069
- # except SyntaxError:
4070
- # # The user may still be typing the expression.
4071
- # tokens.append(("class:interpolation", line[i:]))
4072
- # break
4073
-
4074
- # if end is None:
4075
- # tokens.append(("class:interpolation", line[i:]))
4076
- # break
4077
-
4078
- # end += 2
4079
- # tokens.append(("class:interpolation", line[i:end]))
4080
- # i = end
4081
- # continue
4082
-
4083
- # Backtick shell commands
4084
- if line[i] == "`":
4085
- end = line.find("`", i + 1)
4086
- if end == -1:
4087
- end = len(line) - 1
4088
-
4089
- tokens.append((
4090
- "class:terminalcommand",
4091
- line[i:end + 1]
4092
- ))
4093
- i = end + 1
4868
+ if line.startswith("///", i):
4869
+ tokens.append(("class:comment", line[i:]))
4870
+ break
4871
+ if ch == "`":
4872
+ j = line.find("`", i + 1)
4873
+ j = len(line) - 1 if j < 0 else j
4874
+ tokens.append(("class:terminalcommand", line[i:j + 1]))
4875
+ i = j + 1
4094
4876
  continue
4095
-
4096
- # # Python blocks and single-line Python statements
4097
- # if line[i] == "[":
4098
- # try:
4099
- # end = find_matching(line, i + 1, "[", "]")
4100
- # except SyntaxError:
4101
- # # An unfinished bracket is valid while editing.
4102
- # tokens.append(("class:python", line[i:]))
4103
- # break
4104
-
4105
- # if end is not None:
4106
- # end += 1
4107
- # tokens.append(("class:python", line[i:end]))
4108
- # i = end
4109
- # continue
4110
-
4111
- # tokens.append(("class:python", line[i]))
4112
- # i += 1
4113
- # continue
4114
-
4115
- # Strings
4116
- if line[i] in "\"'":
4117
- quote = line[i]
4118
- start = i
4119
- i += 1
4120
-
4121
- while i < len(line):
4122
- if line[i] == "\\":
4123
- i += 2
4877
+ if line.startswith("[[[", i) or line.startswith("]]]", i):
4878
+ marker = line[i:i + 3]
4879
+ tokens.append(("class:python", marker))
4880
+ i += 3
4881
+ continue
4882
+ if line.startswith("{{", i):
4883
+ j = line.find("}}", i + 2)
4884
+ j = len(line) if j < 0 else j + 2
4885
+ tokens.append(("class:interpolation", line[i:j]))
4886
+ i = j
4887
+ continue
4888
+ if ch in "'\"":
4889
+ quote, j = ch, i + 1
4890
+ while j < len(line):
4891
+ if line[j] == "\\":
4892
+ j += 2
4124
4893
  continue
4125
- if line[i] == quote:
4126
- i += 1
4894
+ if line[j] == quote:
4895
+ j += 1
4127
4896
  break
4128
- i += 1
4129
-
4130
- tokens.append(("class:string", line[start:i]))
4897
+ j += 1
4898
+ tokens.append(("class:string", line[i:j]))
4899
+ i = j
4131
4900
  continue
4132
-
4133
- # Numbers
4134
- if line[i].isdigit():
4135
- start = i
4136
- i += 1
4137
- while i < len(line) and (
4138
- line[i].isdigit() or line[i] == "."
4139
- ):
4140
- i += 1
4141
-
4142
- 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)
4143
4915
  continue
4144
-
4145
- # Operators
4146
- operator = next(
4147
- (
4148
- op for op in (
4149
- "!=", ">=", "<=", "==", "=", ">", "<",
4150
- "+=", "-=", "*=", "/=", "+", "-", "*", "/",
4151
- )
4152
- if line.startswith(op, i)
4153
- ),
4154
- None,
4155
- )
4156
-
4916
+ if ch.isalpha() or ch == "_":
4917
+ match = re.match(r"[A-Za-z_]\w*", line[i:])
4918
+ word = match.group(0) #type:ignore
4919
+ folded = word.casefold()
4920
+ style = ""
4921
+ if declared_name and word == declared_name.split(".")[-1]:
4922
+ style = "class:" + declared_style #type:ignore
4923
+ declared_name = None
4924
+ elif not line[:i].strip():
4925
+ style = "class:command" if folded in self.COMMANDS else ""
4926
+ if not style:
4927
+ if word in names["structs"]:
4928
+ style = "class:struct"
4929
+ elif word in names["records"]:
4930
+ style = "class:record"
4931
+ elif word in names["functions"] or word in function_names:
4932
+ style = "class:function"
4933
+ elif word in names["macros"]:
4934
+ style = "class:macro"
4935
+ elif word in names["definitions"]:
4936
+ style = "class:definition"
4937
+ elif word in names["variables"]:
4938
+ style = "class:variable"
4939
+ elif folded in self.CONTROL_WORDS:
4940
+ style = "class:control"
4941
+ elif word in {"True", "False", "None"}:
4942
+ style = "class:number"
4943
+ elif word in {"print", "len", "str", "int", "float", "range", "enumerate", "zip", "list", "dict", "set", "tuple", "sum", "min", "max", "abs", "type", "isinstance"}:
4944
+ style = "class:python"
4945
+ elif word in SOURCES:
4946
+ style = "class:source"
4947
+ elif folded in self.CONDITIONS:
4948
+ style = "class:condition"
4949
+ elif folded in self.OPERATORS:
4950
+ style = "class:operator"
4951
+ elif word in elementKeys or word in moleculeKeys or word in reactionKeys:
4952
+ style = "class:key"
4953
+ tokens.append((style, word))
4954
+ i += len(word)
4955
+ continue
4956
+ operator = next((op for op in ("**", "//", "!=", ">=", "<=", "==", "+=", "-=", "*=", "/=", "->", "<->", "=", ">", "<", "+", "-", "*", "/", "%") if line.startswith(op, i)), None)
4157
4957
  if operator:
4158
4958
  tokens.append(("class:operator", operator))
4159
4959
  i += len(operator)
4160
4960
  continue
4161
-
4162
- # Identifiers and keywords
4163
- if line[i].isalpha() or line[i] == "_":
4164
- start = i
4165
- while i < len(line) and (
4166
- line[i].isalnum() or line[i] == "_"
4167
- ):
4168
- i += 1
4169
-
4170
- word = line[start:i]
4171
-
4172
- if word in self.COMMANDS:
4173
- style = "class:command"
4174
- elif word in self.CONTROL_WORDS:
4175
- style = "class:control"
4176
- elif word in {"True", "False", "None"}:
4177
- style = "class:number"
4178
- elif word in {"print", "len", "str", "int", "float", "range"}:
4179
- style = "class:python"
4180
- elif word in SOURCES:
4181
- style = "class:source"
4182
- elif word.casefold() in self.CONDITIONS:
4183
- style = "class:condition"
4184
- elif word in self.OPERATORS:
4185
- style = "class:operator"
4186
- elif (
4187
- word in CHEMQL_VARIABLES
4188
- or word in python_globals
4189
- ):
4190
- style = "class:variable"
4191
- elif (
4192
- word in elementKeys
4193
- or word in moleculeKeys
4194
- or word in reactionKeys
4195
- ):
4196
- style = "class:key"
4197
- else:
4198
- style = ""
4199
-
4200
- tokens.append((style, word))
4201
- continue
4202
-
4203
- # Other punctuation
4204
- tokens.append(("", line[i]))
4961
+ tokens.append(("", ch))
4205
4962
  i += 1
4206
-
4207
4963
  return tokens
4208
-
4209
4964
  return get_line
4210
4965
 
4211
4966
 
4212
4967
  class ChemqlCompleter(Completer):
4213
- """Context-aware completion for ChemQL commands and variables."""
4968
+ """Context-aware completion for commands and dynamically created objects."""
4214
4969
 
4215
4970
  COMMANDS = (
4216
- "search", "source", "list", "clear", "return", "exit",
4217
- "findel", "findmol", "findre", "set", "add", "remove",
4218
- "react", "balance", "conditions", "version", "help", "view",
4219
- "dump", "let", "unset", "variables", "func", "define",
4220
- "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",
4221
4976
  )
4222
-
4223
- QUERY_OPERATORS = [
4224
- "=", "!=", ">", "<", ">=", "<=", "like", "like!",
4225
- "likep", "has", "sort", "limit", "count", "asc",
4226
- "desc", "return", "and", "or",
4227
- ]
4977
+ QUERY_OPERATORS = ["=", "!=", ">", "<", ">=", "<=", "like", "like!", "likep", "has", "sort", "limit", "count", "asc", "desc", "return", "and", "or", "union", "intersection"]
4978
+ CONTROL_COMPLETIONS = ["if ", "for ", "while ", "endfor", "endwhile", "endif", "risk", "fix", "cleanup", "endrisk", "struct ", "endstruct", "record ", "endrecord", "func ", "endfunc", "define ", "enddefine"]
4979
+
4980
+ @staticmethod
4981
+ def _names():
4982
+ variables = set(CHEMQL_VARIABLES) | {
4983
+ name for name in python_globals
4984
+ if isinstance(name, str) and not name.startswith("__") and name.isidentifier()
4985
+ }
4986
+ return {
4987
+ "variables": variables,
4988
+ "structs": set(STRUCTS),
4989
+ "records": set(RECORDS),
4990
+ "functions": set(FUNCTIONS),
4991
+ "macros": set(MACROS),
4992
+ "definitions": set(DEFINITIONS),
4993
+ }
4228
4994
 
4229
4995
  def get_completions(self, document, complete_event):
4230
4996
  text = document.text_before_cursor
4231
- parts = text.split()
4997
+ stripped = text.lstrip()
4998
+ parts = stripped.split()
4232
4999
  trailing_space = bool(text and text[-1].isspace())
4233
-
4234
5000
  current = "" if trailing_space or not parts else parts[-1]
4235
5001
  command_words = parts if trailing_space else parts[:-1]
5002
+ names = self._names()
4236
5003
 
4237
- def emit(suggestions):
5004
+ def emit(values):
4238
5005
  seen = set()
4239
- for suggestion in suggestions:
4240
- suggestion = str(suggestion)
4241
- key = suggestion.casefold()
4242
-
4243
- if (
4244
- key not in seen
4245
- and key.startswith(current.casefold())
4246
- ):
5006
+ for value in values:
5007
+ value = str(value)
5008
+ key = value.casefold()
5009
+ if key not in seen and key.startswith(current.casefold()):
4247
5010
  seen.add(key)
4248
- yield Completion(
4249
- suggestion,
4250
- start_position=-len(current),
4251
- )
5011
+ yield Completion(value, start_position=-len(current))
4252
5012
 
4253
- # At the beginning of a command, suggest commands and variables.
5013
+ dynamic = sorted(set().union(*names.values()), key=str.casefold)
4254
5014
  if not command_words:
4255
- suggestions = list(self.COMMANDS)
4256
- suggestions.extend(
4257
- sorted(CHEMQL_VARIABLES)
4258
- )
4259
- yield from emit(suggestions)
5015
+ yield from emit(list(self.COMMANDS) + self.CONTROL_COMPLETIONS + dynamic)
4260
5016
  return
4261
5017
 
4262
- command = command_words[0]
5018
+ command = command_words[0].casefold()
4263
5019
  suggestions = []
4264
-
4265
- # Native variable syntax
4266
5020
  if command == "let":
4267
- if len(command_words) == 1:
4268
- suggestions = ["name = value"]
4269
- elif len(command_words) >= 2:
4270
- suggestions = ["="]
4271
-
5021
+ suggestions = ["name = value"] if len(command_words) == 1 else ["="]
4272
5022
  elif command == "unset":
4273
- suggestions = sorted(CHEMQL_VARIABLES)
4274
-
4275
- elif command == "variables":
4276
- suggestions = []
4277
-
4278
- # Source names
4279
- elif command in {"search", "source"}:
5023
+ suggestions = sorted(names["variables"])
5024
+ elif command == "source":
5025
+ suggestions = list(SOURCES)
5026
+ elif command == "search":
4280
5027
  if len(command_words) == 1:
4281
5028
  suggestions = list(SOURCES)
4282
- elif command == "search" and len(command_words) >= 2:
4283
- source_name = command_words[1]
4284
- if source_name in SOURCES:
4285
- _, keys = SOURCES[source_name]
4286
- suggestions = list(keys) + self.QUERY_OPERATORS
4287
-
4288
- # Reaction settings
5029
+ elif len(command_words) >= 2 and command_words[1] in SOURCES:
5030
+ _, keys = SOURCES[command_words[1]]
5031
+ suggestions = list(keys) + self.QUERY_OPERATORS
4289
5032
  elif command == "set":
4290
5033
  if len(command_words) == 1:
4291
- suggestions = [
4292
- "temperature", "pressure", "catalysts"
4293
- ]
5034
+ suggestions = ["temperature", "pressure", "catalysts"]
4294
5035
  elif len(command_words) == 2:
4295
5036
  setting = command_words[1].casefold()
4296
-
4297
- if setting == "temperature":
4298
- suggestions = [
4299
- "standard", "room", "0C", "0F", "0K"
4300
- ]
4301
- elif setting == "pressure":
4302
- suggestions = [
4303
- "standard", "room", "0Pa",
4304
- "0N/m^2", "0Nm^-2", "0bar",
4305
- ]
4306
- elif setting == "catalysts":
4307
- suggestions = list(
4308
- reaction_state["catalysts"]
4309
- )
4310
-
4311
- # Catalyst management
5037
+ suggestions = {
5038
+ "temperature": ["standard", "room", "0C", "0F", "0K"],
5039
+ "pressure": ["standard", "room", "0Pa", "0N/m^2", "0Nm^-2", "0bar", "1atm"],
5040
+ "catalysts": list(reaction_state["catalysts"]),
5041
+ }.get(setting, [])
4312
5042
  elif command in {"add", "remove"}:
4313
5043
  if len(command_words) == 1:
4314
- suggestions = ["catalyst"]
4315
- elif (
4316
- command == "remove"
4317
- and len(command_words) == 2
4318
- and command_words[1] == "catalyst"
4319
- ):
4320
- suggestions = list(
4321
- reaction_state["catalysts"]
4322
- )
4323
-
4324
- # Variable names can be useful in expressions.
4325
- elif command in {"print", "return"}:
4326
- suggestions = sorted(CHEMQL_VARIABLES)
4327
-
4328
- # Offer native variables in other expression contexts too.
4329
- if not suggestions and (
4330
- "{{" in text
4331
- or text.lstrip().startswith("[")
4332
- ):
4333
- suggestions = sorted(CHEMQL_VARIABLES)
4334
-
5044
+ suggestions = ["catalyst", "definition"] if command == "remove" else ["catalyst"]
5045
+ elif command == "remove" and command_words[1].casefold() == "catalyst":
5046
+ suggestions = list(reaction_state["catalysts"])
5047
+ elif command == "remove" and command_words[1].casefold() == "definition":
5048
+ suggestions = sorted(names["definitions"])
5049
+ elif command == "variables":
5050
+ suggestions = sorted(names["variables"])
5051
+ elif command == "definitions":
5052
+ suggestions = sorted(names["definitions"])
5053
+ elif command == "macros":
5054
+ suggestions = sorted(names["macros"])
5055
+ elif command == "func":
5056
+ suggestions = sorted(names["structs"])
5057
+ elif command == "struct":
5058
+ suggestions = sorted(names["structs"])
5059
+ elif command == "record":
5060
+ suggestions = sorted(names["records"])
5061
+ elif command in {"print", "return", "unset"}:
5062
+ suggestions = sorted(names["variables"])
5063
+ elif command in {"risk", "fix", "cleanup", "endrisk", "endstruct", "endrecord", "endfunc", "enddefine", "endfor", "endwhile", "endif"}:
5064
+ suggestions = self.CONTROL_COMPLETIONS
5065
+
5066
+ # In expressions, Python blocks, attribute access, and function calls,
5067
+ # offer all user-defined symbols, not just built-in commands.
5068
+ if not suggestions or "{{" in text or text.lstrip().startswith("[") or "." in current:
5069
+ suggestions += dynamic
4335
5070
  yield from emit(suggestions)
4336
5071
 
4337
5072
 
4338
-
4339
5073
  chemql_style = Style.from_dict({
4340
5074
  "command": "ansicyan",
4341
5075
  "terminalcommand": "ansipurple",
@@ -4348,11 +5082,16 @@ chemql_style = Style.from_dict({
4348
5082
  "control": "ansired",
4349
5083
  "python": "ansibrightcyan",
4350
5084
  "condition": "ansibrightyellow",
5085
+ "variable": "ansibrightgreen",
5086
+ "struct": "ansibrightblue bold",
5087
+ "record": "ansicyan bold",
5088
+ "function": "ansiyellow bold",
5089
+ "macro": "ansimagenta bold",
5090
+ "definition": "ansibrightmagenta",
5091
+ "interpolation": "ansibrightcyan italic",
4351
5092
  })
4352
5093
 
4353
5094
 
4354
-
4355
-
4356
5095
  class _ChemQLField:
4357
5096
  """Field selector used by the fluent Python query API."""
4358
5097
  def __init__(self, query, field):
@@ -4495,6 +5234,83 @@ ChemQLQuery.__name__ = "ChemQLQuery"
4495
5234
  ChemQLQuery.__qualname__ = "ChemQLQuery"
4496
5235
 
4497
5236
 
5237
+
5238
+ def _unclosed_expression_delimiters(lines):
5239
+ """Return expected closing delimiters for an unfinished expression.
5240
+
5241
+ This scanner is intentionally for REPL continuation only. It recognizes
5242
+ ChemQL's {{...}} query delimiters before ordinary braces and ignores
5243
+ delimiters inside quoted strings and // comments. The dedicated [[[...]]]
5244
+ block is handled separately by the REPL and must not be treated as a stack
5245
+ of ordinary square brackets.
5246
+ """
5247
+ expected = []
5248
+ quote = None
5249
+ escaped = False
5250
+ openers = ("{{", "{", "(", "[")
5251
+ closers = ("}}", "}", ")", "]")
5252
+
5253
+ for line in lines:
5254
+ i = 0
5255
+ while i < len(line):
5256
+ ch = line[i]
5257
+
5258
+ if quote is not None:
5259
+ if escaped:
5260
+ escaped = False
5261
+ elif ch == "\\":
5262
+ escaped = True
5263
+ elif ch == quote:
5264
+ quote = None
5265
+ i += 1
5266
+ continue
5267
+
5268
+ if ch in ("'", '"'):
5269
+ quote = ch
5270
+ i += 1
5271
+ continue
5272
+
5273
+ # ChemQL's single-line comments must not affect delimiter balance.
5274
+ if line.startswith("//", i):
5275
+ break
5276
+
5277
+ # The triple-bracket multiline Python form is managed elsewhere.
5278
+ if line.startswith("[[[", i):
5279
+ i += 3
5280
+ continue
5281
+ if line.startswith("]]]", i):
5282
+ i += 3
5283
+ continue
5284
+
5285
+ token = next((t for t in openers if line.startswith(t, i)), None)
5286
+ if token is not None:
5287
+ expected.append({"{{": "}}", "{": "}", "(": ")", "[": "]"}[token])
5288
+ i += len(token)
5289
+ continue
5290
+
5291
+ token = next((t for t in closers if line.startswith(t, i)), None)
5292
+ if token is not None:
5293
+ if expected:
5294
+ if expected[-1] != token:
5295
+ raise SyntaxError(
5296
+ f"Mismatched delimiter: expected {expected[-1]!r}, got {token!r}"
5297
+ )
5298
+ expected.pop()
5299
+ else:
5300
+ # An unmatched closer belongs to the normal ChemQL parser
5301
+ # to diagnose; it should not cause continuation prompting.
5302
+ return []
5303
+ i += len(token)
5304
+ continue
5305
+
5306
+ i += 1
5307
+
5308
+ # A single-line string cannot legitimately span REPL input lines in
5309
+ # ordinary ChemQL expressions; retain quote state only for completeness.
5310
+
5311
+ return expected
5312
+
5313
+
4498
5314
  def main():
4499
5315
  parser = argparse.ArgumentParser(
4500
5316
  description="Chemql query and chemistry language"
@@ -4619,17 +5435,40 @@ def run_interactive():
4619
5435
  if "///" in first:
4620
5436
  return sum(line.count("///") for line in source_lines) < 2
4621
5437
 
4622
- if is_python_block_header(first):
4623
- depth = 0
4624
- for line in source_lines:
4625
- stripped = line.strip()
4626
- if is_python_block_header(stripped):
4627
- depth += 1
4628
- elif stripped == "[endblock]":
4629
- depth -= 1
4630
- 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
4631
5451
 
4632
- 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)
4633
5472
 
4634
5473
  @bindings.add(Keys.Enter)
4635
5474
  def _handle_enter(event):
@@ -4757,6 +5596,44 @@ def run_interactive():
4757
5596
  if not lines:
4758
5597
  continue
4759
5598
 
5599
+ # Collect multiline expressions only when a physical line ends
5600
+ # with an expression opener. Structured constructs are handled by
5601
+ # their existing collectors above/below and must retain newlines.
5602
+ structured_start = bool(lines) and (
5603
+ lines[0].strip() == "[[["
5604
+ or lines[0].strip().startswith(("```", "func ", "define "))
5605
+ or "///" in lines[0]
5606
+ or is_python_block_header(lines[0].strip())
5607
+ )
5608
+ opens_at_line_end = bool(lines) and any(
5609
+ line.rstrip().endswith(("{{", "{", "(", "["))
5610
+ for line in lines
5611
+ )
5612
+ if lines and not structured_start and opens_at_line_end:
5613
+ try:
5614
+ pending = _unclosed_expression_delimiters(lines)
5615
+ while pending:
5616
+ next_line = session.prompt("... ")
5617
+ lines.extend(next_line.splitlines() or [next_line])
5618
+ pending = _unclosed_expression_delimiters(lines)
5619
+ if len(lines) > 1:
5620
+ # These delimiters represent one expression: collapse
5621
+ # physical lines before handing it to ChemQL evaluation.
5622
+ lines = [" ".join(part.strip() for part in lines if part.strip())]
5623
+ except KeyboardInterrupt:
5624
+ print()
5625
+ lines = []
5626
+ except EOFError:
5627
+ message = "Error: Incomplete expression; missing closing delimiter"
5628
+ print("\n" + (message if NO_ANSI else "\033[31m" + message + "\033[0m"))
5629
+ lines = []
5630
+ except SyntaxError as exc:
5631
+ message = f"Error: {exc}"
5632
+ print(message if NO_ANSI else f"\033[31m{message}\033[0m")
5633
+ lines = []
5634
+ if not lines:
5635
+ continue
5636
+
4760
5637
  # Collect a Python control block only if the single editable buffer
4761
5638
  # was submitted before its matching [endblock] was entered.
4762
5639
  if lines and is_python_block_header(lines[0].strip()):