chemql-python 1.0.3__py3-none-any.whl → 1.2.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
@@ -8,11 +8,16 @@ import argparse
8
8
  import time
9
9
  import subprocess
10
10
  import sys
11
+ import contextlib
12
+ import io
11
13
  from pathlib import Path
12
14
  from prompt_toolkit import PromptSession
15
+ from prompt_toolkit.key_binding import KeyBindings
16
+ from prompt_toolkit.keys import Keys
13
17
  from prompt_toolkit.lexers import Lexer
14
18
  import atexit
15
19
  import uuid
20
+ import select
16
21
  from prompt_toolkit.styles import Style
17
22
  from prompt_toolkit.completion import Completer, Completion
18
23
  import urllib.parse
@@ -40,106 +45,411 @@ __all__ = [
40
45
  "__version__"
41
46
  ]
42
47
 
43
- __version__ = "1.0.3"
48
+ __version__ = "1.2.0"
49
+
50
+ # Persistent preprocessor/function state for the current ChemQL session.
51
+ DEFINITIONS = {}
52
+ FUNCTIONS = {}
53
+ MACROS = {}
54
+ EXECUTION_HISTORY = []
55
+ _DEFINITION_NAME = r"[A-Za-z_][A-Za-z0-9_]*"
56
+
57
+ def _expand_definitions(line):
58
+ """Replace @NAME literally, including inside strings, using current definitions."""
59
+ if not DEFINITIONS:
60
+ return line
61
+ pattern = re.compile(r"@(" + _DEFINITION_NAME + r")")
62
+ # One substitution pass only: replacement text is not recursively expanded.
63
+ return pattern.sub(lambda match: DEFINITIONS.get(match.group(1), match.group(0)), line)
64
+
65
+ def _split_function_arguments(source):
66
+ """Split comma-separated arguments while respecting nested delimiters and strings."""
67
+ args, start = [], 0
68
+ quote = None
69
+ escaped = False
70
+ round_depth = square_depth = curly_depth = double_curly_depth = 0
71
+ i = 0
72
+ while i < len(source):
73
+ ch = source[i]
74
+ if quote is not None:
75
+ if escaped:
76
+ escaped = False
77
+ elif ch == "\\":
78
+ escaped = True
79
+ elif ch == quote:
80
+ quote = None
81
+ i += 1
82
+ continue
83
+ if ch in ("'", '"'):
84
+ quote = ch
85
+ elif source.startswith("{{", i):
86
+ double_curly_depth += 1
87
+ i += 2
88
+ continue
89
+ elif source.startswith("}}", i) and double_curly_depth:
90
+ double_curly_depth -= 1
91
+ i += 2
92
+ continue
93
+ elif not double_curly_depth:
94
+ if ch == "(": round_depth += 1
95
+ elif ch == ")": round_depth = max(0, round_depth - 1)
96
+ elif ch == "[": square_depth += 1
97
+ elif ch == "]": square_depth = max(0, square_depth - 1)
98
+ elif ch == "{": curly_depth += 1
99
+ elif ch == "}": curly_depth = max(0, curly_depth - 1)
100
+ elif ch == "," and not (round_depth or square_depth or curly_depth):
101
+ args.append(source[start:i].strip())
102
+ start = i + 1
103
+ i += 1
104
+ tail = source[start:].strip()
105
+ if tail or source.strip():
106
+ args.append(tail)
107
+ return args
108
+
109
+ def _call_function(name, arguments):
110
+ """Call a ChemQL function; arguments are evaluated Python expressions."""
111
+ if name not in FUNCTIONS:
112
+ raise NameError(f"Unknown ChemQL function `{name}`")
113
+ values = [evaluate_python(arg) for arg in arguments]
114
+ definition = FUNCTIONS[name]
115
+ saved_globals = python_globals.copy()
116
+ saved_chemql_variables = CHEMQL_VARIABLES.copy()
117
+ had_arguments = "arguments" in python_globals
118
+ saved_arguments = python_globals.get("arguments")
119
+ python_globals["arguments"] = [name, *values]
120
+
121
+ def substitute_parameters(source):
122
+ # Replace positional placeholders as tokens, so \1 does not corrupt \10.
123
+ return re.sub(
124
+ r"\\(\d+)",
125
+ lambda match: repr(values[int(match.group(1)) - 1])
126
+ if 0 < int(match.group(1)) <= len(values) else match.group(0),
127
+ source,
128
+ )
44
129
 
130
+ try:
131
+ body = definition["body"]
132
+ i = 0
133
+ while i < len(body):
134
+ line = body[i].strip()
135
+ if not line:
136
+ i += 1
137
+ continue
45
138
 
46
- _shell_process = None
139
+ # A function may contain a multiline [[[ Python block ]]].
140
+ if line == "[[[":
141
+ block = []
142
+ i += 1
143
+ while i < len(body) and body[i].strip() != "]]]":
144
+ block.append(substitute_parameters(body[i]))
145
+ i += 1
146
+ if i >= len(body):
147
+ raise SyntaxError(f"Missing `]]]` in function `{name}`")
148
+ evaluate_python("\n".join(block))
149
+ i += 1
150
+ continue
47
151
 
152
+ if line.startswith("return"):
153
+ expression = line[len("return"):].strip()
154
+ if expression.startswith("[") and expression.endswith("]"):
155
+ expression = expression[1:-1]
156
+ expression = substitute_parameters(expression)
157
+ return evaluate_python(expression) if expression else None
48
158
 
49
- def enable_ansi():
50
- """Enable ANSI escape code support on Windows and Unix-like terminals."""
51
- if os.name == 'nt':
52
- import ctypes
53
- kernel32 = ctypes.windll.kernel32
54
- # STD_OUTPUT_HANDLE = -11
55
- handle = kernel32.GetStdHandle(-11)
56
- mode = ctypes.c_ulong()
57
- if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
58
- # ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
59
- new_mode = mode.value | 0x0004
60
- kernel32.SetConsoleMode(handle, new_mode)
159
+ evaluate_python(substitute_parameters(line))
160
+ i += 1
161
+ return None
162
+ finally:
163
+ # Keep function-local assignments from leaking into the caller's namespace.
164
+ python_globals.clear()
165
+ python_globals.update(saved_globals)
166
+ CHEMQL_VARIABLES.clear()
167
+ CHEMQL_VARIABLES.update(saved_chemql_variables)
168
+ if had_arguments:
169
+ python_globals["arguments"] = saved_arguments
170
+ else:
171
+ python_globals.pop("arguments", None)
172
+
173
+ def _expand_function_calls(text):
174
+ """Expand function calls of the form name{arg1, arg2} in query text."""
175
+ # This pass is only for top-level query expressions, not arbitrary strings.
176
+ # The scanner preserves quoted strings and resolves innermost calls first.
177
+ for _ in range(100):
178
+ changed = False
179
+ quote = None
180
+ escaped = False
181
+ i = 0
182
+ while i < len(text):
183
+ ch = text[i]
184
+ if quote is not None:
185
+ if escaped: escaped = False
186
+ elif ch == "\\": escaped = True
187
+ elif ch == quote: quote = None
188
+ i += 1
189
+ continue
190
+ if ch in ("'", '"'):
191
+ quote = ch; i += 1; continue
192
+ match = re.match(r"([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?)\{", text[i:])
193
+ if match:
194
+ name = match.group(1)
195
+ if name in FUNCTIONS:
196
+ opening = i + match.end() - 1
197
+ end = find_matching(text, opening, "{", "}")
198
+ args = _split_function_arguments(text[opening + 1:end])
199
+ value = _call_function(name, args)
200
+ replacement = repr(value) if isinstance(value, str) else ("None" if value is None else str(value))
201
+ text = text[:i] + replacement + text[end + 1:]
202
+ changed = True
203
+ break
204
+ i += 1
205
+ if not changed:
206
+ return text
207
+ raise SyntaxError("Function expansion exceeded 100 levels (possible recursion)")
61
208
 
62
209
 
63
- enable_ansi()
210
+ _shell_process = None
211
+ _shell_master_fd = None
212
+ _shell_prompt_ready = False
213
+ _shell_output_streamed = False
214
+ _shell_prompt = f"__CHEMQL_PROMPT_{uuid.uuid4().hex}__"
215
+
64
216
 
65
217
  def get_shell():
66
- global _shell_process
218
+ """Return the persistent shell process.
67
219
 
68
- if _shell_process is None or _shell_process.poll() is not None:
69
- if os.name == "nt":
70
- _shell_process = subprocess.Popen(
71
- ["cmd.exe", "/D", "/Q", "/K", "prompt $S"],
72
- stdin=subprocess.PIPE,
73
- stdout=subprocess.PIPE,
74
- stderr=subprocess.STDOUT,
75
- text=True,
76
- bufsize=1,
77
- )
78
- else:
220
+ On POSIX, use a pseudo-terminal so foreground commands such as `read`,
221
+ `read -p`, and interactive CLIs can receive keyboard input. Windows keeps
222
+ the pipe-based implementation until a ConPTY backend is added.
223
+ """
224
+ global _shell_process, _shell_master_fd, _shell_prompt_ready
225
+
226
+ if _shell_process is not None and _shell_process.poll() is None:
227
+ return _shell_process
228
+
229
+ # Clean up any stale shell/PTY left by an interrupted command.
230
+ close_shell()
231
+
232
+ if os.name == "nt":
233
+ _shell_process = subprocess.Popen(
234
+ ["cmd.exe", "/D", "/Q", "/K", "prompt $S"],
235
+ stdin=subprocess.PIPE,
236
+ stdout=subprocess.PIPE,
237
+ stderr=subprocess.STDOUT,
238
+ text=True,
239
+ bufsize=1,
240
+ )
241
+ else:
242
+ import fcntl
243
+ import pty
244
+ import termios
245
+
246
+ master_fd, slave_fd = pty.openpty()
247
+ # Don't echo bytes sent by ChemQL or keyboard input twice. Keep ISIG
248
+ # enabled so forwarding Ctrl-C to the PTY interrupts the foreground job.
249
+ attrs = termios.tcgetattr(slave_fd)
250
+ attrs[3] &= ~termios.ECHO
251
+ termios.tcsetattr(slave_fd, termios.TCSANOW, attrs)
252
+
253
+ def attach_controlling_terminal():
254
+ # Popen(start_new_session=True) has already called setsid().
255
+ fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
256
+
257
+ try:
79
258
  _shell_process = subprocess.Popen(
80
259
  ["bash", "--noprofile", "--norc"],
81
- stdin=subprocess.PIPE,
82
- stdout=subprocess.PIPE,
83
- stderr=subprocess.STDOUT,
84
- text=True,
85
- bufsize=1,
260
+ stdin=slave_fd,
261
+ stdout=slave_fd,
262
+ stderr=slave_fd,
263
+ close_fds=True,
264
+ start_new_session=True,
265
+ preexec_fn=attach_controlling_terminal,
266
+ env={**os.environ, "PS1": _shell_prompt, "PS2": ""},
86
267
  )
268
+ _shell_master_fd = master_fd
269
+ _shell_prompt_ready = False
270
+ except Exception:
271
+ os.close(master_fd)
272
+ raise
273
+ finally:
274
+ os.close(slave_fd)
87
275
 
88
276
  return _shell_process
89
277
 
90
278
 
91
- def run_shell_command(command):
279
+ def _run_shell_command_posix(command, marker=None):
280
+ """Run a command through the persistent PTY and forward keyboard input.
281
+
282
+ A unique shell prompt marks command completion. Unlike appending an `echo`
283
+ marker to stdin, this does not accidentally feed that marker to commands
284
+ such as `read -p` that consume keyboard input.
285
+ """
286
+ global _shell_prompt_ready, _shell_output_streamed
287
+ _shell_output_streamed = False
92
288
  shell = get_shell()
