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 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.3.2"
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
- if name in FUNCTIONS:
436
- opening = i + match.end() - 1
437
- end = find_matching(text, opening, "{", "}")
438
- args = _split_function_arguments(text[opening + 1:end])
439
- value = _call_function(name, args)
440
- replacement = repr(value) if isinstance(value, str) else ("None" if value is None else str(value))
441
- text = text[:i] + replacement + text[end + 1:]
442
- changed = True
443
- break
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
- if "." in name:
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_]*)?)\s*\{(.*)\}", stripped, re.DOTALL)
3650
- if function_call and function_call.group(1) in FUNCTIONS:
3651
- return _call_function(function_call.group(1), _split_function_arguments(function_call.group(2)))
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
- match = re.fullmatch(r"([A-Za-z_]\w*)(?:\s*=\s*(.+))?", field_line)
3813
- if not match:
3814
- raise SyntaxError(f"Invalid {kind} field declaration: {field_line}")
3815
- field, default = match.groups()
3816
- if field in fields:
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
- fields.append(field)
3819
- if default is not None:
3820
- defaults[field] = evaluate_python(_expand_definitions(default))
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.get(field_name, field_defaults.get(field_name)))
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
- generated = namedtuple(type_name, fields, defaults=[defaults[f] for f in fields[first_default:]])
3851
- generated.__str__ = lambda self, _name=type_name: _name + " -> " + ", ".join(f"{f}: {getattr(self, f)}" for f in self._fields)
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
- function_match = re.fullmatch(r"func\s+(" + _DEFINITION_NAME + r"(?:\." + _DEFINITION_NAME + r")?)", stripped, re.IGNORECASE)
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
- setattr(owner, method_name, _make_struct_method(name))
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
- return HELP
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\" \x1b[0m \x1b[90m│\x1b[0m Add a catalyst\n"
4829
- " \x1b[1;96mremove catalyst \"Iron\" \x1b[0m \x1b[90m│\x1b[0m Remove a catalyst\n"
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 \x1b[0m \x1b[90m│\x1b[0m List defined macros\n"
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
- message = f"Error: {e}"
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