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 +1169 -619
- chemql_python-1.2.0.dist-info/METADATA +1067 -0
- {chemql_python-1.0.3.dist-info → chemql_python-1.2.0.dist-info}/RECORD +7 -7
- chemql_python-1.0.3.dist-info/METADATA +0 -443
- {chemql_python-1.0.3.dist-info → chemql_python-1.2.0.dist-info}/WHEEL +0 -0
- {chemql_python-1.0.3.dist-info → chemql_python-1.2.0.dist-info}/entry_points.txt +0 -0
- {chemql_python-1.0.3.dist-info → chemql_python-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {chemql_python-1.0.3.dist-info → chemql_python-1.2.0.dist-info}/top_level.txt +0 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
+
"""Return the persistent shell process.
|
|
67
219
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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=
|
|
82
|
-
stdout=
|
|
83
|
-
stderr=
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2242
|
-
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
|
-
|
|
2249
|
-
stripped = line.strip()
|
|
2694
|
+
raw_line = lines[i]
|
|
2695
|
+
stripped = raw_line.strip()
|
|
2250
2696
|
|
|
2251
|
-
if not
|
|
2697
|
+
if not stripped:
|
|
2698
|
+
i += 1
|
|
2252
2699
|
continue
|
|
2253
2700
|
|
|
2254
|
-
|
|
2255
|
-
|
|
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
|
-
|
|
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
|
|
2262
|
-
|
|
2263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
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
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
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
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
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
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2295
|
-
# ----------------------------------------
|
|
2847
|
+
if line == "[endblock]":
|
|
2848
|
+
raise SyntaxError("Unexpected `[endblock]`")
|
|
2296
2849
|
|
|
2297
|
-
|
|
2298
|
-
|
|
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
|
|
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
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
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
|
-
#
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
if
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
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]
|
|
3657
|
+
line[i].isdigit() or line[i] == "."
|
|
3281
3658
|
):
|
|
3282
3659
|
i += 1
|
|
3283
3660
|
|
|
3284
|
-
tokens.append((
|
|
3285
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
"
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
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
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
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 == "
|
|
3415
|
-
|
|
3416
|
-
|
|
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
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
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
|
-
|
|
3438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
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
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
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
|
|
3637
|
-
|
|
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
|
-
|
|
4102
|
+
if not sys.stdin.isatty():
|
|
3645
4103
|
piped = sys.stdin.read()
|
|
3646
4104
|
if piped.strip():
|
|
3647
|
-
process_lines(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
print("
|
|
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
|
-
#
|
|
3718
|
-
lines
|
|
3719
|
-
|
|
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
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
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
|
-
|
|
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__":
|