289
+ master_fd = _shell_master_fd
290
+ if master_fd is None:
291
+ return "Shell error: pseudo-terminal was not initialized."
292
+
293
+ prompt_bytes = _shell_prompt.encode()
294
+
295
+ def wait_for_prompt(forward_keyboard=False):
296
+ collected = bytearray()
297
+ streamed_pos = 0
298
+ stdin_fd = None
299
+ try:
300
+ if forward_keyboard and sys.stdin is not None and sys.stdin.isatty():
301
+ stdin_fd = sys.stdin.fileno()
302
+ except (AttributeError, OSError, ValueError):
303
+ stdin_fd = None
304
+
305
+ while True:
306
+ readers = [master_fd]
307
+ if stdin_fd is not None:
308
+ readers.append(stdin_fd)
309
+ try:
310
+ ready, _, _ = select.select(readers, [], [])
311
+ except KeyboardInterrupt:
312
+ # ChemQL owns the user's terminal, so forward Ctrl-C into the
313
+ # child PTY to interrupt its foreground command.
314
+ try:
315
+ os.write(master_fd, b"\x03")
316
+ except OSError:
317
+ pass
318
+ continue
93
319
 
320
+ if stdin_fd is not None and stdin_fd in ready:
321
+ try:
322
+ typed = os.read(stdin_fd, 4096)
323
+ except OSError:
324
+ typed = b""
325
+ if typed:
326
+ try:
327
+ os.write(master_fd, typed)
328
+ except OSError:
329
+ stdin_fd = None
330
+ else:
331
+ stdin_fd = None
332
+
333
+ if master_fd in ready:
334
+ try:
335
+ chunk = os.read(master_fd, 4096)
336
+ except OSError:
337
+ chunk = b""
338
+ if not chunk:
339
+ return None
340
+ collected.extend(chunk)
341
+ prompt_pos = collected.find(prompt_bytes)
342
+ if forward_keyboard:
343
+ # Stream command output immediately so prompts such as
344
+ # `read -p` are visible while the command waits for input.
345
+ # Hold back only a suffix that might be the start of PS1.
346
+ safe_end = prompt_pos if prompt_pos >= 0 else len(collected)
347
+ if prompt_pos < 0:
348
+ max_overlap = min(len(prompt_bytes) - 1, len(collected))
349
+ overlap = 0
350
+ for size in range(max_overlap, 0, -1):
351
+ if collected.endswith(prompt_bytes[:size]):
352
+ overlap = size
353
+ break
354
+ safe_end -= overlap
355
+ if safe_end > streamed_pos:
356
+ visible = bytes(collected[streamed_pos:safe_end])
357
+ try:
358
+ sys.stdout.write(visible.decode(errors="replace"))
359
+ sys.stdout.flush()
360
+ _shell_output_streamed = True
361
+ except (OSError, UnicodeError):
362
+ pass
363
+ streamed_pos = safe_end
364
+ if prompt_pos >= 0:
365
+ before = bytes(collected[:prompt_pos])
366
+ # Strip terminal CRs while preserving meaningful output.
367
+ return before.decode(errors="replace").replace("\r\n", "\n").rstrip("\r\n ")
368
+
369
+ # Bash emits a prompt immediately on startup. Consume it before submitting
370
+ # the first command; after each command the same prompt signals completion.
371
+ if not _shell_prompt_ready:
372
+ initial = wait_for_prompt(forward_keyboard=False)
373
+ if initial is None:
374
+ close_shell()
375
+ return "Shell session terminated."
376
+ _shell_prompt_ready = True
377
+
378
+ try:
379
+ os.write(master_fd, (command + "\n").encode())
380
+ result = wait_for_prompt(forward_keyboard=True)
381
+ if result is None:
382
+ close_shell()
383
+ return "Shell session terminated."
384
+ return result
385
+ except (BrokenPipeError, OSError) as exc:
386
+ close_shell()
387
+ return f"Shell error: {exc}"
388
+
389
+
390
+ def run_shell_command(command):
391
+ shell = get_shell()
94
392
  command = command.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n")
95
393
  marker = f"__CHEMQL_END_{uuid.uuid4().hex}__"
96
394
 
97
395
  try:
98
- if os.name == "nt":
99
- # Run the command, then print a unique completion marker.
100
- shell.stdin.write( #type:ignore
101
- f"{command}\r\n"
102
- f"echo {marker}\r\n"
103
- )
104
- else:
105
- shell.stdin.write( #type:ignore
106
- f"{command}\n"
107
- f"printf '\\n%s\\n' '{marker}'\n"
108
- )
109
-
110
- shell.stdin.flush() #type:ignore
396
+ if os.name != "nt":
397
+ return _run_shell_command_posix(command, marker)
111
398
 
399
+ # Windows pipe mode supports persistent commands, but not fully
400
+ # interactive programs yet; a ConPTY backend is needed for that.
401
+ shell.stdin.write(command + "\r\n" + f"echo {marker}\r\n") # type: ignore
402
+ shell.stdin.flush() # type: ignore
112
403
  output = []
113
-
114
404
  while True:
115
- line = shell.stdout.readline() #type:ignore
116
-
405
+ try:
406
+ line = shell.stdout.readline() # type: ignore
407
+ except KeyboardInterrupt:
408
+ # Avoid leaking a traceback; reset the shell so the next command
409
+ # starts with a clean process if the current one was interrupted.
410
+ close_shell()
411
+ return "Shell command interrupted. Shell session was reset."
117
412
  if not line:
118
413
  return "\n".join(output) or "Shell session terminated."
119
-
120
414
  line = line.rstrip("\r\n")
121
-
122
415
  if marker in line:
123
416
  break
124
-
125
417
  output.append(line)
126
-
127
418
  return "\n".join(output).strip()
128
419
 
420
+ except KeyboardInterrupt:
421
+ close_shell()
422
+ return "Shell command interrupted. Shell session was reset."
129
423
  except (BrokenPipeError, OSError) as exc:
424
+ close_shell()
130
425
  return f"Shell error: {exc}"
131
426
 
427
+
132
428
  def close_shell():
133
- """Clean up the persistent shell on exit."""
134
- global _shell_process
429
+ """Clean up the persistent shell and its pseudo-terminal on exit."""
430
+ global _shell_process, _shell_master_fd, _shell_prompt_ready
135
431
 
136
432
  if _shell_process is not None:
137
433
  if _shell_process.poll() is None:
138
- _shell_process.terminate()
139
- _shell_process.wait(timeout=2)
140
-
434
+ try:
435
+ _shell_process.terminate()
436
+ _shell_process.wait(timeout=2)
437
+ except (subprocess.TimeoutExpired, OSError):
438
+ try:
439
+ _shell_process.kill()
440
+ _shell_process.wait(timeout=2)
441
+ except (subprocess.TimeoutExpired, OSError):
442
+ pass
141
443
  _shell_process = None
142
444
 
445
+ if _shell_master_fd is not None:
446
+ try:
447
+ os.close(_shell_master_fd)
448
+ except OSError:
449
+ pass
450
+ _shell_master_fd = None
451
+ _shell_prompt_ready = False
452
+
143
453
 
144
454
  atexit.register(close_shell)
145
455
 
@@ -1621,6 +1931,14 @@ reaction_state = {
1621
1931
 
1622
1932
  # Python variables persist for the whole session.
1623
1933
  python_globals = {"__builtins__": __builtins__, "math": math}
1934
+ CHEMQL_VARIABLES = set()
1935
+
1936
+ def _chemql_value(expression):
1937
+ """Resolve a native ChemQL variable, otherwise evaluate a query expression."""
1938
+ expression = expression.strip()
1939
+ if re.fullmatch(_DEFINITION_NAME, expression) and expression in CHEMQL_VARIABLES:
1940
+ return python_globals[expression]
1941
+ return execute_query_block(expression)
1624
1942
 
1625
1943
 
1626
1944
  def find_matching(text, start, opening, closing):
@@ -1702,7 +2020,7 @@ def interpolate_python_queries(code):
1702
2020
  end = find_matching(code, i, "{{", "}}")
1703
2021
  query = code[i + 2:end].strip()
1704
2022
 
1705
- value = execute_query_block(query)
2023
+ value = _chemql_value(query)
1706
2024
 
1707
2025
  name = f"__chemql_query_{query_index}"
1708
2026
  query_index += 1
@@ -1799,6 +2117,7 @@ def _as_result_list(result):
1799
2117
  def execute_query_text(text):
1800
2118
  """Execute plain query-language text."""
1801
2119
  text = text.strip()
2120
+ text = _expand_function_calls(text)
1802
2121
  set_expression = _split_set_operations(text)
1803
2122
  if set_expression:
1804
2123
  parts, ops = set_expression
@@ -1869,7 +2188,8 @@ def evaluate_python(code):
1869
2188
  if code.startswith("{{", i):
1870
2189
  end = find_matching(code, i, "{{", "}}")
1871
2190
  query = code[i + 2:end].strip()
1872
- value = execute_query_block(query)
2191
+ query = _expand_function_calls(query)
2192
+ value = _chemql_value(query)
1873
2193
 
1874
2194
  name = f"__chemql_query_{query_index}"
1875
2195
  query_index += 1
@@ -1882,19 +2202,22 @@ def evaluate_python(code):
1882
2202
  i += 1
1883
2203
 
1884
2204
  rewritten = "".join(rewritten)
2205
+ rewritten = _expand_function_calls(rewritten)
1885
2206
 
1886
2207
  python_globals.update(query_values)
1887
2208
 
1888
2209
  try:
1889
2210
  tree = ast.parse(rewritten, mode="eval")
1890
2211
  except SyntaxError:
1891
- exec(rewritten, python_globals)
2212
+ module = ast.parse(rewritten, mode="exec")
2213
+ for node in ast.walk(module):
2214
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
2215
+ if isinstance(node.ctx, ast.Store) and node.id.isidentifier() and not node.id.startswith("__chemql_"):
2216
+ CHEMQL_VARIABLES.add(node.id)
2217
+ exec(compile(module, "<embedded-python>", "exec"), python_globals)
1892
2218
  return None
1893
2219
 
1894
- return eval(
1895
- compile(tree, "<embedded-python>", "eval"),
1896
- python_globals
1897
- )
2220
+ return eval(compile(tree, "<embedded-python>", "eval"), python_globals)
1898
2221
 
1899
2222
 
1900
2223
  def is_python_block_header(text):
@@ -2138,7 +2461,7 @@ def execute_if_block(header, body):
2138
2461
  return
2139
2462
 
2140
2463
  def split_commands(text):
2141
- """Split ; commands, but never split inside [], {{}} or strings."""
2464
+ """Split top-level semicolons, respecting quotes and nested syntax."""
2142
2465
  commands = []
2143
2466
  start = 0
2144
2467
  i = 0
@@ -2146,10 +2469,21 @@ def split_commands(text):
2146
2469
  curly = 0
2147
2470
  quote = None
2148
2471
  escaped = False
2472
+ backtick = False
2149
2473
 
2150
2474
  while i < len(text):
2151
2475
  char = text[i]
2152
2476
 
2477
+ if backtick:
2478
+ if char == "`" and not escaped:
2479
+ backtick = False
2480
+ if char == "\\" and not escaped:
2481
+ escaped = True
2482
+ else:
2483
+ escaped = False
2484
+ i += 1
2485
+ continue
2486
+
2153
2487
  if quote is not None:
2154
2488
  if escaped:
2155
2489
  escaped = False
@@ -2162,6 +2496,8 @@ def split_commands(text):
2162
2496
 
2163
2497
  if char in ("'", '"'):
2164
2498
  quote = char
2499
+ elif char == "`":
2500
+ backtick = True
2165
2501
  elif text.startswith("{{", i):
2166
2502
  curly += 1
2167
2503
  i += 2
@@ -2184,6 +2520,58 @@ def split_commands(text):
2184
2520
  return commands
2185
2521
 
2186
2522
 
2523
+ def _record_execution(query, result, success):
2524
+ EXECUTION_HISTORY.append({
2525
+ "query": str(query),
2526
+ "result": str(result) if result is not None else "",
2527
+ "success": bool(success),
2528
+ })
2529
+
2530
+
2531
+ def _dump_history(command):
2532
+ """Implement: dump [all|good|bad] [queries|results] FILE."""
2533
+ try:
2534
+ parts = shlex.split(command)
2535
+ except ValueError as exc:
2536
+ raise ChemQLError(f"Invalid dump command: {exc}") from exc
2537
+
2538
+ if len(parts) == 2:
2539
+ category, mode, filename = "all", "both", parts[1]
2540
+ elif len(parts) == 4:
2541
+ category, mode, filename = parts[1:]
2542
+ if category not in {"all", "good", "bad"} or mode not in {"queries", "results"}:
2543
+ raise ChemQLError("Usage: dump [all|good|bad] [queries|results] FILE")
2544
+ elif len(parts) == 3 and parts[1] in {"all", "good", "bad"}:
2545
+ category, mode, filename = parts[1], "both", parts[2]
2546
+ else:
2547
+ raise ChemQLError("Usage: dump [all|good|bad] [queries|results] FILE")
2548
+
2549
+ records = EXECUTION_HISTORY
2550
+ if category == "good":
2551
+ records = [record for record in records if record["success"]]
2552
+ elif category == "bad":
2553
+ records = [record for record in records if not record["success"]]
2554
+
2555
+ if mode == "queries":
2556
+ content = "\n".join(record["query"] for record in records)
2557
+ elif mode == "results":
2558
+ content = "\n".join(record["result"] for record in records)
2559
+ else:
2560
+ content = "\n\n".join(
2561
+ f"QUERY:\n{record['query']}\nRESULT:\n{record['result']}"
2562
+ for record in records
2563
+ )
2564
+
2565
+ Path(filename).write_text(strip_ansi(content), encoding="utf-8")
2566
+ return f"Dumped {len(records)} execution record(s) to {filename}"
2567
+
2568
+
2569
+ def strip_ansi(text):
2570
+ """Remove common ANSI CSI, OSC, and single-character escape sequences."""
2571
+ text = re.sub(r"\x1B\][^\x07]*(?:\x07|\x1B\\)", "", str(text))
2572
+ return re.sub(r"\x1B(?:\[[0-?]*[ -/]*[@-~]|[@-_])", "", text)
2573
+
2574
+
2187
2575
  def process_inline(text):
2188
2576
  """Process one complete line of the chemql language."""
2189
2577
  stripped = text.strip()
@@ -2191,6 +2579,40 @@ def process_inline(text):
2191
2579
  if not stripped:
2192
2580
  return
2193
2581
 
2582
+ # Native dynamically typed variables: `let name = expression`.
2583
+ let_match = re.fullmatch(r"let\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)", stripped, re.DOTALL)
2584
+ if let_match:
2585
+ name, expression = let_match.groups()
2586
+ value = evaluate_python(expression)
2587
+ python_globals[name] = value
2588
+ CHEMQL_VARIABLES.add(name)
2589
+ return None
2590
+
2591
+ if stripped.casefold() == "variables":
2592
+ if not CHEMQL_VARIABLES:
2593
+ return "No variables."
2594
+ return "\n".join(f"{name} = {python_globals.get(name)!r}" for name in sorted(CHEMQL_VARIABLES) if name in python_globals)
2595
+
2596
+ unset_match = re.fullmatch(r"unset\s+([A-Za-z_][A-Za-z0-9_]*)", stripped, re.IGNORECASE)
2597
+ if unset_match:
2598
+ name = unset_match.group(1)
2599
+ CHEMQL_VARIABLES.discard(name)
2600
+ python_globals.pop(name, None)
2601
+ return None
2602
+
2603
+ # A bare native variable is an expression that prints its value in the REPL.
2604
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", stripped) and stripped in CHEMQL_VARIABLES:
2605
+ return python_globals.get(stripped)
2606
+
2607
+ # History dump command.
2608
+ if stripped.casefold().startswith("dump "):
2609
+ return _dump_history(stripped)
2610
+
2611
+ # A standalone user-defined function call returns its value directly.
2612
+ function_call = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?)\s*\{(.*)\}", stripped, re.DOTALL)
2613
+ if function_call and function_call.group(1) in FUNCTIONS:
2614
+ return _call_function(function_call.group(1), _split_function_arguments(function_call.group(2)))
2615
+
2194
2616
  # [ ... ] is Python.
