chemql-python 1.3.2__py3-none-any.whl → 1.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chemql/__init__.py +850 -42
- chemql/stdlib/README.md +40 -0
- chemql/stdlib/collections.cql +29 -0
- chemql/stdlib/datetime.cql +32 -0
- chemql/stdlib/json.cql +14 -0
- chemql/stdlib/math/statistics.cql +41 -0
- chemql/stdlib/math.cql +86 -0
- chemql/stdlib/text.cql +77 -0
- {chemql_python-1.3.2.dist-info → chemql_python-1.4.1.dist-info}/METADATA +93 -6
- chemql_python-1.4.1.dist-info/RECORD +23 -0
- {chemql_python-1.3.2.dist-info → chemql_python-1.4.1.dist-info}/WHEEL +1 -1
- chemql_python-1.3.2.dist-info/RECORD +0 -16
- {chemql_python-1.3.2.dist-info → chemql_python-1.4.1.dist-info}/entry_points.txt +0 -0
- {chemql_python-1.3.2.dist-info → chemql_python-1.4.1.dist-info}/licenses/LICENSE +0 -0
- {chemql_python-1.3.2.dist-info → chemql_python-1.4.1.dist-info}/top_level.txt +0 -0
chemql/__init__.py
CHANGED
|
@@ -5,10 +5,12 @@ import contextlib
|
|
|
5
5
|
from fractions import Fraction
|
|
6
6
|
from functools import reduce
|
|
7
7
|
import io
|
|
8
|
+
import importlib
|
|
8
9
|
import json
|
|
9
10
|
import math
|
|
10
11
|
from math import gcd
|
|
11
12
|
import os
|
|
13
|
+
import operator
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from prompt_toolkit import PromptSession
|
|
14
16
|
from prompt_toolkit.key_binding import KeyBindings
|
|
@@ -32,6 +34,13 @@ import urllib.error
|
|
|
32
34
|
|
|
33
35
|
__all__ = [
|
|
34
36
|
"ChemQLError",
|
|
37
|
+
"ChemQLBuiltinError",
|
|
38
|
+
"TooFewArguments",
|
|
39
|
+
"TooManyArguments",
|
|
40
|
+
"InvalidArgumentType",
|
|
41
|
+
"InvalidArgumentValue",
|
|
42
|
+
"FileReadError",
|
|
43
|
+
"FileWriteError",
|
|
35
44
|
"Element",
|
|
36
45
|
"Molecule",
|
|
37
46
|
"Reaction",
|
|
@@ -52,7 +61,7 @@ __all__ = [
|
|
|
52
61
|
"__version__"
|
|
53
62
|
]
|
|
54
63
|
|
|
55
|
-
__version__ = "1.
|
|
64
|
+
__version__ = "1.4.1"
|
|
56
65
|
|
|
57
66
|
# Persistent preprocessor/function state for the current ChemQL session.
|
|
58
67
|
DEFINITIONS = {}
|
|
@@ -127,17 +136,373 @@ def is_block_header(line):
|
|
|
127
136
|
or code.startswith("while ")
|
|
128
137
|
)
|
|
129
138
|
|
|
139
|
+
class ChemQLBuiltinError(Exception):
|
|
140
|
+
"""Base class for errors raised by ChemQL's native built-ins."""
|
|
141
|
+
|
|
142
|
+
class TooFewArguments(ChemQLBuiltinError):
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
class TooManyArguments(ChemQLBuiltinError):
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
class InvalidArgumentType(ChemQLBuiltinError):
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
class InvalidArgumentValue(ChemQLBuiltinError):
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
class FileReadError(ChemQLBuiltinError):
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
class FileWriteError(ChemQLBuiltinError):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _builtin_sum(*values):
|
|
162
|
+
if len(values) == 1:
|
|
163
|
+
return sum(values[0])
|
|
164
|
+
return sum(values)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _builtin_type(value):
|
|
168
|
+
"""Return a readable ChemQL type name."""
|
|
169
|
+
if value is None: return "NoneType"
|
|
170
|
+
if isinstance(value, bool): return "bool"
|
|
171
|
+
if isinstance(value, int): return "int"
|
|
172
|
+
if isinstance(value, float): return "float"
|
|
173
|
+
if isinstance(value, str): return "str"
|
|
174
|
+
if isinstance(value, list): return "list"
|
|
175
|
+
if isinstance(value, tuple): return "tuple"
|
|
176
|
+
if isinstance(value, dict): return "dict"
|
|
177
|
+
return type(value).__name__
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _builtin_read_text(path, encoding="utf-8"):
|
|
181
|
+
try:
|
|
182
|
+
return Path(os.fspath(path)).read_text(encoding=encoding)
|
|
183
|
+
except (OSError, UnicodeError, TypeError, ValueError) as exc:
|
|
184
|
+
raise FileReadError(f"Cannot read text file {path!r}: {exc}") from exc
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _builtin_read_lines(path, encoding="utf-8"):
|
|
188
|
+
return _builtin_read_text(path, encoding).splitlines()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _builtin_read_json(path, encoding="utf-8"):
|
|
192
|
+
try:
|
|
193
|
+
with open(os.fspath(path), "r", encoding=encoding) as stream:
|
|
194
|
+
return json.load(stream)
|
|
195
|
+
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError) as exc:
|
|
196
|
+
raise FileReadError(f"Cannot read JSON file {path!r}: {exc}") from exc
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _builtin_read_csv(path, delimiter=",", encoding="utf-8", headers=True):
|
|
200
|
+
import csv
|
|
201
|
+
if not isinstance(delimiter, str) or len(delimiter) != 1:
|
|
202
|
+
raise InvalidArgumentValue("read_csv delimiter must be a single character")
|
|
203
|
+
if not isinstance(headers, bool):
|
|
204
|
+
raise InvalidArgumentType("read_csv headers must be a boolean")
|
|
205
|
+
try:
|
|
206
|
+
with open(os.fspath(path), "r", newline="", encoding=encoding) as stream:
|
|
207
|
+
if headers:
|
|
208
|
+
return list(csv.DictReader(stream, delimiter=delimiter))
|
|
209
|
+
return list(csv.reader(stream, delimiter=delimiter))
|
|
210
|
+
except (OSError, UnicodeError, csv.Error, TypeError, ValueError) as exc:
|
|
211
|
+
raise FileReadError(f"Cannot read CSV file {path!r}: {exc}") from exc
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _builtin_element(symbol, field="symbol"):
|
|
215
|
+
if not isinstance(symbol, str):
|
|
216
|
+
raise InvalidArgumentType("element expects an element symbol as its first argument")
|
|
217
|
+
found = next((item for item in ELEMENTS if str(item.symbol).casefold() == symbol.casefold()), None)
|
|
218
|
+
if found is None:
|
|
219
|
+
raise InvalidArgumentValue(f"Unknown element symbol {symbol!r}")
|
|
220
|
+
if not isinstance(field, str):
|
|
221
|
+
raise InvalidArgumentType("element field must be a string")
|
|
222
|
+
if not hasattr(found, field) or field.startswith("_"):
|
|
223
|
+
raise InvalidArgumentValue(f"Unknown element field {field!r}")
|
|
224
|
+
return getattr(found, field)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _builtin_count_atoms(formula, symbol):
|
|
228
|
+
if not isinstance(formula, str) or not isinstance(symbol, str):
|
|
229
|
+
raise InvalidArgumentType("count_atoms expects a formula string and an element symbol")
|
|
230
|
+
try:
|
|
231
|
+
return _formula_counts(formula).get(symbol, 0)
|
|
232
|
+
except ValueError as exc:
|
|
233
|
+
raise InvalidArgumentValue(str(exc)) from exc
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _builtin_molar_mass(formula):
|
|
237
|
+
if not isinstance(formula, str):
|
|
238
|
+
raise InvalidArgumentType("molar_mass expects a chemical formula string")
|
|
239
|
+
try:
|
|
240
|
+
counts = _formula_counts(formula)
|
|
241
|
+
except ValueError as exc:
|
|
242
|
+
raise InvalidArgumentValue(str(exc)) from exc
|
|
243
|
+
masses = {str(item.symbol): item.atomic_mass for item in ELEMENTS}
|
|
244
|
+
total = 0.0
|
|
245
|
+
for symbol, count in counts.items():
|
|
246
|
+
try:
|
|
247
|
+
mass = float(masses[symbol])
|
|
248
|
+
except (KeyError, TypeError, ValueError):
|
|
249
|
+
raise InvalidArgumentValue(f"No atomic mass is available for element {symbol!r}")
|
|
250
|
+
total += mass * count
|
|
251
|
+
return total
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _builtin_empirical_formula(formula):
|
|
255
|
+
if not isinstance(formula, str):
|
|
256
|
+
raise InvalidArgumentType("empirical_formula expects a chemical formula string")
|
|
257
|
+
try:
|
|
258
|
+
counts = _formula_counts(formula)
|
|
259
|
+
except ValueError as exc:
|
|
260
|
+
raise InvalidArgumentValue(str(exc)) from exc
|
|
261
|
+
divisor = reduce(gcd, counts.values())
|
|
262
|
+
return "".join(symbol + (str(count // divisor) if count // divisor != 1 else "")
|
|
263
|
+
for symbol, count in counts.items())
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _builtin_write_csv(path, rows, delimiter=",", encoding="utf-8"):
|
|
267
|
+
import csv
|
|
268
|
+
if not isinstance(delimiter, str) or len(delimiter) != 1:
|
|
269
|
+
raise InvalidArgumentValue("write_csv delimiter must be a single character")
|
|
270
|
+
try:
|
|
271
|
+
with open(os.fspath(path), "w", newline="", encoding=encoding) as stream:
|
|
272
|
+
if not isinstance(rows, (list, tuple)):
|
|
273
|
+
raise InvalidArgumentType("write_csv rows must be a list")
|
|
274
|
+
if rows and isinstance(rows[0], dict):
|
|
275
|
+
fieldnames = list(rows[0].keys())
|
|
276
|
+
writer = csv.DictWriter(stream, fieldnames=fieldnames, delimiter=delimiter)
|
|
277
|
+
writer.writeheader()
|
|
278
|
+
writer.writerows(rows)
|
|
279
|
+
else:
|
|
280
|
+
writer = csv.writer(stream, delimiter=delimiter)
|
|
281
|
+
writer.writerows(rows)
|
|
282
|
+
return True
|
|
283
|
+
except ChemQLBuiltinError:
|
|
284
|
+
raise
|
|
285
|
+
except (OSError, UnicodeError, csv.Error, TypeError, ValueError) as exc:
|
|
286
|
+
raise FileWriteError(f"Cannot write CSV file {path!r}: {exc}") from exc
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _builtin_file_exists(path):
|
|
290
|
+
try:
|
|
291
|
+
return Path(os.fspath(path)).is_file()
|
|
292
|
+
except (TypeError, ValueError):
|
|
293
|
+
raise InvalidArgumentType("file_exists expects a path string")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _builtin_list_files(path=".", pattern="*"):
|
|
297
|
+
try:
|
|
298
|
+
return sorted(str(p) for p in Path(os.fspath(path)).glob(str(pattern)))
|
|
299
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
300
|
+
raise InvalidArgumentValue(f"Cannot list files: {exc}") from exc
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _builtin_write_text(path, content, encoding="utf-8"):
|
|
304
|
+
try:
|
|
305
|
+
Path(os.fspath(path)).write_text(str(content), encoding=encoding)
|
|
306
|
+
return True
|
|
307
|
+
except (OSError, UnicodeError, TypeError, ValueError) as exc:
|
|
308
|
+
raise FileWriteError(f"Cannot write text file {path!r}: {exc}") from exc
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _builtin_write_json(path, value, encoding="utf-8", indent=2):
|
|
312
|
+
try:
|
|
313
|
+
with open(os.fspath(path), "w", encoding=encoding) as stream:
|
|
314
|
+
json.dump(value, stream, ensure_ascii=False, indent=indent)
|
|
315
|
+
return True
|
|
316
|
+
except (OSError, UnicodeError, TypeError, ValueError) as exc:
|
|
317
|
+
raise FileWriteError(f"Cannot write JSON file {path!r}: {exc}") from exc
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# name: (callable, minimum positional arguments, maximum positional arguments)
|
|
321
|
+
# Built-ins are native Python-backed operations; user-defined ChemQL functions
|
|
322
|
+
# retain precedence when a name is defined in FUNCTIONS.
|
|
323
|
+
BUILTIN_FUNCTIONS = {
|
|
324
|
+
"abs": (abs, 1, 1),
|
|
325
|
+
"round": (round, 1, 2),
|
|
326
|
+
"min": (min, 1, None),
|
|
327
|
+
"max": (max, 1, None),
|
|
328
|
+
"sum": (_builtin_sum, 1, None),
|
|
329
|
+
"len": (len, 1, 1),
|
|
330
|
+
"type": (_builtin_type, 1, 1),
|
|
331
|
+
"str": (str, 1, 1),
|
|
332
|
+
"int": (int, 1, 2),
|
|
333
|
+
"float": (float, 1, 1),
|
|
334
|
+
"bool": (bool, 1, 1),
|
|
335
|
+
"upper": (lambda value: str(value).upper(), 1, 1),
|
|
336
|
+
"lower": (lambda value: str(value).lower(), 1, 1),
|
|
337
|
+
"contains": (lambda container, item: item in container, 2, 2),
|
|
338
|
+
"replace": (lambda value, old, new: str(value).replace(str(old), str(new)), 3, 3),
|
|
339
|
+
"split": (lambda value, separator=None: str(value).split(separator), 1, 2),
|
|
340
|
+
"join": (lambda separator, values: str(separator).join(str(v) for v in values), 2, 2),
|
|
341
|
+
"first": (lambda values: next(iter(values)), 1, 1),
|
|
342
|
+
"last": (lambda values: list(values)[-1], 1, 1),
|
|
343
|
+
"unique": (lambda *values: list(dict.fromkeys(values[0] if len(values) == 1 and isinstance(values[0], (list, tuple, str)) else values)), 1, None),
|
|
344
|
+
"sort": (lambda values: sorted(values), 1, 1),
|
|
345
|
+
"reversed": (lambda values: list(reversed(values)), 1, 1),
|
|
346
|
+
"read_text": (_builtin_read_text, 1, 2),
|
|
347
|
+
"read_lines": (_builtin_read_lines, 1, 2),
|
|
348
|
+
"read_json": (_builtin_read_json, 1, 2),
|
|
349
|
+
"read_csv": (_builtin_read_csv, 1, 4),
|
|
350
|
+
"file_exists": (_builtin_file_exists, 1, 1),
|
|
351
|
+
"element": (_builtin_element, 1, 2),
|
|
352
|
+
"count_atoms": (_builtin_count_atoms, 2, 2),
|
|
353
|
+
"molar_mass": (_builtin_molar_mass, 1, 1),
|
|
354
|
+
"empirical_formula": (_builtin_empirical_formula, 1, 1),
|
|
355
|
+
"write_csv": (_builtin_write_csv, 2, 4),
|
|
356
|
+
"list_files": (_builtin_list_files, 0, 2),
|
|
357
|
+
"write_text": (_builtin_write_text, 2, 3),
|
|
358
|
+
"write_json": (_builtin_write_json, 2, 4),
|
|
359
|
+
"help": (lambda *args: None, 0, 1),
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# User-facing documentation for built-ins. Keep examples in ChemQL syntax.
|
|
364
|
+
_BUILTIN_HELP = {
|
|
365
|
+
"help": ("Help", "help{topic?}", "Show the help index or detailed documentation for a built-in, syntax topic, error signal, or file/chemistry category.", ['help', 'help{read_csv}', 'help{errors}']),
|
|
366
|
+
"abs": ("Numbers", "abs{number}", "Return the absolute value.", ['abs{-15} # 15']),
|
|
367
|
+
"round": ("Numbers", "round{number, digits?}", "Round a number to the requested number of decimal places.", ['round{3.14159, 2} # 3.14']),
|
|
368
|
+
"min": ("Numbers", "min{value, ...}", "Return the smallest supplied value.", ['min{4, 2, 9} # 2']),
|
|
369
|
+
"max": ("Numbers", "max{value, ...}", "Return the largest supplied value.", ['max{4, 2, 9} # 9']),
|
|
370
|
+
"sum": ("Numbers", "sum{value, ...}", "Add numeric values or sum an iterable.", ['sum{1, 2, 3, 4} # 10']),
|
|
371
|
+
"len": ("Core", "len{value}", "Return the number of items in a string or collection.", ['len{"Hello world"} # 11']),
|
|
372
|
+
"type": ("Core", "type{value}", "Return a readable ChemQL/Python type name.", ['type{42}']),
|
|
373
|
+
"str": ("Conversion", "str{value}", "Convert a value to text.", ['str{42} # "42"']),
|
|
374
|
+
"int": ("Conversion", "int{value, base?}", "Convert a value to an integer.", ['int{"101", 2} # 5']),
|
|
375
|
+
"float": ("Conversion", "float{value}", "Convert a value to a floating-point number.", ['float{"3.5"}']),
|
|
376
|
+
"bool": ("Conversion", "bool{value}", "Convert a value to a Boolean.", ['bool{0} # False']),
|
|
377
|
+
"upper": ("Strings", "upper{text}", "Return text converted to uppercase.", ['upper{"chemql"} # CHEMQL']),
|
|
378
|
+
"lower": ("Strings", "lower{text}", "Return text converted to lowercase.", ['lower{"CHEMQL"} # chemql']),
|
|
379
|
+
"contains": ("Strings / Collections", "contains{container, item}", "Return whether a string or collection contains an item.", ['contains{"ChemQL", "QL"} # True']),
|
|
380
|
+
"replace": ("Strings", "replace{text, old, new}", "Replace every occurrence of one substring with another.", ['replace{"H2O", "2", "4"} # H4O']),
|
|
381
|
+
"split": ("Strings", "split{text, separator?}", "Split text into a list of pieces.", ['split{"red,green,blue", ","}']),
|
|
382
|
+
"join": ("Strings", "join{separator, values}", "Join an iterable of values into a string.", ['join{",", ["a", "b", "c"]}']),
|
|
383
|
+
"first": ("Collections", "first{values}", "Return the first item of an iterable.", ['first{[10, 20, 30]} # 10']),
|
|
384
|
+
"last": ("Collections", "last{values}", "Return the last item of an iterable.", ['last{[10, 20, 30]} # 30']),
|
|
385
|
+
"unique": ("Collections", "unique{values...}", "Return unique values while preserving their first-seen order.", ['unique{1, 2, 2, 3} # [1, 2, 3]']),
|
|
386
|
+
"sort": ("Collections", "sort{values}", "Return a sorted list.", ['sort{[3, 1, 2]} # [1, 2, 3]']),
|
|
387
|
+
"reversed": ("Collections", "reversed{values}", "Return the values in reverse order as a list.", ['reversed{[1, 2, 3]} # [3, 2, 1]']),
|
|
388
|
+
"read_text": ("Files", "read_text{path, encoding?}", "Read an entire text file. Defaults to UTF-8.", ['let text = read_text{"notes.txt"}']),
|
|
389
|
+
"read_lines": ("Files", "read_lines{path, encoding?}", "Read a text file into a list of lines.", ['let lines = read_lines{"notes.txt"}']),
|
|
390
|
+
"write_text": ("Files", "write_text{path, content, encoding?}", "Write text to a file. Returns true on success.", ['write_text{"output.txt", "Hello ChemQL!"}']),
|
|
391
|
+
"read_csv": ("Files / Data", "read_csv{path, delimiter?, encoding?, header?}", "Read a CSV file. By default, the first row is treated as column names and rows are returned as dictionaries. Set header to false for row lists.", ['let rows = read_csv{"data.csv"}', 'let rows = read_csv{"data.csv", ";", "utf-8", false}']),
|
|
392
|
+
"write_csv": ("Files / Data", "write_csv{path, rows, delimiter?, encoding?}", "Write rows to a CSV file.", ['write_csv{"output.csv", rows}']),
|
|
393
|
+
"read_json": ("Files / Data", "read_json{path, encoding?}", "Read JSON from a file into ChemQL dictionaries/lists/scalars.", ['let data = read_json{"data.json"}']),
|
|
394
|
+
"write_json": ("Files / Data", "write_json{path, value, encoding?, indent?}", "Write a value as JSON. Indentation defaults to 2 spaces.", ['write_json{"output.json", data}']),
|
|
395
|
+
"file_exists": ("Files", "file_exists{path}", "Return true if a file or path exists.", ['file_exists{"data.csv"}']),
|
|
396
|
+
"list_files": ("Files", "list_files{directory?, pattern?}", "List files in a directory, optionally matching a glob pattern.", ['list_files{".", "*.csv"}']),
|
|
397
|
+
"element": ("Chemistry", "element{symbol, field?}", "Look up a periodic-table element, optionally returning one field.", ['element{"Na", "atomic_mass"}']),
|
|
398
|
+
"count_atoms": ("Chemistry", "count_atoms{formula, element_symbol}", "Count atoms of an element in a chemical formula.", ['count_atoms{"H2SO4", "O"} # 4']),
|
|
399
|
+
"molar_mass": ("Chemistry", "molar_mass{formula}", "Calculate the molar mass of a chemical formula.", ['molar_mass{"H2O"}']),
|
|
400
|
+
"empirical_formula": ("Chemistry", "empirical_formula{formula}", "Calculate the empirical formula from a molecular formula.", ['empirical_formula{"C6H12O6"} # CH2O']),
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
_HELP_TOPICS = {
|
|
404
|
+
"syntax": ("ChemQL Syntax", ["Function calls use braces: `name{arg1, arg2}`.", "Quote text containing spaces: `upper{\"hello world\"}`.", "Separate arguments with commas, not spaces.", "Python statements/expressions can be embedded in `[ ... ]`.", "Multiline Python uses `[[[ ... ]]]`; embedded queries use `{{ ... }}`."]),
|
|
405
|
+
"errors": ("ChemQL Error Signals", ["SyntaxError — the input could not be parsed as valid syntax.", "TooFewArguments — a function received fewer arguments than required.", "TooManyArguments — a function received more arguments than allowed.", "InvalidArgumentType — an argument has an unsupported type.", "InvalidArgumentValue — an argument's value is invalid.", "FileReadError — reading a file failed.", "FileWriteError — writing a file failed.", "NameError — a name or function is unknown.", "PermissionError — a private namespace function was called externally."]),
|
|
406
|
+
"files": ("File Functions", ["read_text{path, encoding?}", "read_lines{path, encoding?}", "read_csv{path, delimiter?, encoding?, header?}", "read_json{path, encoding?}", "write_text{path, content, encoding?}", "write_csv{path, rows, delimiter?, encoding?}", "write_json{path, value, encoding?, indent?}", "file_exists{path}", "list_files{directory?, pattern?}"]),
|
|
407
|
+
"chemistry": ("Chemistry Functions", ["molar_mass{formula}", "count_atoms{formula, element_symbol}", "empirical_formula{formula}", "element{symbol, field?}"]),
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _help_text(topic=None):
|
|
412
|
+
if topic is None or not str(topic).strip():
|
|
413
|
+
names = sorted(_BUILTIN_HELP)
|
|
414
|
+
categories = {}
|
|
415
|
+
for name in names:
|
|
416
|
+
category = _BUILTIN_HELP[name][0]
|
|
417
|
+
categories.setdefault(category, []).append(name)
|
|
418
|
+
lines = [f"ChemQL {__version__} help", "", "Usage: help{topic} or help topic", "Use help{function_name} for details, or help{syntax}, help{errors}, help{files}, help{chemistry}.", ""]
|
|
419
|
+
for category, items in categories.items():
|
|
420
|
+
lines.append(f"{category}: " + ", ".join(items))
|
|
421
|
+
lines += ["", "User-defined functions:"]
|
|
422
|
+
lines += [f" {name}{{...}}" for name in sorted(FUNCTIONS)] or [" (none defined in this session)"]
|
|
423
|
+
lines += ["", "Packages: load package_name; then use package_name::function{...}."]
|
|
424
|
+
return "\n".join(lines)
|
|
425
|
+
|
|
426
|
+
key = str(topic).strip().strip("`\"'")
|
|
427
|
+
key = key.replace("::", "::")
|
|
428
|
+
if key in _BUILTIN_HELP:
|
|
429
|
+
category, signature, description, examples = _BUILTIN_HELP[key]
|
|
430
|
+
return "\n".join([f"{key} — {category}", f"Signature: {signature}", description, "", "Examples:", *[f" {line}" for line in examples]])
|
|
431
|
+
if key in _HELP_TOPICS:
|
|
432
|
+
title, lines = _HELP_TOPICS[key]
|
|
433
|
+
return "\n".join([title, *[f" {line}" for line in lines]])
|
|
434
|
+
if key in FUNCTIONS:
|
|
435
|
+
return f"User-defined ChemQL function: {key}{{...}}\nDefined in the current session. Use `view`/`dump` where appropriate to inspect program state."
|
|
436
|
+
if "::" in key:
|
|
437
|
+
resolved = _resolve_package_qualified_name(key)
|
|
438
|
+
if resolved in FUNCTIONS:
|
|
439
|
+
return f"ChemQL package function: {key}{{...}}\nCanonical name: {resolved}"
|
|
440
|
+
package = key.rsplit("::", 1)[0]
|
|
441
|
+
return f"Package/member help is not registered for `{key}`.\nTry `load {package}` and inspect the package namespace, or use `help{{{package}}}` if documented."
|
|
442
|
+
return f"No help available for `{key}`. Try `help` to list built-ins, or `help{{syntax}}`, `help{{errors}}`, `help{{files}}`, and `help{{chemistry}}`."
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _call_builtin(name, arguments):
|
|
446
|
+
entry = BUILTIN_FUNCTIONS.get(name)
|
|
447
|
+
if entry is None:
|
|
448
|
+
raise NameError(f"Unknown ChemQL built-in `{name}`")
|
|
449
|
+
function, minimum, maximum = entry
|
|
450
|
+
count = len(arguments)
|
|
451
|
+
if count < minimum:
|
|
452
|
+
raise TooFewArguments(f"Too few arguments for `{name}`: expected at least {minimum}, got {count}")
|
|
453
|
+
if maximum is not None and count > maximum:
|
|
454
|
+
raise TooManyArguments(f"Too many arguments for `{name}`: expected at most {maximum}, got {count}")
|
|
455
|
+
if name == "help":
|
|
456
|
+
if len(arguments) > 1:
|
|
457
|
+
raise TooManyArguments("Too many arguments for `help`: expected at most 1, got " + str(len(arguments)))
|
|
458
|
+
topic = arguments[0].strip() if arguments else None
|
|
459
|
+
if topic is not None and len(topic) >= 2 and topic[0] in ("\"", "'") and topic[-1] == topic[0]:
|
|
460
|
+
try:
|
|
461
|
+
topic = ast.literal_eval(topic)
|
|
462
|
+
except (ValueError, SyntaxError):
|
|
463
|
+
topic = topic[1:-1]
|
|
464
|
+
return _help_text(topic)
|
|
465
|
+
values = [evaluate_python(arg) for arg in arguments]
|
|
466
|
+
try:
|
|
467
|
+
return function(*values)
|
|
468
|
+
except ChemQLBuiltinError:
|
|
469
|
+
raise
|
|
470
|
+
except TypeError as exc:
|
|
471
|
+
raise InvalidArgumentType(f"Invalid argument type for `{name}`: {exc}") from exc
|
|
472
|
+
except ValueError as exc:
|
|
473
|
+
raise InvalidArgumentValue(f"Invalid argument value for `{name}`: {exc}") from exc
|
|
474
|
+
except IndexError as exc:
|
|
475
|
+
raise InvalidArgumentValue(f"Invalid argument value for `{name}`: {exc}") from exc
|
|
476
|
+
|
|
477
|
+
|
|
130
478
|
def _call_function(name, arguments):
|
|
131
479
|
"""Call a ChemQL function, including let assignments and block syntax."""
|
|
480
|
+
global _CURRENT_NAMESPACE
|
|
132
481
|
if name not in FUNCTIONS:
|
|
133
482
|
raise NameError(f"Unknown ChemQL function `{name}`")
|
|
134
483
|
|
|
135
484
|
values = [evaluate_python(arg) for arg in arguments]
|
|
136
485
|
definition = FUNCTIONS[name]
|
|
137
486
|
|
|
487
|
+
# While a namespace function runs, its private members are available to it
|
|
488
|
+
# and to other functions in the same namespace, but not to external callers.
|
|
489
|
+
previous_namespace = _CURRENT_NAMESPACE
|
|
490
|
+
if "::" in name:
|
|
491
|
+
_CURRENT_NAMESPACE = NAMESPACES.get(name.rsplit("::", 1)[0], previous_namespace)
|
|
492
|
+
|
|
138
493
|
saved_globals = python_globals.copy()
|
|
139
494
|
saved_chemql_variables = CHEMQL_VARIABLES.copy()
|
|
140
495
|
|
|
496
|
+
# Namespace members shadow same-named globals only while a function in
|
|
497
|
+
# that namespace executes. Never leak namespace variables into the caller's
|
|
498
|
+
# global scope, and preserve private-member access for internal functions.
|
|
499
|
+
active_namespace = _CURRENT_NAMESPACE
|
|
500
|
+
if active_namespace is not None:
|
|
501
|
+
for member_name in active_namespace._chemql_public: # type: ignore
|
|
502
|
+
if member_name in active_namespace.__dict__:
|
|
503
|
+
python_globals[member_name] = active_namespace.__dict__[member_name]
|
|
504
|
+
python_globals.update(active_namespace._chemql_private) # type: ignore
|
|
505
|
+
|
|
141
506
|
had_arguments = "arguments" in python_globals
|
|
142
507
|
saved_arguments = python_globals.get("arguments")
|
|
143
508
|
|
|
@@ -409,6 +774,34 @@ def _call_function(name, arguments):
|
|
|
409
774
|
python_globals["arguments"] = saved_arguments
|
|
410
775
|
else:
|
|
411
776
|
python_globals.pop("arguments", None)
|
|
777
|
+
_CURRENT_NAMESPACE = previous_namespace
|
|
778
|
+
|
|
779
|
+
def _resolve_package_qualified_name(name):
|
|
780
|
+
"""Resolve an explicit package alias at the start of a qualified name."""
|
|
781
|
+
if "::" not in name:
|
|
782
|
+
return name
|
|
783
|
+
bindings = globals().get("CHEMQL_PACKAGE_BINDINGS", {})
|
|
784
|
+
parts = name.split("::")
|
|
785
|
+
for prefix_length in range(len(parts) - 1, 0, -1):
|
|
786
|
+
prefix = "::".join(parts[:prefix_length])
|
|
787
|
+
canonical = bindings.get(prefix)
|
|
788
|
+
if canonical:
|
|
789
|
+
return "::".join([canonical, *parts[prefix_length:]])
|
|
790
|
+
return name
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _namespace_function_is_callable(name):
|
|
794
|
+
"""Return whether a namespaced function is public or called internally."""
|
|
795
|
+
name = _resolve_package_qualified_name(name)
|
|
796
|
+
if "::" not in name:
|
|
797
|
+
return True
|
|
798
|
+
namespace_name, member_name = name.rsplit("::", 1)
|
|
799
|
+
namespace = NAMESPACES.get(namespace_name)
|
|
800
|
+
if namespace is None:
|
|
801
|
+
return True # Preserve the normal unknown-function error path.
|
|
802
|
+
if member_name in namespace._chemql_public:
|
|
803
|
+
return True
|
|
804
|
+
return _CURRENT_NAMESPACE is not None and _CURRENT_NAMESPACE._chemql_name == namespace_name # type: ignore
|
|
412
805
|
|
|
413
806
|
def _expand_function_calls(text):
|
|
414
807
|
"""Expand function calls of the form name{arg1, arg2} in query text."""
|
|
@@ -429,18 +822,26 @@ def _expand_function_calls(text):
|
|
|
429
822
|
continue
|
|
430
823
|
if ch in ("'", '"'):
|
|
431
824
|
quote = ch; i += 1; continue
|
|
432
|
-
match = re.match(r"([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?)\{", text[i:])
|
|
825
|
+
match = re.match(r"([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\{", text[i:])
|
|
433
826
|
if match:
|
|
434
827
|
name = match.group(1)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
828
|
+
resolved_name = _resolve_package_qualified_name(name)
|
|
829
|
+
opening = i + match.end() - 1
|
|
830
|
+
end = find_matching(text, opening, "{", "}")
|
|
831
|
+
args = _split_function_arguments(text[opening + 1:end])
|
|
832
|
+
if resolved_name in FUNCTIONS:
|
|
833
|
+
if not _namespace_function_is_callable(resolved_name):
|
|
834
|
+
raise PermissionError(f"Function `{name}` is private")
|
|
835
|
+
value = _call_function(resolved_name, args)
|
|
836
|
+
elif "::" not in resolved_name and resolved_name in BUILTIN_FUNCTIONS:
|
|
837
|
+
value = _call_builtin(resolved_name, args)
|
|
838
|
+
else:
|
|
839
|
+
i += 1
|
|
840
|
+
continue
|
|
841
|
+
replacement = repr(value) if isinstance(value, str) else ("None" if value is None else str(value))
|
|
842
|
+
text = text[:i] + replacement + text[end + 1:]
|
|
843
|
+
changed = True
|
|
844
|
+
break
|
|
444
845
|
i += 1
|
|
445
846
|
if not changed:
|
|
446
847
|
return text
|
|
@@ -2905,6 +3306,31 @@ class ChemQLSignal(Exception):
|
|
|
2905
3306
|
|
|
2906
3307
|
CHEMQL_VARIABLES = set()
|
|
2907
3308
|
|
|
3309
|
+
class ChemQLNamespace:
|
|
3310
|
+
"""A namespace exposing only explicitly public members as attributes."""
|
|
3311
|
+
def __init__(self, name, parent=None):
|
|
3312
|
+
self.__dict__["_chemql_name"] = name
|
|
3313
|
+
self.__dict__["_chemql_parent"] = parent
|
|
3314
|
+
self.__dict__["_chemql_public"] = set()
|
|
3315
|
+
self.__dict__["_chemql_private"] = {}
|
|
3316
|
+
|
|
3317
|
+
def _set_member(self, name, value, public=False):
|
|
3318
|
+
# Redefining a public member as private must revoke the old public binding.
|
|
3319
|
+
self.__dict__.pop(name, None)
|
|
3320
|
+
self.__dict__["_chemql_public"].discard(name)
|
|
3321
|
+
self.__dict__["_chemql_private"].pop(name, None)
|
|
3322
|
+
if public:
|
|
3323
|
+
self.__dict__[name] = value
|
|
3324
|
+
self.__dict__["_chemql_public"].add(name)
|
|
3325
|
+
else:
|
|
3326
|
+
self.__dict__["_chemql_private"][name] = value
|
|
3327
|
+
# Private names are available to code executing in the namespace,
|
|
3328
|
+
# but are deliberately not exposed through namespace.member.
|
|
3329
|
+
|
|
3330
|
+
NAMESPACES = {}
|
|
3331
|
+
_CURRENT_NAMESPACE = None
|
|
3332
|
+
_CURRENT_VISIBILITY = "private"
|
|
3333
|
+
|
|
2908
3334
|
def _chemql_value(expression):
|
|
2909
3335
|
"""Resolve a native ChemQL variable, otherwise evaluate a query expression."""
|
|
2910
3336
|
expression = expression.strip()
|
|
@@ -3196,6 +3622,9 @@ def evaluate_python(code):
|
|
|
3196
3622
|
rewritten = re.sub(r"\b" + re.escape(type_name) + r"\s*\{([^{}]*)\}",
|
|
3197
3623
|
lambda m, n=type_name: n + "(" + m.group(1) + ")", rewritten)
|
|
3198
3624
|
rewritten = _expand_function_calls(rewritten)
|
|
3625
|
+
# ChemQL uses :: for namespace qualification; Python uses attribute dots.
|
|
3626
|
+
rewritten = re.sub(r"\b([A-Za-z_]\w*(?:::[A-Za-z_]\w*)+)",
|
|
3627
|
+
lambda m: m.group(1).replace("::", "."), rewritten)
|
|
3199
3628
|
|
|
3200
3629
|
python_globals.update(query_values)
|
|
3201
3630
|
|
|
@@ -3602,10 +4031,19 @@ def process_inline(text):
|
|
|
3602
4031
|
let_match = re.fullmatch(r"let\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*=\s*(.+)", stripped, re.DOTALL)
|
|
3603
4032
|
if let_match:
|
|
3604
4033
|
name, expression = let_match.groups()
|
|
3605
|
-
|
|
4034
|
+
value = evaluate_python(expression)
|
|
4035
|
+
if _CURRENT_NAMESPACE is not None and "." not in name:
|
|
4036
|
+
qualified = _CURRENT_NAMESPACE._chemql_name + "::" + name # type: ignore
|
|
4037
|
+
_CURRENT_NAMESPACE._set_member(name, value, _CURRENT_VISIBILITY == "public")
|
|
4038
|
+
# Keep a compatibility alias, but do not overwrite a same-named
|
|
4039
|
+
# variable in the caller's global scope. Namespace-qualified access
|
|
4040
|
+
# resolves through the namespace object, not this alias.
|
|
4041
|
+
python_globals[qualified.replace("::", "__")] = value
|
|
4042
|
+
elif "::" in name:
|
|
4043
|
+
evaluate_python(name + " = (" + expression + ")")
|
|
4044
|
+
elif "." in name:
|
|
3606
4045
|
evaluate_python(name + " = (" + expression + ")")
|
|
3607
4046
|
else:
|
|
3608
|
-
value = evaluate_python(expression)
|
|
3609
4047
|
python_globals[name] = value
|
|
3610
4048
|
CHEMQL_VARIABLES.add(name)
|
|
3611
4049
|
return None
|
|
@@ -3646,9 +4084,14 @@ def process_inline(text):
|
|
|
3646
4084
|
return evaluate_python(stripped)
|
|
3647
4085
|
|
|
3648
4086
|
# A standalone user-defined function call returns its value directly.
|
|
3649
|
-
function_call = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)
|
|
3650
|
-
if function_call
|
|
3651
|
-
|
|
4087
|
+
function_call = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*)\s*\{(.*)\}", stripped, re.DOTALL)
|
|
4088
|
+
if function_call:
|
|
4089
|
+
function_name = _resolve_package_qualified_name(function_call.group(1))
|
|
4090
|
+
arguments = _split_function_arguments(function_call.group(2))
|
|
4091
|
+
if function_name in FUNCTIONS:
|
|
4092
|
+
return _call_function(function_name, arguments)
|
|
4093
|
+
if "::" not in function_name and function_name in BUILTIN_FUNCTIONS:
|
|
4094
|
+
return _call_builtin(function_name, arguments)
|
|
3652
4095
|
|
|
3653
4096
|
# [ ... ] is Python.
|
|
3654
4097
|
if stripped.startswith("["):
|
|
@@ -3761,8 +4204,179 @@ def _find_unquoted_marker(text, marker, start=0):
|
|
|
3761
4204
|
return -1
|
|
3762
4205
|
|
|
3763
4206
|
|
|
4207
|
+
# Canonical ChemQL package names and user-defined aliases are deliberately
|
|
4208
|
+
# tracked separately. An alias must never replace a canonical package binding.
|
|
4209
|
+
CHEMQL_PACKAGE_BINDINGS = {}
|
|
4210
|
+
CHEMQL_PACKAGE_SOURCE_LOADED = set()
|
|
4211
|
+
|
|
4212
|
+
|
|
4213
|
+
def _package_relative_path(package_name):
|
|
4214
|
+
"""Convert a canonical `a::b` package name to a relative source path."""
|
|
4215
|
+
parts = package_name.split("::")
|
|
4216
|
+
if not parts or any(not re.fullmatch(r"[A-Za-z_]\w*", part) for part in parts):
|
|
4217
|
+
raise ImportError(f"Invalid ChemQL package name: `{package_name}`")
|
|
4218
|
+
return Path(*parts[:-1], parts[-1] + ".cql")
|
|
4219
|
+
|
|
4220
|
+
|
|
4221
|
+
def _find_cql_package_file(package_name):
|
|
4222
|
+
"""Find a .cql package locally first, then in ChemQL's bundled stdlib."""
|
|
4223
|
+
relative = _package_relative_path(package_name)
|
|
4224
|
+
local_path = Path.cwd() / relative
|
|
4225
|
+
if local_path.is_file():
|
|
4226
|
+
return local_path
|
|
4227
|
+
stdlib_path = Path(__file__).resolve().parent / "stdlib" / relative
|
|
4228
|
+
if stdlib_path.is_file():
|
|
4229
|
+
return stdlib_path
|
|
4230
|
+
return None
|
|
4231
|
+
|
|
4232
|
+
|
|
4233
|
+
def _ensure_package_namespace(package_name, allow_namespace_only=False):
|
|
4234
|
+
"""Load a canonical ChemQL package and return its namespace object."""
|
|
4235
|
+
if not re.fullmatch(r"[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*", package_name):
|
|
4236
|
+
raise ImportError(f"Invalid ChemQL package name: `{package_name}`")
|
|
4237
|
+
cached = NAMESPACES.get(package_name)
|
|
4238
|
+
if cached is not None and package_name in CHEMQL_PACKAGE_SOURCE_LOADED:
|
|
4239
|
+
return cached
|
|
4240
|
+
|
|
4241
|
+
parent_name, separator, leaf_name = package_name.rpartition("::")
|
|
4242
|
+
path = _find_cql_package_file(package_name)
|
|
4243
|
+
if path is None and not allow_namespace_only:
|
|
4244
|
+
raise ImportError(
|
|
4245
|
+
f"Cannot find ChemQL package `{package_name}`. Searched the current "
|
|
4246
|
+
f"directory and ChemQL's bundled `stdlib` directory for .cql files."
|
|
4247
|
+
)
|
|
4248
|
+
parent = _ensure_package_namespace(parent_name, allow_namespace_only=True) if separator else None
|
|
4249
|
+
|
|
4250
|
+
namespace = NAMESPACES.get(package_name)
|
|
4251
|
+
if namespace is None:
|
|
4252
|
+
namespace = ChemQLNamespace(package_name, parent)
|
|
4253
|
+
NAMESPACES[package_name] = namespace
|
|
4254
|
+
if parent is not None:
|
|
4255
|
+
# The canonical path must remain traversable (math::statistics).
|
|
4256
|
+
parent._set_member(leaf_name, namespace, True)
|
|
4257
|
+
|
|
4258
|
+
if path is not None and CHEMQL_PACKAGE_BINDINGS.get(package_name) != package_name:
|
|
4259
|
+
source_lines = path.read_text(encoding="utf-8").splitlines()
|
|
4260
|
+
old_namespace, old_visibility = _CURRENT_NAMESPACE, _CURRENT_VISIBILITY
|
|
4261
|
+
try:
|
|
4262
|
+
globals()["_CURRENT_NAMESPACE"] = namespace
|
|
4263
|
+
globals()["_CURRENT_VISIBILITY"] = "private"
|
|
4264
|
+
process_lines(source_lines)
|
|
4265
|
+
except Exception:
|
|
4266
|
+
# Do not mark a partially executed package as successfully loaded.
|
|
4267
|
+
NAMESPACES.pop(package_name, None)
|
|
4268
|
+
if parent is not None:
|
|
4269
|
+
parent._set_member(leaf_name, namespace, False)
|
|
4270
|
+
raise
|
|
4271
|
+
finally:
|
|
4272
|
+
globals()["_CURRENT_NAMESPACE"] = old_namespace
|
|
4273
|
+
globals()["_CURRENT_VISIBILITY"] = old_visibility
|
|
4274
|
+
CHEMQL_PACKAGE_SOURCE_LOADED.add(package_name)
|
|
4275
|
+
|
|
4276
|
+
# A namespace-only parent is a canonical container, not an independently
|
|
4277
|
+
# loadable package unless it has its own .cql source file.
|
|
4278
|
+
if path is not None:
|
|
4279
|
+
CHEMQL_PACKAGE_BINDINGS.setdefault(package_name, package_name)
|
|
4280
|
+
return namespace
|
|
4281
|
+
|
|
4282
|
+
|
|
4283
|
+
def _execute_load_statement(statement):
|
|
4284
|
+
"""Load a ChemQL .cql package, optionally binding an explicit alias."""
|
|
4285
|
+
match = re.fullmatch(
|
|
4286
|
+
r"load\s+([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)(?:\s+as\s+([A-Za-z_]\w*))?",
|
|
4287
|
+
statement,
|
|
4288
|
+
)
|
|
4289
|
+
if not match:
|
|
4290
|
+
if statement.startswith("load"):
|
|
4291
|
+
raise SyntaxError("Expected `load package::name` or `load package::name as alias`")
|
|
4292
|
+
return False
|
|
4293
|
+
|
|
4294
|
+
package_name, alias = match.groups()
|
|
4295
|
+
|
|
4296
|
+
# Detect binding conflicts before executing package source, so a failed
|
|
4297
|
+
# load cannot run code or partially initialize the conflicting package.
|
|
4298
|
+
if alias:
|
|
4299
|
+
existing = CHEMQL_PACKAGE_BINDINGS.get(alias)
|
|
4300
|
+
if existing is not None and existing != package_name:
|
|
4301
|
+
raise ImportError(
|
|
4302
|
+
f"Package name `{alias}` is already bound to `{existing}`; "
|
|
4303
|
+
f"cannot also bind it to `{package_name}`. Use a different alias, "
|
|
4304
|
+
f"for example `load {package_name} as {alias}_pkg`."
|
|
4305
|
+
)
|
|
4306
|
+
if alias in CHEMQL_VARIABLES and alias not in CHEMQL_PACKAGE_BINDINGS:
|
|
4307
|
+
raise ImportError(
|
|
4308
|
+
f"Name `{alias}` is already defined and cannot be used as a package alias. "
|
|
4309
|
+
f"Choose another alias."
|
|
4310
|
+
)
|
|
4311
|
+
elif "::" not in package_name:
|
|
4312
|
+
existing = CHEMQL_PACKAGE_BINDINGS.get(package_name)
|
|
4313
|
+
if existing is not None and existing != package_name:
|
|
4314
|
+
raise ImportError(
|
|
4315
|
+
f"Name `{package_name}` is already bound to `{existing}`. "
|
|
4316
|
+
f"Use an explicit alias to load `{package_name}` under another name."
|
|
4317
|
+
)
|
|
4318
|
+
if package_name in CHEMQL_VARIABLES and package_name not in CHEMQL_PACKAGE_BINDINGS:
|
|
4319
|
+
raise ImportError(f"Name `{package_name}` is already defined; cannot bind package to it.")
|
|
4320
|
+
|
|
4321
|
+
namespace = _ensure_package_namespace(package_name)
|
|
4322
|
+
|
|
4323
|
+
# A nested package's canonical path needs its root namespace bound, but
|
|
4324
|
+
# this does not create an alias for the nested package's final component.
|
|
4325
|
+
if "::" in package_name:
|
|
4326
|
+
root_name = package_name.split("::", 1)[0]
|
|
4327
|
+
root_namespace = NAMESPACES[root_name]
|
|
4328
|
+
root_binding = CHEMQL_PACKAGE_BINDINGS.get(root_name)
|
|
4329
|
+
if root_binding is not None and root_binding != root_name:
|
|
4330
|
+
raise ImportError(
|
|
4331
|
+
f"Canonical namespace `{root_name}` is already bound to `{root_binding}`; "
|
|
4332
|
+
f"cannot expose `{package_name}` through its canonical path."
|
|
4333
|
+
)
|
|
4334
|
+
if root_name in CHEMQL_VARIABLES and root_name not in CHEMQL_PACKAGE_BINDINGS:
|
|
4335
|
+
raise ImportError(f"Name `{root_name}` is already defined; cannot expose `{package_name}`.")
|
|
4336
|
+
CHEMQL_PACKAGE_BINDINGS[root_name] = root_name
|
|
4337
|
+
python_globals[root_name] = root_namespace
|
|
4338
|
+
CHEMQL_VARIABLES.add(root_name)
|
|
4339
|
+
|
|
4340
|
+
if alias:
|
|
4341
|
+
existing = CHEMQL_PACKAGE_BINDINGS.get(alias)
|
|
4342
|
+
if existing is not None and existing != package_name:
|
|
4343
|
+
raise ImportError(
|
|
4344
|
+
f"Package name `{alias}` is already bound to `{existing}`; "
|
|
4345
|
+
f"cannot also bind it to `{package_name}`. Use a different alias, "
|
|
4346
|
+
f"for example `load {package_name} as {alias}_pkg`."
|
|
4347
|
+
)
|
|
4348
|
+
# Avoid silently overwriting a non-package ChemQL variable either.
|
|
4349
|
+
if alias in CHEMQL_VARIABLES and alias not in CHEMQL_PACKAGE_BINDINGS:
|
|
4350
|
+
raise ImportError(
|
|
4351
|
+
f"Name `{alias}` is already defined and cannot be used as a package alias. "
|
|
4352
|
+
f"Choose another alias."
|
|
4353
|
+
)
|
|
4354
|
+
CHEMQL_PACKAGE_BINDINGS[alias] = package_name
|
|
4355
|
+
python_globals[alias] = namespace
|
|
4356
|
+
CHEMQL_VARIABLES.add(alias)
|
|
4357
|
+
else:
|
|
4358
|
+
# A canonical top-level package binds its own short name. Nested
|
|
4359
|
+
# packages do not acquire an implicit short-name alias. When a nested
|
|
4360
|
+
# package is loaded, its root namespace (e.g. `math`) is available so
|
|
4361
|
+
# the full canonical path can be traversed.
|
|
4362
|
+
if "::" not in package_name:
|
|
4363
|
+
existing = CHEMQL_PACKAGE_BINDINGS.get(package_name)
|
|
4364
|
+
if existing is not None and existing != package_name:
|
|
4365
|
+
raise ImportError(
|
|
4366
|
+
f"Name `{package_name}` is already bound to `{existing}`. "
|
|
4367
|
+
f"Use an explicit alias to load `{package_name}` under another name."
|
|
4368
|
+
)
|
|
4369
|
+
if package_name in CHEMQL_VARIABLES and package_name not in CHEMQL_PACKAGE_BINDINGS:
|
|
4370
|
+
raise ImportError(f"Name `{package_name}` is already defined; cannot bind package to it.")
|
|
4371
|
+
CHEMQL_PACKAGE_BINDINGS[package_name] = package_name
|
|
4372
|
+
python_globals[package_name] = namespace
|
|
4373
|
+
CHEMQL_VARIABLES.add(package_name)
|
|
4374
|
+
return True
|
|
4375
|
+
|
|
4376
|
+
|
|
3764
4377
|
def process_lines(lines):
|
|
3765
4378
|
"""Process ChemQL source, including multiline Python, terminal, and comments."""
|
|
4379
|
+
global _CURRENT_NAMESPACE, _CURRENT_VISIBILITY
|
|
3766
4380
|
lines = join_continuations(list(lines))
|
|
3767
4381
|
# Natural headers (for/if/while) and typed terminators are accepted in v1.3.
|
|
3768
4382
|
normalized_lines = []
|
|
@@ -3795,6 +4409,87 @@ def process_lines(lines):
|
|
|
3795
4409
|
i += 1
|
|
3796
4410
|
continue
|
|
3797
4411
|
|
|
4412
|
+
# Namespace blocks can be nested. Visibility defaults to private.
|
|
4413
|
+
namespace_match = re.fullmatch(r"namespace\s+([A-Za-z_]\w*)", stripped, re.IGNORECASE)
|
|
4414
|
+
if namespace_match:
|
|
4415
|
+
ns_name = namespace_match.group(1)
|
|
4416
|
+
end = i + 1
|
|
4417
|
+
depth = 1
|
|
4418
|
+
while end < len(lines):
|
|
4419
|
+
candidate = lines[end].strip()
|
|
4420
|
+
if re.fullmatch(r"(?:(?:public|private)\s+)?namespace\s+[A-Za-z_]\w*", candidate, re.IGNORECASE):
|
|
4421
|
+
depth += 1
|
|
4422
|
+
elif candidate.casefold() == "endnamespace":
|
|
4423
|
+
depth -= 1
|
|
4424
|
+
if depth == 0:
|
|
4425
|
+
break
|
|
4426
|
+
end += 1
|
|
4427
|
+
if end >= len(lines):
|
|
4428
|
+
raise SyntaxError(f"Missing `endnamespace` for namespace `{ns_name}`")
|
|
4429
|
+
parent = _CURRENT_NAMESPACE
|
|
4430
|
+
full_name = (parent._chemql_name + "::" + ns_name) if parent else ns_name # type: ignore
|
|
4431
|
+
ns = NAMESPACES.get(full_name)
|
|
4432
|
+
if ns is None:
|
|
4433
|
+
ns = ChemQLNamespace(full_name, parent)
|
|
4434
|
+
NAMESPACES[full_name] = ns
|
|
4435
|
+
if parent is not None:
|
|
4436
|
+
parent._set_member(ns_name, ns, _CURRENT_VISIBILITY == "public")
|
|
4437
|
+
else:
|
|
4438
|
+
python_globals[ns_name] = ns
|
|
4439
|
+
CHEMQL_VARIABLES.add(ns_name)
|
|
4440
|
+
old_ns, old_visibility = _CURRENT_NAMESPACE, _CURRENT_VISIBILITY
|
|
4441
|
+
_CURRENT_NAMESPACE, _CURRENT_VISIBILITY = ns, "private"
|
|
4442
|
+
try:
|
|
4443
|
+
process_lines(lines[i + 1:end])
|
|
4444
|
+
finally:
|
|
4445
|
+
_CURRENT_NAMESPACE, _CURRENT_VISIBILITY = old_ns, old_visibility
|
|
4446
|
+
i = end + 1
|
|
4447
|
+
continue
|
|
4448
|
+
if stripped.casefold() == "endnamespace":
|
|
4449
|
+
raise SyntaxError("Unexpected `endnamespace`")
|
|
4450
|
+
visibility_match = re.match(r"^(public|private)\s+(.+)$", stripped, re.IGNORECASE)
|
|
4451
|
+
if visibility_match:
|
|
4452
|
+
visibility, declaration = visibility_match.groups()
|
|
4453
|
+
old_visibility = _CURRENT_VISIBILITY
|
|
4454
|
+
_CURRENT_VISIBILITY = visibility.casefold()
|
|
4455
|
+
try:
|
|
4456
|
+
# Visibility prefixes may precede block declarations, so pass the
|
|
4457
|
+
# complete declaration (including its body) to the normal parser.
|
|
4458
|
+
block_end = i
|
|
4459
|
+
if re.match(r"^(?:(?:static\s+func|func\s+static|func)\s+)", declaration, re.IGNORECASE):
|
|
4460
|
+
block_end = i + 1
|
|
4461
|
+
while block_end < len(lines) and lines[block_end].strip().casefold() != "endfunc":
|
|
4462
|
+
block_end += 1
|
|
4463
|
+
if block_end >= len(lines):
|
|
4464
|
+
raise SyntaxError("Missing `endfunc` after visibility declaration")
|
|
4465
|
+
elif re.match(r"^namespace\s+", declaration, re.IGNORECASE):
|
|
4466
|
+
depth = 1
|
|
4467
|
+
block_end = i + 1
|
|
4468
|
+
while block_end < len(lines):
|
|
4469
|
+
item = lines[block_end].strip()
|
|
4470
|
+
if re.match(r"^(?:(?:public|private)\s+)?namespace\s+", item, re.IGNORECASE):
|
|
4471
|
+
depth += 1
|
|
4472
|
+
elif item.casefold() == "endnamespace":
|
|
4473
|
+
depth -= 1
|
|
4474
|
+
if depth == 0:
|
|
4475
|
+
break
|
|
4476
|
+
block_end += 1
|
|
4477
|
+
if block_end >= len(lines):
|
|
4478
|
+
raise SyntaxError("Missing `endnamespace` after visibility declaration")
|
|
4479
|
+
elif re.match(r"^(struct|record)\s+[A-Za-z_]\w*\s*$", declaration, re.IGNORECASE):
|
|
4480
|
+
kind = re.match(r"^(struct|record)", declaration, re.IGNORECASE).group(1).casefold() # type:ignore
|
|
4481
|
+
terminator = "endstruct" if kind == "struct" else "endrecord"
|
|
4482
|
+
block_end = i + 1
|
|
4483
|
+
while block_end < len(lines) and lines[block_end].strip().casefold() != terminator:
|
|
4484
|
+
block_end += 1
|
|
4485
|
+
if block_end >= len(lines):
|
|
4486
|
+
raise SyntaxError(f"Missing `{terminator}` after visibility declaration")
|
|
4487
|
+
process_lines([declaration] + lines[i + 1:block_end + 1])
|
|
4488
|
+
finally:
|
|
4489
|
+
_CURRENT_VISIBILITY = old_visibility
|
|
4490
|
+
i = block_end + 1
|
|
4491
|
+
continue
|
|
4492
|
+
|
|
3798
4493
|
type_match = re.fullmatch(r"(struct|record)\s+([A-Za-z_]\w*)", stripped, re.IGNORECASE)
|
|
3799
4494
|
if type_match:
|
|
3800
4495
|
kind, type_name = type_match.group(1).casefold(), type_match.group(2)
|
|
@@ -3804,24 +4499,40 @@ def process_lines(lines):
|
|
|
3804
4499
|
end += 1
|
|
3805
4500
|
if end >= len(lines):
|
|
3806
4501
|
raise SyntaxError((f"Missing `{terminator}` for {kind} `{type_name}`" if NO_ANSI else f"Missing \033[1m{terminator}\033[22m for {kind} \033[1m{type_name}\033[22m"))
|
|
3807
|
-
fields, defaults = [], {}
|
|
4502
|
+
fields, defaults, required_fields, optional_fields = [], {}, [], []
|
|
3808
4503
|
for field_line in lines[i + 1:end]:
|
|
3809
4504
|
field_line = remove_comments(field_line).strip()
|
|
3810
4505
|
if not field_line:
|
|
3811
4506
|
continue
|
|
3812
|
-
|
|
3813
|
-
if
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
4507
|
+
required_match = re.fullmatch(r"required\s+([A-Za-z_]\w*)", field_line, re.IGNORECASE)
|
|
4508
|
+
if required_match:
|
|
4509
|
+
if kind != "struct":
|
|
4510
|
+
raise SyntaxError("`required` fields are supported only in structs")
|
|
4511
|
+
field = required_match.group(1)
|
|
4512
|
+
default = None
|
|
4513
|
+
is_required = True
|
|
4514
|
+
else:
|
|
4515
|
+
match = re.fullmatch(r"([A-Za-z_]\w*)(?:\s*=\s*(.+))?", field_line)
|
|
4516
|
+
if not match:
|
|
4517
|
+
raise SyntaxError(f"Invalid {kind} field declaration: {field_line}")
|
|
4518
|
+
field, default = match.groups()
|
|
4519
|
+
is_required = False
|
|
4520
|
+
if field in fields or field in required_fields or field in optional_fields:
|
|
3817
4521
|
raise SyntaxError((f"Duplicate field `{field}` in {type_name}" if NO_ANSI else f"Duplicate field \033[1m{field}\033[22m in {type_name}"))
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
4522
|
+
if is_required:
|
|
4523
|
+
required_fields.append(field)
|
|
4524
|
+
else:
|
|
4525
|
+
optional_fields.append(field)
|
|
4526
|
+
if default is not None:
|
|
4527
|
+
defaults[field] = evaluate_python(_expand_definitions(default))
|
|
4528
|
+
# Required struct fields always come first, preserving order within
|
|
4529
|
+
# the required and optional groups. This makes positional calls
|
|
4530
|
+
# consistent with the generated constructor signature.
|
|
4531
|
+
fields = required_fields + optional_fields if kind == "struct" else optional_fields
|
|
3821
4532
|
if not fields:
|
|
3822
4533
|
raise SyntaxError((f"{kind.title()} `{type_name}` must declare fields" if NO_ANSI else f"{kind.title()} \033[1m{type_name}\033[22m must declare fields"))
|
|
3823
4534
|
if kind == "struct":
|
|
3824
|
-
def make_struct(class_name, field_names, field_defaults):
|
|
4535
|
+
def make_struct(class_name, field_names, field_defaults, required_names):
|
|
3825
4536
|
def __init__(self, *args, **kwargs):
|
|
3826
4537
|
if len(args) > len(field_names):
|
|
3827
4538
|
raise TypeError(f"{class_name} expected at most {len(field_names)} positional arguments")
|
|
@@ -3833,22 +4544,54 @@ def process_lines(lines):
|
|
|
3833
4544
|
extra = values.keys() - set(field_names)
|
|
3834
4545
|
if extra:
|
|
3835
4546
|
raise TypeError(f"Unexpected field(s): {', '.join(sorted(extra))}")
|
|
4547
|
+
missing = [name for name in required_names if name not in values]
|
|
4548
|
+
if missing:
|
|
4549
|
+
names = ", ".join(missing)
|
|
4550
|
+
raise TypeError(f"{class_name} missing required field(s): {names}")
|
|
3836
4551
|
for field_name in field_names:
|
|
3837
|
-
setattr(self, field_name, values
|
|
4552
|
+
setattr(self, field_name, values[field_name] if field_name in values else field_defaults.get(field_name))
|
|
3838
4553
|
def __repr__(self):
|
|
3839
4554
|
return class_name + "{ " + ", ".join(f"{f}={getattr(self, f)!r}" for f in field_names) + " }"
|
|
3840
4555
|
def __eq__(self, other):
|
|
3841
4556
|
return type(other) is type(self) and all(getattr(self, f) == getattr(other, f) for f in field_names)
|
|
3842
4557
|
return type(class_name, (), {"__init__": __init__, "__repr__": __repr__, "__str__": __repr__, "__eq__": __eq__, "__slots__": tuple(field_names)})
|
|
3843
|
-
generated = make_struct(type_name, fields, defaults)
|
|
4558
|
+
generated = make_struct(type_name, fields, defaults, required_fields)
|
|
3844
4559
|
STRUCTS[type_name] = generated
|
|
3845
4560
|
else:
|
|
3846
4561
|
from collections import namedtuple
|
|
3847
4562
|
first_default = next((n for n, f in enumerate(fields) if f in defaults), len(fields))
|
|
3848
4563
|
if any(f not in defaults for f in fields[first_default:]):
|
|
3849
4564
|
raise SyntaxError((f"Record `{type_name}` fields with defaults must be trailing fields" if NO_ANSI else f"Record \033[1m{type_name}\033[22m fields with defaults must be trailing fields"))
|
|
3850
|
-
|
|
3851
|
-
|
|
4565
|
+
base_record = namedtuple(type_name + "Base", fields, defaults=[defaults[f] for f in fields[first_default:]])
|
|
4566
|
+
def make_record(record_name, record_base, field_names):
|
|
4567
|
+
def _record_repr(self):
|
|
4568
|
+
return record_name + " -> " + ", ".join(f"{field}: {getattr(self, field)}" for field in field_names)
|
|
4569
|
+
def _binary_method(operation, reverse=False):
|
|
4570
|
+
def method(self, other):
|
|
4571
|
+
if type(other) is type(self):
|
|
4572
|
+
values = [operation(getattr(other, field), getattr(self, field)) if reverse else operation(getattr(self, field), getattr(other, field)) for field in field_names]
|
|
4573
|
+
elif isinstance(other, (int, float, complex)):
|
|
4574
|
+
values = [operation(other, getattr(self, field)) if reverse else operation(getattr(self, field), other) for field in field_names]
|
|
4575
|
+
else:
|
|
4576
|
+
return NotImplemented
|
|
4577
|
+
return type(self)(*values)
|
|
4578
|
+
return method
|
|
4579
|
+
attrs = {
|
|
4580
|
+
"__slots__": (), "__module__": __name__, "__str__": _record_repr,
|
|
4581
|
+
"__repr__": _record_repr,
|
|
4582
|
+
"__add__": _binary_method(operator.add), "__radd__": _binary_method(operator.add, True),
|
|
4583
|
+
"__sub__": _binary_method(operator.sub), "__rsub__": _binary_method(operator.sub, True),
|
|
4584
|
+
"__mul__": _binary_method(operator.mul), "__rmul__": _binary_method(operator.mul, True),
|
|
4585
|
+
"__truediv__": _binary_method(operator.truediv), "__rtruediv__": _binary_method(operator.truediv, True),
|
|
4586
|
+
"__floordiv__": _binary_method(operator.floordiv), "__rfloordiv__": _binary_method(operator.floordiv, True),
|
|
4587
|
+
"__mod__": _binary_method(operator.mod), "__rmod__": _binary_method(operator.mod, True),
|
|
4588
|
+
"__pow__": _binary_method(operator.pow), "__rpow__": _binary_method(operator.pow, True),
|
|
4589
|
+
"__neg__": lambda self: type(self)(*(-getattr(self, f) for f in field_names)),
|
|
4590
|
+
"__pos__": lambda self: type(self)(*(+getattr(self, f) for f in field_names)),
|
|
4591
|
+
"__abs__": lambda self: type(self)(*(abs(getattr(self, f)) for f in field_names)),
|
|
4592
|
+
}
|
|
4593
|
+
return type(record_name, (record_base,), attrs)
|
|
4594
|
+
generated = make_record(type_name, base_record, fields)
|
|
3852
4595
|
RECORDS[type_name] = generated
|
|
3853
4596
|
python_globals[type_name] = generated
|
|
3854
4597
|
i = end + 1
|
|
@@ -3930,15 +4673,39 @@ def process_lines(lines):
|
|
|
3930
4673
|
continue
|
|
3931
4674
|
|
|
3932
4675
|
# Function definitions use `func NAME ... endfunc`.
|
|
3933
|
-
|
|
4676
|
+
static_function = False
|
|
4677
|
+
function_source = stripped
|
|
4678
|
+
if re.match(r"^static\s+func\s+", function_source, re.IGNORECASE):
|
|
4679
|
+
static_function = True
|
|
4680
|
+
function_source = re.sub(r"^static\s+func\s+", "func ", function_source, count=1, flags=re.IGNORECASE)
|
|
4681
|
+
elif re.match(r"^func\s+static\s+", function_source, re.IGNORECASE):
|
|
4682
|
+
static_function = True
|
|
4683
|
+
function_source = re.sub(r"^func\s+static\s+", "func ", function_source, count=1, flags=re.IGNORECASE)
|
|
4684
|
+
function_match = re.fullmatch(r"func\s+([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*(?:\.[A-Za-z_]\w*)?)", function_source, re.IGNORECASE)
|
|
3934
4685
|
if function_match:
|
|
3935
4686
|
name = function_match.group(1)
|
|
4687
|
+
if _CURRENT_NAMESPACE is not None and "::" not in name and "." not in name:
|
|
4688
|
+
name = _CURRENT_NAMESPACE._chemql_name + "::" + name # type: ignore
|
|
3936
4689
|
end = i + 1
|
|
3937
4690
|
while end < len(lines) and lines[end].strip().casefold() != "endfunc":
|
|
3938
4691
|
end += 1
|
|
3939
4692
|
if end >= len(lines):
|
|
3940
4693
|
raise SyntaxError((f"Missing `endfunc` for function `{name}`" if NO_ANSI else f"Missing `endfunc` for function \033[1m{name}\033[22m"))
|
|
3941
|
-
FUNCTIONS[name] = {"body": [_expand_definitions(source_line) for source_line in lines[i + 1:end]]}
|
|
4694
|
+
FUNCTIONS[name] = {"body": [_expand_definitions(source_line) for source_line in lines[i + 1:end]], "static": static_function}
|
|
4695
|
+
if _CURRENT_NAMESPACE is not None and "::" in name and "." not in name:
|
|
4696
|
+
member_name = name.rsplit("::", 1)[-1]
|
|
4697
|
+
def _namespace_function(*args, _function_name=name):
|
|
4698
|
+
temp_names = []
|
|
4699
|
+
try:
|
|
4700
|
+
for arg in args:
|
|
4701
|
+
temp = "__chemql_ns_arg_" + uuid.uuid4().hex
|
|
4702
|
+
python_globals[temp] = arg
|
|
4703
|
+
temp_names.append(temp)
|
|
4704
|
+
return _call_function(_function_name, temp_names)
|
|
4705
|
+
finally:
|
|
4706
|
+
for temp in temp_names:
|
|
4707
|
+
python_globals.pop(temp, None)
|
|
4708
|
+
_CURRENT_NAMESPACE._set_member(member_name, _namespace_function, _CURRENT_VISIBILITY == "public")
|
|
3942
4709
|
if "." in name:
|
|
3943
4710
|
owner_name, method_name = name.split(".", 1)
|
|
3944
4711
|
owner = STRUCTS.get(owner_name)
|
|
@@ -3968,7 +4735,23 @@ def process_lines(lines):
|
|
|
3968
4735
|
else:
|
|
3969
4736
|
python_globals.pop("this", None)
|
|
3970
4737
|
return _method
|
|
3971
|
-
|
|
4738
|
+
method_callable = _make_struct_method(name)
|
|
4739
|
+
if static_function:
|
|
4740
|
+
def _make_static_method(function_name):
|
|
4741
|
+
def _static_method(*args):
|
|
4742
|
+
temporary_names = []
|
|
4743
|
+
try:
|
|
4744
|
+
for arg in args:
|
|
4745
|
+
temp_name = "__chemql_static_arg_" + uuid.uuid4().hex
|
|
4746
|
+
python_globals[temp_name] = arg
|
|
4747
|
+
temporary_names.append(temp_name)
|
|
4748
|
+
return _call_function(function_name, temporary_names)
|
|
4749
|
+
finally:
|
|
4750
|
+
for temp_name in temporary_names:
|
|
4751
|
+
python_globals.pop(temp_name, None)
|
|
4752
|
+
return staticmethod(_static_method)
|
|
4753
|
+
method_callable = _make_static_method(name)
|
|
4754
|
+
setattr(owner, method_name, method_callable)
|
|
3972
4755
|
i = end + 1
|
|
3973
4756
|
continue
|
|
3974
4757
|
|
|
@@ -4075,6 +4858,22 @@ def process_lines(lines):
|
|
|
4075
4858
|
i += 1
|
|
4076
4859
|
continue
|
|
4077
4860
|
|
|
4861
|
+
top_level_statement = remove_comments(_expand_definitions(raw_line)).strip()
|
|
4862
|
+
# ChemQL package loading is separate from Python imports.
|
|
4863
|
+
load_statement = top_level_statement
|
|
4864
|
+
if load_statement == "load" or load_statement.startswith("load "):
|
|
4865
|
+
try:
|
|
4866
|
+
if _execute_load_statement(load_statement):
|
|
4867
|
+
_record_execution(load_statement, "", True)
|
|
4868
|
+
i += 1
|
|
4869
|
+
continue
|
|
4870
|
+
except Exception as exc:
|
|
4871
|
+
message = f"\033[31mError: {exc}\033[0m"
|
|
4872
|
+
print(strip_ansi(message) if NO_ANSI else message, file=sys.stderr)
|
|
4873
|
+
_record_execution(load_statement, message, False)
|
|
4874
|
+
i += 1
|
|
4875
|
+
continue
|
|
4876
|
+
|
|
4078
4877
|
# Literal definitions are intentionally expanded inside strings too.
|
|
4079
4878
|
# Directives themselves were handled above, so updates are not retroactive.
|
|
4080
4879
|
raw_line = _expand_definitions(raw_line)
|
|
@@ -4408,7 +5207,9 @@ def execute(arguments, query_text=None, positions=None):
|
|
|
4408
5207
|
return format_error(str(exc), query_text)
|
|
4409
5208
|
|
|
4410
5209
|
if arguments[0].lower() in {"help", "?"}:
|
|
4411
|
-
|
|
5210
|
+
if len(arguments) == 1:
|
|
5211
|
+
return HELP + "\n\nFor built-in function documentation, try `help{read_csv}` or `help read_csv`."
|
|
5212
|
+
return _help_text(" ".join(arguments[1:]))
|
|
4412
5213
|
|
|
4413
5214
|
if arguments[0].lower() == "sorry":
|
|
4414
5215
|
return "\033[34mIt's ok, everybody makes mistakes👌\033[0m"
|
|
@@ -4784,6 +5585,7 @@ HELP = (
|
|
|
4784
5585
|
" \x1b[92mfindre \"Haber process\" \x1b[0m \x1b[90m│\x1b[0m Find a reaction\n"
|
|
4785
5586
|
" \x1b[92mbalance H2 + O2 -> H2O \x1b[0m \x1b[90m│\x1b[0m Balance a chemical equation\n"
|
|
4786
5587
|
" \x1b[92mhelp or ? \x1b[0m \x1b[90m│\x1b[0m Display this help message\n"
|
|
5588
|
+
" \x1b[92mhelp{read_csv} \x1b[0m \x1b[90m│\x1b[0m Show detailed built-in documentation\n"
|
|
4787
5589
|
" \x1b[92mversion \x1b[0m \x1b[90m│\x1b[0m Display the installed version\n"
|
|
4788
5590
|
" \x1b[92mexit \x1b[0m \x1b[90m│\x1b[0m Exit the interactive prompt\n"
|
|
4789
5591
|
"\n"
|
|
@@ -4825,8 +5627,8 @@ HELP = (
|
|
|
4825
5627
|
" \x1b[1;96mset pressure 1atm \x1b[0m \x1b[90m│\x1b[0m Set the reaction pressure\n"
|
|
4826
5628
|
" \x1b[1;96mset catalysts iron zinc \x1b[0m \x1b[90m│\x1b[0m Set available catalysts\n"
|
|
4827
5629
|
" \x1b[1;96mset catalysts \x1b[0m \x1b[90m│\x1b[0m Clear the catalyst list\n"
|
|
4828
|
-
" \x1b[1;96madd catalyst \"Iron\"
|
|
4829
|
-
" \x1b[1;96mremove catalyst \"Iron\"
|
|
5630
|
+
" \x1b[1;96madd catalyst \"Iron\" \x1b[0m \x1b[90m│\x1b[0m Add a catalyst\n"
|
|
5631
|
+
" \x1b[1;96mremove catalyst \"Iron\" \x1b[0m \x1b[90m│\x1b[0m Remove a catalyst\n"
|
|
4830
5632
|
" \x1b[1;96mconditions \x1b[0m \x1b[90m│\x1b[0m Display current conditions\n"
|
|
4831
5633
|
" \x1b[2mTemperature units: K, C, F. Pressure units include Pa, bar, and atm.\x1b[0m\n"
|
|
4832
5634
|
"\n"
|
|
@@ -4849,7 +5651,7 @@ HELP = (
|
|
|
4849
5651
|
" \x1b[95mdefine double{} \x1b[0m \x1b[90m│\x1b[0m Start a macro definition\n"
|
|
4850
5652
|
" \x1b[95menddefine \x1b[0m \x1b[90m│\x1b[0m Finish a macro definition\n"
|
|
4851
5653
|
" \x1b[95m@double{5} \x1b[0m \x1b[90m│\x1b[0m Expand and execute a macro\n"
|
|
4852
|
-
" \x1b[95mmacros
|
|
5654
|
+
" \x1b[95mmacros \x1b[0m \x1b[90m│\x1b[0m List defined macros\n"
|
|
4853
5655
|
" \x1b[2mFunction and macro arguments use positional placeholders: \\1, \\2, and so on.\x1b[0m\n"
|
|
4854
5656
|
"\n"
|
|
4855
5657
|
"\x1b[1;93m\x1b[1m◆ CONTROL FLOW\x1b[0m\n"
|
|
@@ -4965,7 +5767,8 @@ class ChemqlLexer(Lexer):
|
|
|
4965
5767
|
"if", "elif", "else", "for", "while", "break", "continue", "endblock",
|
|
4966
5768
|
"endfor", "endwhile", "endif", "endrisk", "endstruct", "endrecord",
|
|
4967
5769
|
"endfunc", "enddefine", "func", "define", "struct", "record", "risk",
|
|
4968
|
-
"fix", "cleanup", "signal", "import", "as", "return",
|
|
5770
|
+
"fix", "cleanup", "signal", "import", "as", "return", "public", "private",
|
|
5771
|
+
"required", "namespace", "endnamespace"
|
|
4969
5772
|
}
|
|
4970
5773
|
CONDITIONS = {
|
|
4971
5774
|
"temperature", "pressure", "catalyst", "catalysts", "standard", "room",
|
|
@@ -5596,7 +6399,7 @@ def run_interactive():
|
|
|
5596
6399
|
closer = "]]]" if first == "[[[" else "```"
|
|
5597
6400
|
return not (len(source_lines) > 1 and last == closer)
|
|
5598
6401
|
|
|
5599
|
-
if re.fullmatch(r"func\s+" + _DEFINITION_NAME, first, re.IGNORECASE):
|
|
6402
|
+
if re.fullmatch(r"(?:(?:static\s+func|func\s+static|func)\s+)" + _DEFINITION_NAME, first, re.IGNORECASE):
|
|
5600
6403
|
return last.casefold() != "endfunc"
|
|
5601
6404
|
|
|
5602
6405
|
if re.fullmatch(r"define\s+" + _DEFINITION_NAME + r"\s*\{\s*\}", first):
|
|
@@ -5611,7 +6414,7 @@ def run_interactive():
|
|
|
5611
6414
|
closers = {
|
|
5612
6415
|
"endrisk": "risk", "endstruct": "struct", "endrecord": "record",
|
|
5613
6416
|
"endfunc": "func", "enddefine": "macro", "endfor": "for",
|
|
5614
|
-
"endwhile": "while", "endif": "if", "[endblock]": "bracket",
|
|
6417
|
+
"endwhile": "while", "endif": "if", "[endblock]": "bracket", "endnamespace": "namespace"
|
|
5615
6418
|
}
|
|
5616
6419
|
for source_line in source_lines:
|
|
5617
6420
|
stripped = source_line.strip()
|
|
@@ -5619,6 +6422,8 @@ def run_interactive():
|
|
|
5619
6422
|
if not stripped or stripped.startswith("//"):
|
|
5620
6423
|
continue
|
|
5621
6424
|
|
|
6425
|
+
if re.fullmatch(r"namespace\s+[A-Za-z_]\w*", stripped, re.IGNORECASE):
|
|
6426
|
+
stack.append("namespace")
|
|
5622
6427
|
if re.fullmatch(r"struct\s+[A-Za-z_]\w*", stripped, re.IGNORECASE):
|
|
5623
6428
|
stack.append("struct")
|
|
5624
6429
|
elif re.fullmatch(r"record\s+[A-Za-z_]\w*", stripped, re.IGNORECASE):
|
|
@@ -5717,7 +6522,7 @@ def run_interactive():
|
|
|
5717
6522
|
continue
|
|
5718
6523
|
|
|
5719
6524
|
# Collect function and macro declarations as complete REPL units.
|
|
5720
|
-
if lines and re.fullmatch(r"func\s+" + _DEFINITION_NAME, lines[0].strip(), re.IGNORECASE):
|
|
6525
|
+
if lines and re.fullmatch(r"(?:(?:static\s+func|func\s+static|func)\s+)" + _DEFINITION_NAME, lines[0].strip(), re.IGNORECASE):
|
|
5721
6526
|
while lines[-1].strip().casefold() != "endfunc":
|
|
5722
6527
|
try:
|
|
5723
6528
|
lines.append(session.prompt("... "))
|
|
@@ -5840,7 +6645,10 @@ def run_interactive():
|
|
|
5840
6645
|
process_lines(lines)
|
|
5841
6646
|
history.extend(lines)
|
|
5842
6647
|
except Exception as e:
|
|
5843
|
-
|
|
6648
|
+
if isinstance(e, ChemQLBuiltinError):
|
|
6649
|
+
message = f"Error: {type(e).__name__}: {e}"
|
|
6650
|
+
else:
|
|
6651
|
+
message = f"Error: {e}"
|
|
5844
6652
|
print(message if NO_ANSI else f"\033[31m{message}\033[0m")
|
|
5845
6653
|
|
|
5846
6654
|
|