2195
2617
  if stripped.startswith("["):
2196
2618
  end = find_matching(stripped, 0, "[", "]")
@@ -2237,84 +2659,264 @@ def join_continuations(lines):
2237
2659
  return joined
2238
2660
 
2239
2661
 
2662
+ def _find_unquoted_marker(text, marker, start=0):
2663
+ """Find a marker outside single- and double-quoted strings."""
2664
+ quote = None
2665
+ escaped = False
2666
+ i = start
2667
+ while i <= len(text) - len(marker):
2668
+ char = text[i]
2669
+ if quote is not None:
2670
+ if escaped:
2671
+ escaped = False
2672
+ elif char == "\\":
2673
+ escaped = True
2674
+ elif char == quote:
2675
+ quote = None
2676
+ i += 1
2677
+ continue
2678
+ if char in ("'", '"'):
2679
+ quote = char
2680
+ i += 1
2681
+ continue
2682
+ if text.startswith(marker, i):
2683
+ return i
2684
+ i += 1
2685
+ return -1
2686
+
2687
+
2240
2688
  def process_lines(lines):
2241
- lines = join_continuations(lines)
2242
- lines = [remove_comments(line) for line in lines]
2243
- lines = [line for line in lines if line.strip()]
2689
+ """Process ChemQL source, including multiline Python, terminal, and comments."""
2690
+ lines = join_continuations(list(lines))
2244
2691
  i = 0
2245
2692
 
2246
2693
  while i < len(lines):
2247
-
2248
- line = lines[i]
2249
- stripped = line.strip()
2694
+ raw_line = lines[i]
2695
+ stripped = raw_line.strip()
2250
2696
 
2251
- if not line.strip():
2697
+ if not stripped:
2698
+ i += 1
2252
2699
  continue
2253
2700
 
2254
- if stripped == "[endblock]":
2255
- raise SyntaxError("Unexpected `[endblock]`")
2701
+ # Definitions are processed in source order and affect only later lines.
2702
+ define_match = re.fullmatch(r"define\s+(" + _DEFINITION_NAME + r")\s+(.+)", stripped)
2703
+ if define_match and not stripped.endswith("{}"):
2704
+ DEFINITIONS[define_match.group(1)] = define_match.group(2)
2705
+ i += 1
2706
+ continue
2256
2707
 
2257
- # ----------------------------------------
2258
- # Control-flow block
2259
- # ----------------------------------------
2708
+ remove_definition = re.fullmatch(r"remove\s+definition\s+(" + _DEFINITION_NAME + r")", stripped, re.IGNORECASE)
2709
+ if remove_definition:
2710
+ DEFINITIONS.pop(remove_definition.group(1), None)
2711
+ i += 1
2712
+ continue
2260
2713
 
2261
- if is_python_block_header(stripped):
2262
- end = find_endblock(lines, i + 1)
2263
- body = lines[i + 1:end]
2714
+ if stripped.casefold() == "definitions":
2715
+ if DEFINITIONS:
2716
+ for name, value in DEFINITIONS.items():
2717
+ print(f"@{name} = {value}")
2718
+ else:
2719
+ print("No definitions.")
2720
+ i += 1
2721
+ continue
2264
2722
 
2265
- code = stripped[1:-1].strip()
2723
+ # Function definitions use `func NAME ... endfunc`.
2724
+ function_match = re.fullmatch(r"func\s+(" + _DEFINITION_NAME + r")", stripped, re.IGNORECASE)
2725
+ if function_match:
2726
+ name = function_match.group(1)
2727
+ end = i + 1
2728
+ while end < len(lines) and lines[end].strip().casefold() != "endfunc":
2729
+ end += 1
2730
+ if end >= len(lines):
2731
+ raise SyntaxError(f"Missing `endfunc` for function `{name}`")
2732
+ FUNCTIONS[name] = {"body": [_expand_definitions(source_line) for source_line in lines[i + 1:end]]}
2733
+ i = end + 1
2734
+ continue
2266
2735
 
2267
- if code.startswith("for "):
2268
- execute_for_block(
2269
- stripped,
2270
- body
2271
- )
2736
+ # `define NAME{}` introduces a source-generating macro.
2737
+ macro_match = re.fullmatch(r"define\s+(" + _DEFINITION_NAME + r")\s*\{\s*\}", stripped)
2738
+ if macro_match:
2739
+ name = macro_match.group(1)
2740
+ end = i + 1
2741
+ while end < len(lines) and lines[end].strip().casefold() != "enddefine":
2742
+ end += 1
2743
+ if end >= len(lines):
2744
+ raise SyntaxError(f"Missing `enddefine` for macro `{name}`")
2745
+ MACROS[name] = [_expand_definitions(source_line) for source_line in lines[i + 1:end]]
2746
+ i = end + 1
2747
+ continue
2272
2748
 
2273
- elif code.startswith("while "):
2274
- execute_while_block(
2275
- stripped,
2276
- body
2277
- )
2749
+ # Multiline comments can start/end within a line, but markers inside
2750
+ # quoted strings are ordinary text. Keep any code surrounding a comment.
2751
+ comment_start = _find_unquoted_marker(raw_line, "///")
2752
+ if comment_start >= 0:
2753
+ prefix = raw_line[:comment_start]
2754
+ same_line_end = _find_unquoted_marker(raw_line, "///", comment_start + 3)
2755
+ if same_line_end >= 0:
2756
+ suffix = raw_line[same_line_end + 3:]
2757
+ lines[i] = prefix + suffix
2758
+ if not lines[i].strip():
2759
+ i += 1
2760
+ continue
2761
+ raw_line = lines[i]
2762
+ stripped = raw_line.strip()
2763
+ else:
2764
+ close_line = i + 1
2765
+ close_pos = -1
2766
+ while close_line < len(lines):
2767
+ close_pos = _find_unquoted_marker(lines[close_line], "///")
2768
+ if close_pos >= 0:
2769
+ break
2770
+ close_line += 1
2771
+ if close_line >= len(lines):
2772
+ raise SyntaxError("Missing closing `///` for multiline comment")
2773
+ suffix = lines[close_line][close_pos + 3:]
2774
+ lines[i] = prefix
2775
+ del lines[i + 1:close_line + 1]
2776
+ if suffix.strip():
2777
+ lines.insert(i + 1, suffix)
2778
+ if not prefix.strip():
2779
+ if suffix.strip():
2780
+ i += 1
2781
+ else:
2782
+ continue
2783
+ else:
2784
+ raw_line = prefix
2785
+ stripped = prefix.strip()
2278
2786
 
2279
- elif code.startswith("if "):
2280
- execute_if_block(
2281
- stripped,
2282
- body
2283
- )
2787
+ # Multiline Python block. Closing delimiter must be alone on a line;
2788
+ # this avoids mistaking valid expressions such as [[4]]] for a close.
2789
+ if stripped == "[[[":
2790
+ body = []
2791
+ i += 1
2792
+ while i < len(lines) and lines[i].strip() != "]]]":
2793
+ body.append(lines[i])
2794
+ i += 1
2795
+ if i >= len(lines):
2796
+ raise SyntaxError("Missing closing `]]]` for Python block")
2797
+ body = [_expand_definitions(source_line) for source_line in body]
2798
+ code = "\n".join(body)
2799
+ captured = io.StringIO()
2800
+ try:
2801
+ with contextlib.redirect_stdout(captured):
2802
+ value = evaluate_python(code)
2803
+ output = captured.getvalue()
2804
+ if value is not None:
2805
+ output += str(value)
2806
+ if output:
2807
+ output = strip_ansi(output) if NO_ANSI else output
2808
+ print(output, end="" if output.endswith("\n") else "\n")
2809
+ _record_execution("[[[\n" + code + "\n]]]", output, True)
2810
+ except Exception as exc:
2811
+ message = f"Error: {exc}"
2812
+ print(strip_ansi(message) if NO_ANSI else message, file=sys.stderr)
2813
+ _record_execution("[[[\n" + code + "\n]]]", message, False)
2814
+ i += 1
2815
+ continue
2284
2816
 
2285
- else:
2286
- raise SyntaxError(
2287
- f"Unsupported block `{code}`"
2288
- )
2817
+ # Multiline terminal block, sent as one script to the persistent shell.
2818
+ if stripped in {"```", "```bash", "```sh", "```cmd", "```terminal"}:
2819
+ body = []
2820
+ i += 1
2821
+ while i < len(lines) and lines[i].strip() != "```":
2822
+ body.append(lines[i])
2823
+ i += 1
2824
+ if i >= len(lines):
2825
+ raise SyntaxError("Missing closing ``` for terminal block")
2826
+ body = [_expand_definitions(source_line) for source_line in body]
2827
+ command = "\n".join(body)
2828
+ result = run_shell_command(command)
2829
+ result = strip_ansi(result) if NO_ANSI else result
2830
+ # POSIX PTY commands stream output live. Never print their returned
2831
+ # capture a second time; Windows pipe mode still needs normal printing.
2832
+ if result and (os.name == "nt" or not _shell_output_streamed):
2833
+ print(result)
2834
+ _record_execution("```\n" + command + "\n```", result, not result.startswith("Shell error:"))
2835
+ globals()["_shell_output_streamed"] = False
2836
+ i += 1
2837
+ continue
2289
2838
 
2290
- i = end + 1
2839
+ # Literal definitions are intentionally expanded inside strings too.
2840
+ # Directives themselves were handled above, so updates are not retroactive.
2841
+ raw_line = _expand_definitions(raw_line)
2842
+ line = remove_comments(raw_line).strip()
2843
+ if not line:
2844
+ i += 1
2291
2845
  continue
2292
2846
 
2293
- # ----------------------------------------
2294
- # break / continue
2295
- # ----------------------------------------
2847
+ if line == "[endblock]":
2848
+ raise SyntaxError("Unexpected `[endblock]`")
2296
2849
 
2297
- if stripped == "[break]":
2298
- raise ChemQLBreak()
2850
+ # Existing Python control-flow blocks.
2851
+ if is_python_block_header(line):
2852
+ end = find_endblock(lines, i + 1)
2853
+ body = lines[i + 1:end]
2854
+ code = line[1:-1].strip()
2855
+ if code.startswith("for "):
2856
+ execute_for_block(line, body)
2857
+ elif code.startswith("while "):
2858
+ execute_while_block(line, body)
2859
+ elif code.startswith("if "):
2860
+ execute_if_block(line, body)
2861
+ else:
2862
+ raise SyntaxError(f"Unsupported block `{code}`")
2863
+ i = end + 1
2864
+ continue
2299
2865
 
2300
- if stripped == "[continue]":
2866
+ if line == "[break]":
2867
+ raise ChemQLBreak()
2868
+ if line == "[continue]":
2301
2869
  raise ChemQLContinue()
2302
-
2303
- if stripped == "view":
2870
+ if line == "view":
2304
2871
  view_periodic_table()
2305
2872
  i += 1
2306
2873
  continue
2307
2874
 
2308
-
2309
- # ----------------------------------------
2310
- # Normal chemql-language line
2311
- # ----------------------------------------
2312
-
2313
2875
  for command in split_commands(line):
2314
- result = process_inline(command)
2315
-
2316
- if result is not None:
2317
- print(result)
2876
+ command = command.strip()
2877
+ if not command:
2878
+ continue
2879
+ try:
2880
+ macro_call = re.fullmatch(r"@(" + _DEFINITION_NAME + r")\s*\{(.*)\}", command, re.DOTALL)
2881
+ if macro_call and macro_call.group(1) in MACROS:
2882
+ macro_name = macro_call.group(1)
2883
+ macro_args = _split_function_arguments(macro_call.group(2))
2884
+ expanded_lines = []
2885
+ for macro_line in MACROS[macro_name]:
2886
+ expanded = macro_line
2887
+ for arg_index, arg in enumerate(macro_args, start=1):
2888
+ expanded = re.sub(
2889
+ r"\\" + str(arg_index) + r"(?!\d)",
2890
+ lambda _match, value=arg: value,
2891
+ expanded,
2892
+ )
2893
+ expanded_lines.append(_expand_definitions(expanded))
2894
+ process_lines(expanded_lines)
2895
+ result = None
2896
+ elif command.casefold().startswith("dump "):
2897
+ result = _dump_history(command)
2898
+ else:
2899
+ result = process_inline(command)
2900
+ rendered = "" if result is None else str(result)
2901
+ if result is not None:
2902
+ if NO_ANSI:
2903
+ rendered = strip_ansi(rendered)
2904
+ # PTY-backed backtick commands already stream their output.
2905
+ # Don't echo their captured return value a second time.
2906
+ is_streamed_shell = (
2907
+ os.name != "nt"
2908
+ and len(command) >= 2
2909
+ and command.startswith("`")
2910
+ and command.endswith("`")
2911
+ )
2912
+ if not is_streamed_shell and not _shell_output_streamed:
2913
+ print(rendered)
2914
+ _record_execution(command, rendered, True)
2915
+ globals()["_shell_output_streamed"] = False
2916
+ except Exception as exc:
2917
+ message = f"Error: {exc}"
2918
+ print(strip_ansi(message) if NO_ANSI else message, file=sys.stderr)
2919
+ _record_execution(command, message, False)
2318
2920
 
2319
2921
  i += 1
2320
2922
 
@@ -2558,6 +3160,10 @@ def execute(arguments, query_text=None, positions=None):
2558
3160
 
2559
3161
  if arguments[0].lower() == "sorry":
2560
3162
  return "\033[34mIt's ok, everybody makes mistakes👌\033[0m"
3163
+
3164
+ if arguments[0].lower() == "view":
3165
+ view_periodic_table()
3166
+ return "Check your browser to view periodic table"
2561
3167
 
2562
3168
  if arguments[0].lower() == "version":
2563
3169
  return __version__
@@ -2897,230 +3503,9 @@ def execute(arguments, query_text=None, positions=None):
2897
3503
  )
2898
3504
 
2899
3505
  return format_error(f"Unknown command `{arguments[0]}`")
2900
- HELP = """\033[1;36mChemql - Python Chemistry Query Language\033[0m
2901
-
2902
- \033[1;33mQUERYING\033[0m
2903
- \033[1msearch\033[0m
2904
- Search the current source.
2905
-
2906
- search elements
2907
- search molecules
2908
- search reactions
2909
- search all elements
2910
- search all molecules
2911
- search all reactions
2912
-
2913
- Example:
2914
- search elements name = "Hydrogen"
2915
- search molecules molecular_weight < 20
2916
- search reactions reaction_type = "combustion"
2917
-
2918
- \033[1msource\033[0m
2919
- Change the current source.
2920
-
2921
- source elements
2922
- source molecules
2923
- source reactions
2924
-
2925
- \033[1mfindel <value>\033[0m
2926
- Find an element by name or symbol.
2927
-
2928
- Example:
2929
- findel "He"
2930
- findel "Hydrogen"
2931
-
2932
- \033[1mfindmol <value>\033[0m
2933
- Find a molecule by name or formula.
2934
-
2935
- Example:
2936
- findmol "H2O"
2937
- findmol "Water"
2938
-
2939
- \033[1mfindre <name-or-id>\033[0m
2940
- Find a reaction by name, identifier, or equation.
2941
-
2942
- Example:
2943
- findre "Haber process"
2944
- findre "RHEA:10000" return name equation source
2945
-
2946
- Reaction fields:
2947
- id, name, equation, reaction_type, reversible,
2948
- reactants, products, conditions, notes
2949
-
2950
- Example:
2951
- search reactions name like "*combustion*" return name equation
2952
- search reactions reversible = true return name conditions
2953
-
2954
- \033[1;33mFILTERING\033[0m
2955
- Operators:
2956
- = Equal
2957
- > Greater than
2958
- < Less than
2959
- >= Greater than or equal
2960
- <= Less than or equal
2961
- like Pattern match (case-insensitive)
2962
- like! Pattern match (case-sensitive)
2963
- has Check whether a value contains an item
2964
3506
 
2965
- Logical operators:
2966
- and
2967
- or
3507
+ HELP = '\x1b[1;94m╭──────────────────────────────────────────────────────────────────────────────╮\x1b[0m\n\x1b[1;94m│\x1b[0m \x1b[1;96m✦ \x1b[1mChemQL\x1b[0m \x1b[97mv1.2.0\x1b[0m \x1b[90m│\x1b[0m \x1b[1m\x1b[97mTHE PYTHON CHEMISTRY QUERY LANGUAGE\x1b[0m \x1b[1;94m│\x1b[0m\n\x1b[1;94m│\x1b[0m \x1b[90mQuery chemical datasets · balance equations · embed Python · automate workflows\x1b[0m \x1b[1;94m│\x1b[0m\n\x1b[1;94m╰──────────────────────────────────────────────────────────────────────────────╯\x1b[0m\n\n\x1b[1;93m\x1b[1m▶ QUICK START\x1b[0m\n \x1b[92msearch elements ... \x1b[0m \x1b[90m│\x1b[0m Query the periodic-table dataset\n \x1b[92msearch molecules ... \x1b[0m \x1b[90m│\x1b[0m Query molecule records\n \x1b[92msearch reactions ... \x1b[0m \x1b[90m│\x1b[0m Query stored reaction records\n \x1b[92mfindel "He" \x1b[0m \x1b[90m│\x1b[0m Find an element by name or symbol\n \x1b[92mfindmol "Water" \x1b[0m \x1b[90m│\x1b[0m Find a molecule by name or formula\n \x1b[92mfindre "Haber process" \x1b[0m \x1b[90m│\x1b[0m Find a reaction by name, ID, or equation\n \x1b[92mlist all \x1b[0m \x1b[90m│\x1b[0m List available fields and sources\n \x1b[92mhelp or ? \x1b[0m \x1b[90m│\x1b[0m Show this help reference\n \x1b[92mversion \x1b[0m \x1b[90m│\x1b[0m Show the installed version\n \x1b[92mexit \x1b[0m \x1b[90m│\x1b[0m Leave the interactive prompt\n\n\x1b[1;93m\x1b[1m◆ QUERY SOURCES & EXAMPLES\x1b[0m\n \x1b[92msearch elements [where] \x1b[0m \x1b[90m│\x1b[0m Search element data\n \x1b[92msearch molecules [where] \x1b[0m \x1b[90m│\x1b[0m Search molecule data\n \x1b[92msearch reactions [where] \x1b[0m \x1b[90m│\x1b[0m Search reaction data\n \x1b[1;96msource elements|molecules|reactions\x1b[0m \x1b[90m│\x1b[0m Set the current source\n \x1b[95msearch elements symbol = "Fe" return name symbol number atomic_mass\x1b[0m\n \x1b[95msearch molecules molecular_weight < 50 sort molecular_weight limit 10\x1b[0m\n \x1b[95msearch reactions reversible = true return name equation source\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ FILTERS & LOGIC\x1b[0m\n \x1b[1;96m= != > < >= <= \x1b[0m \x1b[90m│\x1b[0m Comparison operators\n \x1b[1;96mlike / like! \x1b[0m \x1b[90m│\x1b[0m Case-insensitive / case-sensitive wildcard matching\n \x1b[1;96mlikep / has \x1b[0m \x1b[90m│\x1b[0m Pattern matching / contains an item\n \x1b[1;96mand / or / ( ... ) \x1b[0m \x1b[90m│\x1b[0m Combine conditions; parentheses group logic\n \x1b[95msearch elements number > 10 and number < 20\x1b[0m\n \x1b[95msearch molecules name like "*water*"\x1b[0m\n \x1b[95msearch molecules (name like "*water*" or formula = "H2O") and molecular_weight < 25\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ RESULT OPERATIONS\x1b[0m\n \x1b[92mfirst / last \x1b[0m \x1b[90m│\x1b[0m Return the first or last match\n \x1b[92msort FIELD [asc|desc] \x1b[0m \x1b[90m│\x1b[0m Sort matching records\n \x1b[92mlimit N \x1b[0m \x1b[90m│\x1b[0m Limit the number of results\n \x1b[92mcount \x1b[0m \x1b[90m│\x1b[0m Count matches\n \x1b[92mreturn FIELD ... \x1b[0m \x1b[90m│\x1b[0m Choose fields; one field lists, multiple fields form a table\n \x1b[92munion / intersection \x1b[0m \x1b[90m│\x1b[0m Combine result sets\n \x1b[95msearch molecules molecular_weight < 20 sort molecular_weight asc limit 5\x1b[0m\n \x1b[95msearch elements number < 10 union search elements number > 90\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ REACTION TOOLS & CONDITIONS\x1b[0m\n \x1b[1;96mset temperature 723K|150C|300F|standard|room \x1b[0m \x1b[90m│\x1b[0m Set reaction temperature\n \x1b[1;96mset pressure 101325Pa|2bar|1atm|standard|room \x1b[0m \x1b[90m│\x1b[0m Set reaction pressure\n \x1b[1;96mset catalysts iron zinc \x1b[0m \x1b[90m│\x1b[0m Set the catalyst list\n \x1b[1;96mset catalysts \x1b[0m \x1b[90m│\x1b[0m Clear the catalyst list\n \x1b[1;96madd catalyst "Vanadium oxide"\x1b[0m \x1b[90m│\x1b[0m Add a catalyst\n \x1b[1;96mremove catalyst "Vanadium oxide"\x1b[0m \x1b[90m│\x1b[0m Remove a catalyst\n \x1b[1;96mconditions \x1b[0m \x1b[90m│\x1b[0m Show active temperature, pressure, and catalysts\n \x1b[1;96mreact N2 3H2 \x1b[0m \x1b[90m│\x1b[0m Find a stored reaction matching reactants\n \x1b[1;96mbalance H2 + O2 -> H2O \x1b[0m \x1b[90m│\x1b[0m Balance an irreversible equation\n \x1b[1;96mbalance N2 + H2 <-> NH3 \x1b[0m \x1b[90m│\x1b[0m Balance a reversible equation\n \x1b[2mTemperature units: C, F, K. Pressure units include Pa, N/m^2, Nm^-2, bar, and atm.\x1b[0m\n \x1b[2mPresets: standard = 0 C and 100000 Pa; room = 25 C and 101325 Pa.\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ PREPROCESSOR, FUNCTIONS & MACROS · V1.2.0\x1b[0m\n \x1b[95mdefine MAX 9 \x1b[0m \x1b[90m│\x1b[0m Define a literal replacement used as @MAX\n \x1b[95mdefinitions \x1b[0m \x1b[90m│\x1b[0m List current literal definitions\n \x1b[95mremove definition MAX \x1b[0m \x1b[90m│\x1b[0m Remove a definition\n \x1b[95mdefine MAX 9\x1b[0m\n \x1b[95msearch elements number <= @MAX\x1b[0m\n\n \x1b[95mfunc square \x1b[0m \x1b[90m│\x1b[0m Begin a function; finish it with endfunc\n \x1b[95mreturn [\\1 * \\1] \x1b[0m \x1b[90m│\x1b[0m Return a value using positional argument \\1\n \x1b[95mendfunc \x1b[0m \x1b[90m│\x1b[0m Close the function definition\n \x1b[95msquare{5} \x1b[0m \x1b[90m│\x1b[0m Call the function with brace-delimited arguments\n\n \x1b[95mdefine light{} \x1b[0m \x1b[90m│\x1b[0m Begin a macro; finish it with enddefine\n \x1b[95msearch molecules molecular_weight < \\1 return name formula\x1b[0m\n \x1b[95menddefine \x1b[0m \x1b[90m│\x1b[0m Close the macro definition\n \x1b[95m@light{50} \x1b[0m \x1b[90m│\x1b[0m Expand and execute a macro\n \x1b[2mFunction and macro arguments use positional placeholders: \\1, \\2, and so on.\x1b[0m\n \x1b[2mDefinitions are processed in source order and are local to the current session.\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ PYTHON INTEGRATION\x1b[0m\n \x1b[1;96m[x = 10] \x1b[0m \x1b[90m│\x1b[0m Execute a Python statement\n \x1b[1;96m[print(x)] \x1b[0m \x1b[90m│\x1b[0m Use Python variables across commands\n \x1b[1;96m[x * 2] \x1b[0m \x1b[90m│\x1b[0m Evaluate and display a Python expression\n \x1b[95m{{ search elements name = "Hydrogen" }}\x1b[0m\n \x1b[2mEmbedded {{ ... }} queries return their result as a value.\x1b[0m\n Python control-flow blocks:\n \x1b[95m[if condition] ... [elif condition] ... [else] ... [endblock]\x1b[0m\n \x1b[95m[for item in iterable] ... [endblock]\x1b[0m\n \x1b[95m[while condition] ... [endblock]\x1b[0m\n \x1b[95m[break] and [continue] inside loops\x1b[0m\n Multiline Python block:\n \x1b[95m[[[\x1b[0m\n \x1b[95mfor x in range(3):\x1b[0m\n \x1b[95m print(x)\x1b[0m\n \x1b[95m]]] # close on its own line\x1b[0m\n\n\x1b[1;93m\x1b[1m◆ SHELL, COMMENTS & COMMAND COMPOSITION\x1b[0m\n \x1b[1;96m`command` \x1b[0m \x1b[90m│\x1b[0m Run a command in the persistent system shell\n \x1b[1;96m```bash ... ``` \x1b[0m \x1b[90m│\x1b[0m Run a multiline shell block (also sh, cmd, terminal)\n \x1b[1;96m// comment \x1b[0m \x1b[90m│\x1b[0m Single-line comment\n \x1b[1;96m/// ... /// \x1b[0m \x1b[90m│\x1b[0m Multiline comment\n \x1b[1;96mcommand1; command2 \x1b[0m \x1b[90m│\x1b[0m Run multiple commands on one line\n \x1b[1;96mtrailing backslash \x1b[0m \x1b[90m│\x1b[0m Continue a command on the next line\n \x1b[1;96mview \x1b[0m \x1b[90m│\x1b[0m Open the bundled periodic-table viewer\n \x1b[1;96mclear \x1b[0m \x1b[90m│\x1b[0m Clear the terminal\n\n\x1b[1;93m\x1b[1m◆ HISTORY EXPORT\x1b[0m\n \x1b[92mdump FILE \x1b[0m \x1b[90m│\x1b[0m Export all recorded queries and results\n \x1b[92mdump all|good|bad FILE \x1b[0m \x1b[90m│\x1b[0m Export all, successful, or failed executions\n \x1b[92mdump all|good|bad queries FILE\x1b[0m \x1b[90m│\x1b[0m Export query history\n \x1b[92mdump all|good|bad results FILE\x1b[0m \x1b[90m│\x1b[0m Export result history\n\n\x1b[1;93m\x1b[1m◆ COMMAND-LINE OPTIONS\x1b[0m\n \x1b[1;96mchemql \x1b[0m \x1b[90m│\x1b[0m Start the interactive prompt\n \x1b[1;96mchemql -c "COMMAND" \x1b[0m \x1b[90m│\x1b[0m Execute one command\n \x1b[1;96mchemql -f FILE \x1b[0m \x1b[90m│\x1b[0m Execute commands from a file\n \x1b[1;96mchemql --version \x1b[0m \x1b[90m│\x1b[0m Print the installed version\n \x1b[1;96mchemql --no-ansi \x1b[0m \x1b[90m│\x1b[0m Disable ANSI styling in output\n \x1b[1;96mchemql -c "COMMAND" -o FILE \x1b[0m \x1b[90m│\x1b[0m Write command output to a plain-text file\n \x1b[1;96mchemql --upgrade [VERSION] \x1b[0m \x1b[90m│\x1b[0m Upgrade to latest or a chosen release\n \x1b[1;96mchemql --force \x1b[0m \x1b[90m│\x1b[0m Force upgrade past system-package protections\n\n\x1b[1;93m\x1b[1m◆ PYTHON API\x1b[0m\n \x1b[95mfrom chemql import execute_query_text, execute_query\x1b[0m\n \x1b[95mresult = execute_query_text(\'search elements symbol = "O"\')\x1b[0m\n \x1b[2mDistribution package: chemql-python · import / CLI name: chemql\x1b[0m\n\n\x1b[1;93m\x1b[1m⚠ SAFETY & DATA NOTES\x1b[0m\n \x1b[2mPython blocks and shell commands run real code with your user permissions.\x1b[0m\n \x1b[2mRun only code and commands you trust. External lookups need network access\x1b[0m\n \x1b[2mand can fail when a remote service is unavailable.\x1b[0m\n\n\x1b[1;94m────────────────────────────────────────────────────────────────────────────────\x1b[0m\n\x1b[90mChemQL 1.2.0 · Type \x1b[92mhelp\x1b[90m for this guide, or \x1b[92mexit\x1b[90m to quit.\x1b[0m\n'
2968
3508
 
2969
- Parentheses can be used to group conditions.
2970
-
2971
- Example:
2972
- search elements atomic_number > 10 and atomic_number < 20
2973
- search molecules name like "*water*"
2974
- search reactions reaction_type = "combustion"
2975
-
2976
- \033[1;33mREACTIONS\033[0m
2977
- Set reaction conditions before using `react`.
2978
-
2979
- set temperature 723K
2980
- set temperature standard
2981
- set pressure 20265000pa
2982
- set pressure room
2983
- set catalysts iron zinc
2984
- add catalyst "Vanadium oxide"
2985
- remove catalyst "Vanadium oxide"
2986
- set catalysts
2987
- conditions
2988
- react N2 3H2
2989
- balance O2 + H2 -> H2O
2990
- balance O2 + H2 <-> H2O
2991
-
2992
- Temperature accepts C, F, or K. Pressure accepts Pa, N/m^2, Nm^-2, or bar.
2993
- `standard` means 0 C and 100000 Pa; `room` means 25 C and 101325 Pa.
2994
- Unset values appear as `Not Set` in `conditions`.
2995
- Reaction matching allows 5 K temperature and 5% pressure tolerance.
2996
- `balance` returns a Reaction with the smallest positive integer coefficients.
2997
- The `->` arrow is irreversible; `<->` is reversible. `//` starts a comment.
2998
-
2999
- \033[1;33mRESULTS\033[0m
3000
- \033[1mfirst\033[0m
3001
- Return only the first result.
3002
-
3003
- \033[1mlast\033[0m
3004
- Return only the last result.
3005
-
3006
- \033[1msort <key> [asc|desc]\033[0m
3007
- Sort results by an attribute.
3008
-
3009
- Example:
3010
- search elements sort atomic_number
3011
- search molecules sort molecular_weight desc
3012
-
3013
- \033[1mlimit <number>\033[0m
3014
- Limit the number of results.
3015
-
3016
- Example:
3017
- search molecules limit 10
3018
-
3019
- \033[1mcount\033[0m
3020
- Return the number of results.
3021
-
3022
- Example:
3023
- search molecules count
3024
- search reactions reversible = true return name equation
3025
-
3026
- \033[1mreturn <attributes...>\033[0m
3027
- Return only the specified attributes.
3028
-
3029
- One attribute returns a list.
3030
- Multiple attributes return a table.
3031
-
3032
- Example:
3033
- search molecules molecular_weight < 20 return name
3034
-
3035
- search molecules molecular_weight < 20 return name formula molecular_weight
3036
-
3037
- \033[1;33mGENERAL COMMANDS\033[0m
3038
- \033[1mlist all\033[0m
3039
- List keys from the active source or all available sources.
3040
-
3041
- \033[1mview\033[0m
3042
- Open the periodic table viewer in the default browser.
3043
-
3044
- \033[1mclear\033[0m
3045
- Clear the current screen.
3046
-
3047
- \033[1mdump\033[0m
3048
- Export the recent command history to a file.
3049
- Example: dump my_session.txt
3050
-
3051
- \033[1mhelp\033[0m
3052
- Show this help message.
3053
-
3054
- \033[1mexit\033[0m
3055
- Exit Chemql.
3056
-
3057
- \033[1;33mHYBRID PYTHON\033[0m
3058
- Chemql can be combined with Python using [ ... ].
3059
-
3060
- [x = 10]
3061
- [print(x)]
3062
-
3063
- Python control blocks are also supported:
3064
-
3065
- [for x in ...]
3066
- [if ...]
3067
- [elif ...]
3068
- [else]
3069
- [while ...]
3070
- [break]
3071
- [continue]
3072
- [endblock]
3073
-
3074
- ChemQL queries can be embedded directly using {{ ... }}.
3075
- This evaluates the query and inserts its result into the surrounding Python.
3076
-
3077
- {{ search elements name = "Hydrogen" }}
3078
- [print({{ search elements atomic_number = 1 }})]
3079
-
3080
- You can also use a bare query block as a value-producing expression:
3081
-
3082
- {{ search molecules molecular_weight < 20 return name formula }}
3083
-
3084
- \033[1;33mLINE CONTINUATION\033[0m
3085
- A line ending with '\\' continues onto the next line.
3086
-
3087
- search elements \\
3088
- name = "Hydrogen"
3089
-
3090
- This is treated as:
3091
-
3092
- search elements name = "Hydrogen"
3093
-
3094
- \033[1;33mOPTIONS\033[0m
3095
- \033[1m-c <command>\033[0m
3096
- Execute a command.
3097
-
3098
- \033[1m-f <file>\033[0m
3099
- Execute commands from a file.
3100
-
3101
- \033[1m?\033[0m
3102
- Show help.
3103
-
3104
- \033[1;33mEXAMPLES\033[0m
3105
- search elements name = "Hydrogen"
3106
-
3107
- findel "He"
3108
-
3109
- findmol "H2O"
3110
-
3111
- search molecules molecular_weight < 20 \\
3112
- return name formula molecular_weight
3113
-
3114
- search elements atomic_number > 10 \\
3115
- and atomic_number < 20 \\
3116
- sort atomic_number desc \\
3117
- limit 5
3118
-
3119
- search molecules count
3120
-
3121
- \033[1;36mChemql\033[0m
3122
- Python Chemistry Query Language
3123
- """
3124
3509
  import webbrowser
3125
3510
  from pathlib import Path
3126
3511
 
@@ -3128,7 +3513,33 @@ def view_periodic_table():
3128
3513
  path = Path(__file__).parent / "html" /"periodic_table.html"
3129
3514
  webbrowser.open(path.resolve().as_uri())
3130
3515
 
3516
+
3131
3517
  class ChemqlLexer(Lexer):
3518
+ """Syntax highlighting for ChemQL, native variables, and embedded Python."""
3519
+
3520
+ COMMANDS = {
3521
+ "search", "source", "list", "clear", "exit", "findel",
3522
+ "findmol", "findre", "set", "add", "remove", "react",
3523
+ "balance", "conditions", "view", "help", "dump", "version",
3524
+ "return", "union", "intersection", "let", "unset", "variables",
3525
+ }
3526
+
3527
+ OPERATORS = {
3528
+ "and", "or", "has", "like", "like!", "likep", "sort",
3529
+ "limit", "count", "asc", "desc",
3530
+ }
3531
+
3532
+ CONTROL_WORDS = {
3533
+ "if", "elif", "else", "for", "while", "in", "break",
3534
+ "continue", "endblock", "endfunc", "func", "enddefine",
3535
+ "define", "import", "as",
3536
+ }
3537
+
3538
+ CONDITIONS = {
3539
+ "temperature", "pressure", "catalyst", "catalysts",
3540
+ "standard", "room", "c", "f", "k", "pa", "bar",
3541
+ }
3542
+
3132
3543
  def lex_document(self, document):
3133
3544
  lines = document.lines
3134
3545
 
@@ -3138,88 +3549,59 @@ class ChemqlLexer(Lexer):
3138
3549
 
3139
3550
  line = lines[lineno]
3140
3551
  tokens = []
3141
-
3142
3552
  i = 0
3143
- words = line.split()
3144
-
3145
- # Determine basic query context
3146
- first_word = words[0] if words else ""
3147
3553
 
3148
- # search / source / list / return / clear / exit
3149
- if first_word in {"search", "source", "list", "clear", "exit", "findel", "findmol", "findre", "set", "add", "remove", "react", "balance", "conditions", "view", "help", "dump", "version"}:
3150
- command_start = line.find(first_word)
3151
- command_end = command_start + len(first_word)
3152
-
3153
- # Preserve leading whitespace exactly.
3154
- if command_start > 0:
3155
- tokens.append((
3156
- "",
3157
- line[:command_start]
3158
- ))
3159
-
3160
- tokens.append((
3161
- "class:command",
3162
- first_word
3163
- ))
3554
+ # Preserve leading indentation.
3555
+ while i < len(line) and line[i].isspace():
3556
+ start = i
3557
+ while i < len(line) and line[i].isspace():
3558
+ i += 1
3559
+ tokens.append(("", line[start:i]))
3164
3560
 
3165
- i = command_end
3561
+ # Highlight a leading ChemQL command.
3562
+ first_match = re.match(r"[A-Za-z_][A-Za-z0-9_]*", line[i:])
3563
+ if first_match:
3564
+ word = first_match.group(0)
3565
+ if word in self.COMMANDS:
3566
+ tokens.append(("class:command", word))
3567
+ i += len(word)
3166
3568
 
3167
- # Everything else is scanned character-by-character
3168
3569
  while i < len(line):
3169
3570
  # Whitespace
3170
3571
  if line[i].isspace():
3171
3572
  start = i
3172
-
3173
3573
  while i < len(line) and line[i].isspace():
3174
3574
  i += 1
3175
-
3176
3575
  tokens.append(("", line[start:i]))
3177
3576
  continue
3178
3577
 
3179
- # Python/query blocks
3180
- if line.startswith("{{", i):
3181
- end = line.find("}}", i + 2)
3182
-
3183
- if end == -1:
3184
- end = len(line) - 2
3185
-
3186
- tokens.append((
3187
- "class:python",
3188
- line[i:end + 2]
3189
- ))
3190
-
3191
- i = end + 2
3192
- continue
3193
-
3578
+ # Comments
3194
3579
  if line.startswith("//", i):
3580
+ tokens.append(("class:comment", line[i:]))
3581
+ break
3195
3582
 
3196
- end = len(line)
3197
-
3198
- tokens.append((
3199
- "class:comment",
3200
- line[i:end]
3201
- ))
3202
-
3203
- i = end
3204
- continue
3205
-
3206
- if line[i] == "[":
3207
- end = line.find("]", i + 1)
3208
-
3209
- if end == -1:
3210
- end = len(line) - 1
3211
-
3212
- tokens.append((
3213
- "class:python",
3214
- line[i:end + 1]
3215
- ))
3216
-
3217
- i = end + 1
3218
- continue
3219
-
3583
+ # # Embedded ChemQL expressions: {{ ... }}
3584
+ # # Embedded ChemQL expressions: {{ ... }}
3585
+ # if line.startswith("{{", i):
3586
+ # try:
3587
+ # end = find_matching(line, i + 2, "{{", "}}")
3588
+ # except SyntaxError:
3589
+ # # The user may still be typing the expression.
3590
+ # tokens.append(("class:interpolation", line[i:]))
3591
+ # break
3592
+
3593
+ # if end is None:
3594
+ # tokens.append(("class:interpolation", line[i:]))
3595
+ # break
3596
+
3597
+ # end += 2
3598
+ # tokens.append(("class:interpolation", line[i:end]))
3599
+ # i = end
3600
+ # continue
3601
+
3602
+ # Backtick shell commands
3220
3603
  if line[i] == "`":
3221
3604
  end = line.find("`", i + 1)
3222
-
3223
3605
  if end == -1:
3224
3606
  end = len(line) - 1
3225
3607
 
@@ -3227,10 +3609,28 @@ class ChemqlLexer(Lexer):
3227
3609
  "class:terminalcommand",
3228
3610
  line[i:end + 1]
3229
3611
  ))
3230
-
3231
3612
  i = end + 1
3232
3613
  continue
3233
3614
 
3615
+ # # Python blocks and single-line Python statements
3616
+ # if line[i] == "[":
3617
+ # try:
3618
+ # end = find_matching(line, i + 1, "[", "]")
3619
+ # except SyntaxError:
3620
+ # # An unfinished bracket is valid while editing.
3621
+ # tokens.append(("class:python", line[i:]))
3622
+ # break
3623
+
3624
+ # if end is not None:
3625
+ # end += 1
3626
+ # tokens.append(("class:python", line[i:end]))
3627
+ # i = end
3628
+ # continue
3629
+
3630
+ # tokens.append(("class:python", line[i]))
3631
+ # i += 1
3632
+ # continue
3633
+
3234
3634
  # Strings
3235
3635
  if line[i] in "\"'":
3236
3636
  quote = line[i]
@@ -3241,57 +3641,46 @@ class ChemqlLexer(Lexer):
3241
3641
  if line[i] == "\\":
3242
3642
  i += 2
3243
3643
  continue
3244
-
3245
3644
  if line[i] == quote:
3246
3645
  i += 1
3247
3646
  break
3248
-
3249
3647
  i += 1
3250
3648
 
3251
- tokens.append((
3252
- "class:string",
3253
- line[start:i]
3254
- ))
3255
-
3256
- continue
3257
-
3258
- # Operators
3259
- matched_operator = None
3260
-
3261
- for operator in ("!=", ">=", "<=", "=", ">", "<"):
3262
- if line.startswith(operator, i):
3263
- matched_operator = operator
3264
- break
3265
-
3266
- if matched_operator:
3267
- tokens.append((
3268
- "class:operator",
3269
- matched_operator
3270
- ))
3271
-
3272
- i += len(matched_operator)
3649
+ tokens.append(("class:string", line[start:i]))
3273
3650
  continue
3274
3651
 
3275
3652
  # Numbers
3276
3653
  if line[i].isdigit():
3277
3654
  start = i
3278
-
3655
+ i += 1
3279
3656
  while i < len(line) and (
3280
- line[i].isdigit() or line[i] in ".-"
3657
+ line[i].isdigit() or line[i] == "."
3281
3658
  ):
3282
3659
  i += 1
3283
3660
 
3284
- tokens.append((
3285
- "class:number",
3286
- line[start:i]
3287
- ))
3661
+ tokens.append(("class:number", line[start:i]))
3662
+ continue
3288
3663
 
3664
+ # Operators
3665
+ operator = next(
3666
+ (
3667
+ op for op in (
3668
+ "!=", ">=", "<=", "==", "=", ">", "<",
3669
+ "+=", "-=", "*=", "/=", "+", "-", "*", "/",
3670
+ )
3671
+ if line.startswith(op, i)
3672
+ ),
3673
+ None,
3674
+ )
3675
+
3676
+ if operator:
3677
+ tokens.append(("class:operator", operator))
3678
+ i += len(operator)
3289
3679
  continue
3290
3680
 
3291
- # Words
3681
+ # Identifiers and keywords
3292
3682
  if line[i].isalpha() or line[i] == "_":
3293
3683
  start = i
3294
-
3295
3684
  while i < len(line) and (
3296
3685
  line[i].isalnum() or line[i] == "_"
3297
3686
  ):
@@ -3299,84 +3688,38 @@ class ChemqlLexer(Lexer):
3299
3688
 
3300
3689
  word = line[start:i]
3301
3690
 
3302
- # Commands
3303
- if word in {
3304
- "search",
3305
- "source",
3306
- "list",
3307
- "clear",
3308
- "exit",
3309
- "return",
3310
- "set",
3311
- "add",
3312
- "remove",
3313
- "react",
3314
- "balance",
3315
- "conditions",
3316
- "view",
3317
- "help",
3318
- "dump",
3319
- "union",
3320
- "intersection",
3321
- }:
3691
+ if word in self.COMMANDS:
3322
3692
  style = "class:command"
3323
-
3324
- # Sources
3693
+ elif word in self.CONTROL_WORDS:
3694
+ style = "class:control"
3695
+ elif word in {"True", "False", "None"}:
3696
+ style = "class:number"
3697
+ elif word in {"print", "len", "str", "int", "float", "range"}:
3698
+ style = "class:python"
3325
3699
  elif word in SOURCES:
3326
3700
  style = "class:source"
3327
-
3328
- elif word.casefold() in {"temperature", "pressure", "catalyst", "catalysts", "standard", "room", "c", "f", "k", "pa", "bar"}:
3701
+ elif word.casefold() in self.CONDITIONS:
3329
3702
  style = "class:condition"
3330
-
3331
- # Operators
3332
- elif word in {
3333
- "and",
3334
- "or",
3335
- "has",
3336
- "like",
3337
- "like!",
3338
- "likep",
3339
- "sort",
3340
- "limit",
3341
- "count",
3342
- "asc",
3343
- "desc"
3344
- }:
3703
+ elif word in self.OPERATORS:
3345
3704
  style = "class:operator"
3346
-
3347
- # Control flow
3348
- elif word in {
3349
- "if",
3350
- "elif",
3351
- "else",
3352
- "for",
3353
- "while",
3354
- "in",
3355
- "break",
3356
- "continue",
3357
- "endblock"
3358
- }:
3359
- style = "class:control"
3360
-
3361
- # Python constants
3362
- elif word in {
3363
- "True",
3364
- "False",
3365
- "None"
3366
- }:
3367
- style = "class:number"
3368
-
3369
- # Dataset keys
3370
- elif word in elementKeys or word in moleculeKeys or word in reactionKeys:
3705
+ elif (
3706
+ word in CHEMQL_VARIABLES
3707
+ or word in python_globals
3708
+ ):
3709
+ style = "class:variable"
3710
+ elif (
3711
+ word in elementKeys
3712
+ or word in moleculeKeys
3713
+ or word in reactionKeys
3714
+ ):
3371
3715
  style = "class:key"
3372
-
3373
3716
  else:
3374
3717
  style = ""
3375
3718
 
3376
3719
  tokens.append((style, word))
3377
3720
  continue
3378
3721
 
3379
- # Anything else
3722
+ # Other punctuation
3380
3723
  tokens.append(("", line[i]))
3381
3724
  i += 1
3382
3725
 
@@ -3384,62 +3727,131 @@ class ChemqlLexer(Lexer):
3384
3727
 
3385
3728
  return get_line
3386
3729
 
3730
+
3387
3731
  class ChemqlCompleter(Completer):
3732
+ """Context-aware completion for ChemQL commands and variables."""
3733
+
3734
+ COMMANDS = (
3735
+ "search", "source", "list", "clear", "return", "exit",
3736
+ "findel", "findmol", "findre", "set", "add", "remove",
3737
+ "react", "balance", "conditions", "version", "help", "view",
3738
+ "dump", "let", "unset", "variables", "func", "define",
3739
+ "import",
3740
+ )
3741
+
3742
+ QUERY_OPERATORS = [
3743
+ "=", "!=", ">", "<", ">=", "<=", "like", "like!",
3744
+ "likep", "has", "sort", "limit", "count", "asc",
3745
+ "desc", "return", "and", "or",
3746
+ ]
3747
+
3388
3748
  def get_completions(self, document, complete_event):
3389
3749
  text = document.text_before_cursor
3390
3750
  parts = text.split()
3391
3751
  trailing_space = bool(text and text[-1].isspace())
3752
+
3392
3753
  current = "" if trailing_space or not parts else parts[-1]
3393
3754
  command_words = parts if trailing_space else parts[:-1]
3394
- commands = (
3395
- "search", "source", "list", "clear", "return", "exit",
3396
- "findel", "findmol", "findre", "set", "add", "remove",
3397
- "react", "balance", "conditions", "version", "help", "view"
3398
- )
3399
3755
 
3756
+ def emit(suggestions):
3757
+ seen = set()
3758
+ for suggestion in suggestions:
3759
+ suggestion = str(suggestion)
3760
+ key = suggestion.casefold()
3761
+
3762
+ if (
3763
+ key not in seen
3764
+ and key.startswith(current.casefold())
3765
+ ):
3766
+ seen.add(key)
3767
+ yield Completion(
3768
+ suggestion,
3769
+ start_position=-len(current),
3770
+ )
3771
+
3772
+ # At the beginning of a command, suggest commands and variables.
3400
3773
  if not command_words:
3401
- for command in commands:
3402
- if command.startswith(current):
3403
- yield Completion(command, start_position=-len(current))
3774
+ suggestions = list(self.COMMANDS)
3775
+ suggestions.extend(
3776
+ sorted(CHEMQL_VARIABLES)
3777
+ )
3778
+ yield from emit(suggestions)
3404
3779
  return
3405
3780
 
3406
3781
  command = command_words[0]
3407
3782
  suggestions = []
3408
3783
 
3409
- if len(command_words) == 1:
3410
- if command == "search":
3411
- suggestions = list(SOURCES)
3412
- elif command == "source":
3784
+ # Native variable syntax
3785
+ if command == "let":
3786
+ if len(command_words) == 1:
3787
+ suggestions = ["name = value"]
3788
+ elif len(command_words) >= 2:
3789
+ suggestions = ["="]
3790
+
3791
+ elif command == "unset":
3792
+ suggestions = sorted(CHEMQL_VARIABLES)
3793
+
3794
+ elif command == "variables":
3795
+ suggestions = []
3796
+
3797
+ # Source names
3798
+ elif command in {"search", "source"}:
3799
+ if len(command_words) == 1:
3413
3800
  suggestions = list(SOURCES)
3414
- elif command == "set":
3415
- suggestions = ["temperature", "pressure", "catalysts"]
3416
- elif command in {"add", "remove"}:
3801
+ elif command == "search" and len(command_words) >= 2:
3802
+ source_name = command_words[1]
3803
+ if source_name in SOURCES:
3804
+ _, keys = SOURCES[source_name]
3805
+ suggestions = list(keys) + self.QUERY_OPERATORS
3806
+
3807
+ # Reaction settings
3808
+ elif command == "set":
3809
+ if len(command_words) == 1:
3810
+ suggestions = [
3811
+ "temperature", "pressure", "catalysts"
3812
+ ]
3813
+ elif len(command_words) == 2:
3814
+ setting = command_words[1].casefold()
3815
+
3816
+ if setting == "temperature":
3817
+ suggestions = [
3818
+ "standard", "room", "0C", "0F", "0K"
3819
+ ]
3820
+ elif setting == "pressure":
3821
+ suggestions = [
3822
+ "standard", "room", "0Pa",
3823
+ "0N/m^2", "0Nm^-2", "0bar",
3824
+ ]
3825
+ elif setting == "catalysts":
3826
+ suggestions = list(
3827
+ reaction_state["catalysts"]
3828
+ )
3829
+
3830
+ # Catalyst management
3831
+ elif command in {"add", "remove"}:
3832
+ if len(command_words) == 1:
3417
3833
  suggestions = ["catalyst"]
3834
+ elif (
3835
+ command == "remove"
3836
+ and len(command_words) == 2
3837
+ and command_words[1] == "catalyst"
3838
+ ):
3839
+ suggestions = list(
3840
+ reaction_state["catalysts"]
3841
+ )
3418
3842
 
3419
- elif command == "set" and len(command_words) == 2:
3420
- setting = command_words[1].casefold()
3421
- if setting == "temperature":
3422
- suggestions = ["standard", "room", "0C", "0F", "0K"]
3423
- elif setting == "pressure":
3424
- suggestions = ["standard", "room", "0Pa", "0N/m^2", "0Nm^-2", "0bar"]
3425
- elif setting == "catalysts":
3426
- suggestions = list(reaction_state["catalysts"])
3427
-
3428
- elif command == "search" and len(command_words) >= 2:
3429
- source_name = command_words[1]
3430
- if source_name in SOURCES:
3431
- _, keys = SOURCES[source_name]
3432
- suggestions = list(keys) + [
3433
- "=", ">", "<", ">=", "<=", "!=", "like", "like!", "likep", "has",
3434
- "sort", "limit", "count", "asc", "desc", "return",
3435
- ]
3843
+ # Variable names can be useful in expressions.
3844
+ elif command in {"print", "return"}:
3845
+ suggestions = sorted(CHEMQL_VARIABLES)
3436
3846
 
3437
- elif command == "remove" and len(command_words) == 2 and command_words[1] == "catalyst":
3438
- suggestions = list(reaction_state["catalysts"])
3847
+ # Offer native variables in other expression contexts too.
3848
+ if not suggestions and (
3849
+ "{{" in text
3850
+ or text.lstrip().startswith("[")
3851
+ ):
3852
+ suggestions = sorted(CHEMQL_VARIABLES)
3439
3853
 
3440
- for suggestion in suggestions:
3441
- if suggestion.casefold().startswith(current.casefold()):
3442
- yield Completion(suggestion, start_position=-len(current))
3854
+ yield from emit(suggestions)
3443
3855
 
3444
3856
 
3445
3857
 
@@ -3539,7 +3951,7 @@ def intersection(x, y):
3539
3951
 
3540
3952
  PACKAGE_NAME = "chemql-python"
3541
3953
 
3542
- def upgrade_package(version=None):
3954
+ def upgrade_package(version=None, force=False):
3543
3955
  """Upgrade ChemQL, optionally to a specific version."""
3544
3956
 
3545
3957
  if version is not None:
@@ -3555,16 +3967,28 @@ def upgrade_package(version=None):
3555
3967
 
3556
3968
  target = f"{PACKAGE_NAME}=={version}"
3557
3969
  print(f"Installing {target}...")
3558
- cmd = [
3559
- sys.executable, "-m", "pip", "install",
3560
- "--force-reinstall", target,
3561
- ]
3970
+ if force:
3971
+ cmd = [
3972
+ sys.executable, "-m", "pip", "install",
3973
+ "--force-reinstall", target, "--break-system-packages"
3974
+ ]
3975
+ else:
3976
+ cmd = [
3977
+ sys.executable, "-m", "pip", "install",
3978
+ "--force-reinstall", target,
3979
+ ]
3562
3980
  else:
3563
3981
  print(f"Upgrading {PACKAGE_NAME} to the latest version...")
3564
- cmd = [
3565
- sys.executable, "-m", "pip", "install",
3566
- "--upgrade", PACKAGE_NAME,
3567
- ]
3982
+ if force:
3983
+ cmd = [
3984
+ sys.executable, "-m", "pip", "install",
3985
+ "--upgrade", PACKAGE_NAME, "--break-system-packages"
3986
+ ]
3987
+ else:
3988
+ cmd = [
3989
+ sys.executable, "-m", "pip", "install",
3990
+ "--upgrade", PACKAGE_NAME,
3991
+ ]
3568
3992
 
3569
3993
  try:
3570
3994
  result = subprocess.run(cmd, check=False)
@@ -3607,6 +4031,18 @@ def main():
3607
4031
  help="Execute Chemql code from a file"
3608
4032
  )
3609
4033
 
4034
+ parser.add_argument(
4035
+ "--no-ansi",
4036
+ action="store_true",
4037
+ help="Strip ANSI escape sequences from output.",
4038
+ )
4039
+
4040
+ parser.add_argument(
4041
+ "-o", "--output",
4042
+ metavar="FILE",
4043
+ help="Write -c or -f output to a file without ANSI sequences.",
4044
+ )
4045
+
3610
4046
  parser.add_argument(
3611
4047
  "-v",
3612
4048
  "--version",
@@ -3624,6 +4060,13 @@ def main():
3624
4060
  help="Upgrade ChemQL, optionally to a specific version.",
3625
4061
  )
3626
4062
 
4063
+ parser.add_argument(
4064
+ "--force",
4065
+ "--dangerously",
4066
+ dest="force",
4067
+ action="store_true",
4068
+ help="Force the upgrade, including breaking system-package protections.",
4069
+ )
3627
4070
 
3628
4071
  args = parser.parse_args()
3629
4072
 
@@ -3631,20 +4074,35 @@ def main():
3631
4074
  version_target = (
3632
4075
  None if args.upgrade == "latest" else args.upgrade
3633
4076
  )
3634
- sys.exit(upgrade_package(version_target))
4077
+ sys.exit(upgrade_package(version_target, args.force))
4078
+
4079
+ global NO_ANSI
4080
+ NO_ANSI = args.no_ansi or bool(args.output)
3635
4081
 
3636
- if args.command is not None:
3637
- process_lines(args.command.splitlines())
4082
+ if args.output and args.command is None and args.file is None:
4083
+ parser.error("-o/--output requires -c/--command or -f/--file")
4084
+
4085
+ if args.command is not None or args.file:
4086
+ if args.command is not None:
4087
+ code = args.command
4088
+ else:
4089
+ code = Path(args.file).read_text(encoding="utf-8")
4090
+
4091
+ if args.output:
4092
+ stdout_capture = io.StringIO()
4093
+ stderr_capture = io.StringIO()
4094
+ with contextlib.redirect_stdout(stdout_capture), contextlib.redirect_stderr(stderr_capture):
4095
+ process_lines(code.splitlines())
4096
+ output_text = stdout_capture.getvalue() + stderr_capture.getvalue()
4097
+ Path(args.output).write_text(strip_ansi(output_text), encoding="utf-8")
4098
+ else:
4099
+ process_lines(code.splitlines())
3638
4100
  return
3639
- elif args.file:
3640
- with open(args.file) as f:
3641
- code = f.read()
3642
- process_lines(code.splitlines())
3643
4101
 
3644
- elif not sys.stdin.isatty():
4102
+ if not sys.stdin.isatty():
3645
4103
  piped = sys.stdin.read()
3646
4104
  if piped.strip():
3647
- process_lines([piped.strip()])
4105
+ process_lines(piped.splitlines())
3648
4106
  else:
3649
4107
  run_interactive()
3650
4108
  else:
@@ -3652,12 +4110,62 @@ def main():
3652
4110
 
3653
4111
  def run_interactive():
3654
4112
  history = []
3655
- print("\033[1mType help or ? for help\033[0m")
4113
+ welcome = "Type help or ? for help"
4114
+ print(welcome if NO_ANSI else "\033[1m" + welcome + "\033[0m")
4115
+ bindings = KeyBindings()
4116
+
4117
+ def multiline_input_needs_more(text):
4118
+ """Return True when Enter should add a newline instead of submitting."""
4119
+ if not text:
4120
+ return False
4121
+ source_lines = text.splitlines()
4122
+ first = source_lines[0].strip()
4123
+ last = source_lines[-1].strip()
4124
+
4125
+ if last.endswith("\\"):
4126
+ return True
4127
+
4128
+ if first in {"[[[", "```", "```bash", "```sh", "```cmd", "```terminal"}:
4129
+ closer = "]]]" if first == "[[[" else "```"
4130
+ return not (len(source_lines) > 1 and last == closer)
4131
+
4132
+ if re.fullmatch(r"func\s+" + _DEFINITION_NAME, first, re.IGNORECASE):
4133
+ return last.casefold() != "endfunc"
4134
+
4135
+ if re.fullmatch(r"define\s+" + _DEFINITION_NAME + r"\s*\{\s*\}", first):
4136
+ return last.casefold() != "enddefine"
4137
+
4138
+ if "///" in first:
4139
+ return sum(line.count("///") for line in source_lines) < 2
4140
+
4141
+ if is_python_block_header(first):
4142
+ depth = 0
4143
+ for line in source_lines:
4144
+ stripped = line.strip()
4145
+ if is_python_block_header(stripped):
4146
+ depth += 1
4147
+ elif stripped == "[endblock]":
4148
+ depth -= 1
4149
+ return depth > 0
4150
+
4151
+ return False
4152
+
4153
+ @bindings.add(Keys.Enter)
4154
+ def _handle_enter(event):
4155
+ buffer = event.current_buffer
4156
+ if multiline_input_needs_more(buffer.text):
4157
+ buffer.insert_text("\n")
4158
+ else:
4159
+ buffer.validate_and_handle()
4160
+
3656
4161
  session = PromptSession(
3657
4162
  lexer=ChemqlLexer(),
3658
4163
  completer=ChemqlCompleter(),
3659
- style=chemql_style,
4164
+ style=Style.from_dict({}) if NO_ANSI else chemql_style,
3660
4165
  reserve_space_for_menu=0,
4166
+ multiline=True,
4167
+ key_bindings=bindings,
4168
+ prompt_continuation=lambda width, line_number, is_soft_wrap: "... ",
3661
4169
  )
3662
4170
  while True:
3663
4171
  try:
@@ -3668,7 +4176,8 @@ def run_interactive():
3668
4176
  break
3669
4177
 
3670
4178
  except EOFError:
3671
- print("\n\033[34mUse \033[1mCTRL+C\033[22m or type '\033[1mexit\033[22m' to exit\033[0m")
4179
+ message = "\nUse CTRL+C or type 'exit' to exit"
4180
+ print(message if NO_ANSI else "\n\033[34mUse \033[1mCTRL+C\033[22m or type '\033[1mexit\033[22m' to exit\033[0m")
3672
4181
  continue
3673
4182
  if first_line.strip() == "":
3674
4183
  continue
@@ -3676,94 +4185,135 @@ def run_interactive():
3676
4185
  break
3677
4186
 
3678
4187
  elif first_line.strip() in ("?", "help"):
3679
- print(HELP)
4188
+ print(strip_ansi(HELP) if NO_ANSI else HELP)
3680
4189
  continue
3681
4190
 
3682
4191
  elif first_line.strip().lower() == "sorry":
3683
- print("\033[34mIt's ok, everybody makes mistakes👌\033[0m")
4192
+ message = "It's ok, everybody makes mistakes👌"
4193
+ print(message if NO_ANSI else "\033[34m" + message + "\033[0m")
3684
4194
  continue
3685
4195
 
3686
- elif first_line.strip().split()[0] == "dump":
3687
- arguments = first_line.strip().split()
3688
-
3689
- if len(arguments) != 2:
3690
- print("Usage: dump <file>")
3691
- else:
3692
- filename = arguments[1]
3693
-
3694
- try:
3695
- with open(filename, "x") as f:
3696
- f.write("\n".join(history))
3697
-
3698
- except FileExistsError:
3699
- action = choice(
3700
- message=f"`{filename}` already exists. What would you like to do?",
3701
- options=[("append", "Append to existing file"), ("overwrite", "Overwrite existing file"), ("cancel", "Cancel")],
3702
- )
3703
-
3704
- if action == "append":
3705
- with open(filename, "a") as f:
3706
- f.write("\n" + "\n".join(history))
3707
-
3708
- elif action == "overwrite":
3709
- with open(filename, "w") as f:
3710
- f.write("\n".join(history))
3711
-
4196
+ elif first_line.strip().split()[0].casefold() == "dump":
4197
+ try:
4198
+ print(_dump_history(first_line.strip()))
4199
+ except Exception as exc:
4200
+ print(f"Error: {exc}")
3712
4201
  continue
3713
4202
 
3714
4203
  if not first_line.strip():
3715
4204
  continue
3716
4205
 
3717
- # Collect continued input.
3718
- lines = []
3719
- current_line = first_line
4206
+ # PromptSession now edits multiline blocks in one buffer, so the
4207
+ # user can move across lines and Backspace can join them.
4208
+ lines = first_line.splitlines() or [first_line]
4209
+
4210
+ # Preserve the existing backslash continuation behavior for commands
4211
+ # that were not collected as a block by the Enter key binding.
4212
+ while lines[-1].rstrip().endswith("\\"):
4213
+ lines[-1] = lines[-1].rstrip()[:-1] + " "
4214
+ try:
4215
+ next_line = session.prompt("... ")
4216
+ except KeyboardInterrupt:
4217
+ print()
4218
+ lines = []
4219
+ break
4220
+ except EOFError:
4221
+ message = "Error: Incomplete continued input"
4222
+ print("\n" + (message if NO_ANSI else "\033[31m" + message + "\033[0m"))
4223
+ lines = []
4224
+ break
4225
+ lines.extend(next_line.splitlines() or [next_line])
4226
+ if not lines:
4227
+ continue
3720
4228
 
3721
- while True:
3722
- if current_line.rstrip().endswith("\\"):
3723
- current_line = current_line.rstrip()[:-1] + " "
4229
+ # Collect function and macro declarations as complete REPL units.
4230
+ if lines and re.fullmatch(r"func\s+" + _DEFINITION_NAME, lines[0].strip(), re.IGNORECASE):
4231
+ while lines[-1].strip().casefold() != "endfunc":
4232
+ try:
4233
+ lines.append(session.prompt("... "))
4234
+ except (KeyboardInterrupt, EOFError):
4235
+ print("\nIncomplete function definition")
4236
+ lines = []
4237
+ break
4238
+ if not lines:
4239
+ continue
4240
+ elif lines and re.fullmatch(r"define\s+" + _DEFINITION_NAME + r"\s*\{\s*\}", lines[0].strip()):
4241
+ while lines[-1].strip().casefold() != "enddefine":
4242
+ try:
4243
+ lines.append(session.prompt("... "))
4244
+ except (KeyboardInterrupt, EOFError):
4245
+ print("\nIncomplete macro definition")
4246
+ lines = []
4247
+ break
4248
+ if not lines:
4249
+ continue
3724
4250
 
4251
+ # Collect new multiline Python, terminal, and comment blocks in the REPL.
4252
+ if lines and lines[0].strip() in {"[[[", "```", "```bash", "```sh", "```cmd", "```terminal"}:
4253
+ opener = lines[0].strip()
4254
+ closer = "]]]" if opener == "[[[" else "```"
4255
+ while not (len(lines) > 1 and lines[-1].strip() == closer):
3725
4256
  try:
3726
4257
  next_line = session.prompt("... ")
3727
- except KeyboardInterrupt:
3728
- print()
3729
- break
3730
- except EOFError:
3731
- print("\n\033[31mError: Incomplete continued input\033[0m")
4258
+ except (KeyboardInterrupt, EOFError):
4259
+ print("\nIncomplete multiline block")
4260
+ lines = []
3732
4261
  break
4262
+ lines.append(next_line)
4263
+ if not lines:
4264
+ continue
3733
4265
 
3734
- current_line += next_line
3735
- else:
3736
- lines.append(current_line)
3737
- break
4266
+ elif lines and "///" in lines[0]:
4267
+ # Collect multiline comment until its closing marker.
4268
+ while sum(line.count("///") for line in lines) < 2:
4269
+ try:
4270
+ next_line = session.prompt("... ")
4271
+ except (KeyboardInterrupt, EOFError):
4272
+ print("\nIncomplete multiline comment")
4273
+ lines = []
4274
+ break
4275
+ lines.append(next_line)
4276
+ if not lines:
4277
+ continue
3738
4278
 
3739
- # Collect a Python control block until its matching [endblock].
4279
+ # Collect a Python control block only if the single editable buffer
4280
+ # was submitted before its matching [endblock] was entered.
3740
4281
  if lines and is_python_block_header(lines[0].strip()):
3741
- depth = 1
4282
+ depth = 0
4283
+ for existing_line in lines:
4284
+ if is_python_block_header(existing_line.strip()):
4285
+ depth += 1
4286
+ elif existing_line.strip() == "[endblock]":
4287
+ depth -= 1
3742
4288
 
3743
- while depth:
4289
+ while depth > 0:
3744
4290
  try:
3745
4291
  line = session.prompt(">>> ")
3746
4292
  except KeyboardInterrupt:
3747
4293
  print()
4294
+ lines = []
3748
4295
  break
3749
4296
  except EOFError:
3750
- print("\n\033[31mMissing `\033[1m[endblock]\033[22m`\033[0m")
4297
+ message = "Missing `[endblock]`"
4298
+ print("\n" + (message if NO_ANSI else "\033[31mMissing `\033[1m[endblock]\033[22m`\033[0m"))
4299
+ lines = []
3751
4300
  break
3752
4301
 
3753
- lines.append(line)
3754
-
3755
- if is_python_block_header(line.strip()):
3756
- depth += 1
3757
- elif line.strip() == "[endblock]":
3758
- depth -= 1
3759
- else:
3760
- pass
4302
+ lines.extend(line.splitlines() or [line])
4303
+ for added_line in (line.splitlines() or [line]):
4304
+ if is_python_block_header(added_line.strip()):
4305
+ depth += 1
4306
+ elif added_line.strip() == "[endblock]":
4307
+ depth -= 1
4308
+ if not lines:
4309
+ continue
3761
4310
 
3762
4311
  try:
3763
4312
  process_lines(lines)
3764
4313
  history.extend(lines)
3765
4314
  except Exception as e:
3766
- print(f"\033[31mError: {e}\033[0m")
4315
+ message = f"Error: {e}"
4316
+ print(message if NO_ANSI else f"\033[31m{message}\033[0m")
3767
4317
 
3768
4318
 
3769
4319
  if __name__ == "__main__":