chemql-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
chemql/__init__.py ADDED
@@ -0,0 +1,3154 @@
1
+ import json
2
+ import math
3
+ import shlex
4
+ import re
5
+ import ast
6
+ import argparse
7
+ import time
8
+ from pathlib import Path
9
+ from prompt_toolkit import PromptSession
10
+ from prompt_toolkit.lexers import Lexer
11
+ from prompt_toolkit.styles import Style
12
+ from prompt_toolkit.completion import Completer, Completion
13
+ import urllib.parse
14
+ import urllib.request
15
+ import urllib.error
16
+
17
+
18
+ __all__ = [
19
+ "ChemQLError",
20
+ "Element",
21
+ "Molecule",
22
+ "Reaction",
23
+ "ReturnTable",
24
+ "Unknown",
25
+ "execute_query",
26
+ "execute_query_text",
27
+ ]
28
+
29
+
30
+ class Unknown:
31
+ """Represents a Chemql query result that cannot be resolved to a known object."""
32
+
33
+ __slots__ = ("value",)
34
+
35
+ def __init__(self, value=None):
36
+ self.value = value
37
+
38
+ def __str__(self):
39
+ return str(self.value) if self.value is not None else "Unknown"
40
+
41
+ def __repr__(self):
42
+ return repr(self.value) if self.value is not None else "Unknown"
43
+
44
+
45
+ class ChemQLError(Exception):
46
+ def __init__(self, message, position=None, length=1):
47
+ super().__init__(message)
48
+ self.position = position
49
+ self.length = length
50
+
51
+
52
+ def format_error(message, text=None, position=None, length=1):
53
+ output = f"Error: {message}"
54
+
55
+ if text is not None and position is not None:
56
+ output += f"\n{text}\n{' ' * position}{'^' * max(1, length)}"
57
+
58
+ return output
59
+
60
+
61
+ def token_positions(text):
62
+ positions = []
63
+ i = 0
64
+ while i < len(text):
65
+ while i < len(text) and text[i].isspace():
66
+ i += 1
67
+ if i >= len(text):
68
+ break
69
+
70
+ start = i
71
+ quote = None
72
+ escaped = False
73
+ while i < len(text):
74
+ char = text[i]
75
+ if escaped:
76
+ escaped = False
77
+ elif char == "\\":
78
+ escaped = True
79
+ elif quote:
80
+ if char == quote:
81
+ quote = None
82
+ elif char in ("\'", '"'):
83
+ quote = char
84
+ elif char.isspace():
85
+ break
86
+ i += 1
87
+ positions.append((start, max(1, i - start)))
88
+ return positions
89
+
90
+
91
+ def error_position(positions, index):
92
+ if 0 <= index < len(positions):
93
+ return positions[index]
94
+ return (None, 1)
95
+ from prompt_toolkit.shortcuts import choice
96
+
97
+ BASE_DIR = Path(__file__).resolve().parent
98
+ DATA_DIR = BASE_DIR / "data"
99
+
100
+ with (DATA_DIR / "PeriodicTableJSON.json").open("r", encoding="utf-8") as f:
101
+ elements = json.load(f)
102
+ with (DATA_DIR / "Molecules.json").open("r", encoding="utf-8") as f:
103
+ molecules = json.load(f)
104
+ with (DATA_DIR / "Reactions.json").open("r", encoding="utf-8") as f:
105
+ reactions = json.load(f)
106
+ with (DATA_DIR / "MoleculeBonds.json").open("r", encoding="utf-8") as f:
107
+ moleculeBonds = json.load(f)
108
+ with (DATA_DIR / "BondEnergies.json").open("r", encoding="utf-8") as f:
109
+ bondEnergies = json.load(f)
110
+
111
+ operators = {"has", "like", "like!", "=", ">", "<", ">=", "<="}
112
+
113
+ queryOperations = {"sort", "limit", "count"}
114
+
115
+ def like_match(value, pattern, case_sensitive=False):
116
+ regex = ""
117
+ i = 0
118
+
119
+ while i < len(pattern):
120
+ char = pattern[i]
121
+
122
+ if char == "\\":
123
+ i += 1
124
+
125
+ if i >= len(pattern):
126
+ raise ValueError(
127
+ "Escape character at end of pattern"
128
+ )
129
+
130
+ regex += re.escape(pattern[i])
131
+
132
+ elif char == "*":
133
+ regex += ".*"
134
+
135
+ elif char == "%":
136
+ regex += "[0-9]*"
137
+
138
+ elif char == "&":
139
+ regex += "[A-Za-z]*"
140
+
141
+ elif char == "_":
142
+ regex += "."
143
+
144
+ elif char == "#":
145
+ regex += "[0-9]"
146
+
147
+ elif char == "?":
148
+ regex += "[A-Za-z]"
149
+
150
+ else:
151
+ regex += re.escape(char)
152
+
153
+ i += 1
154
+
155
+ flags = 0 if case_sensitive else re.IGNORECASE
156
+
157
+ return re.fullmatch(regex, value, flags) is not None
158
+
159
+
160
+ def normal_round(num, p=0):
161
+ num1 = num / 10**p
162
+ numint = math.floor(num1)
163
+ numfloat = num - numint
164
+ if numfloat >= 0.5:
165
+ return -(-numint - 1) * 10**p
166
+ return numint * 10 ** p
167
+
168
+
169
+
170
+ class Element:
171
+ __slots__ = ("name", "appearance", "atomic_mass", "boil", "category", "density", "discovered_by", "melt", "molar_heat", "named_by", "number", "period", "group", "phase", "source", "bohr_model_image", "bohr_model_3d", "spectral_img", "summary", "symbol", "xpos", "ypos", "wxpos", "wypos", "shells", "electron_configuration", "electron_configuration_semantic", "electron_affinity", "electronegativity_pauling", "ionization_energies", "cpk_hex", "image", "block")
172
+
173
+ def __init__(
174
+ self,
175
+ name,
176
+ appearance,
177
+ atomic_mass,
178
+ boil,
179
+ category,
180
+ density,
181
+ discovered_by,
182
+ melt,
183
+ molar_heat,
184
+ named_by,
185
+ number,
186
+ period,
187
+ group,
188
+ phase,
189
+ source,
190
+ bohr_model_image,
191
+ bohr_model_3d,
192
+ spectral_img,
193
+ summary,
194
+ symbol,
195
+ xpos,
196
+ ypos,
197
+ wxpos,
198
+ wypos,
199
+ shells,
200
+ electron_configuration,
201
+ electron_configuration_semantic,
202
+ electron_affinity,
203
+ electronegativity_pauling,
204
+ ionization_energies,
205
+ cpk_hex,
206
+ image,
207
+ block,
208
+ ):
209
+ self.name = name
210
+ self.appearance = appearance
211
+ self.atomic_mass = atomic_mass
212
+ self.boil = boil
213
+ self.category = category
214
+ self.density = density
215
+ self.discovered_by = discovered_by
216
+ self.melt = melt
217
+ self.molar_heat = molar_heat
218
+ self.named_by = named_by
219
+ self.number = number
220
+ self.period = period
221
+ self.group = group
222
+ self.phase = phase
223
+ self.source = source
224
+ self.bohr_model_image = bohr_model_image
225
+ self.bohr_model_3d = bohr_model_3d
226
+ self.spectral_img = spectral_img
227
+ self.summary = summary
228
+ self.symbol = symbol
229
+ self.xpos = xpos
230
+ self.ypos = ypos
231
+ self.wxpos = wxpos
232
+ self.wypos = wypos
233
+ self.shells = shells
234
+ self.electron_configuration = electron_configuration
235
+ self.electron_configuration_semantic = electron_configuration_semantic
236
+ self.electron_affinity = electron_affinity
237
+ self.electronegativity_pauling = electronegativity_pauling
238
+ self.ionization_energies = ionization_energies
239
+ self.cpk_hex = cpk_hex
240
+ self.image = image
241
+ self.block = block
242
+
243
+ def __getitem__(self, key):
244
+ return getattr(self, key)
245
+
246
+ def __str__(self):
247
+ if self.cpk_hex:
248
+ r = int(self.cpk_hex[0:2], 16)
249
+ g = int(self.cpk_hex[2:4], 16)
250
+ b = int(self.cpk_hex[4:6], 16)
251
+ else:
252
+ r = g = b = 255
253
+
254
+ return (
255
+ f"\033[38;2;{r};{g};{b}m"
256
+ f" ______________\n"
257
+ f"| {self.number:^3} |\n"
258
+ f"| |\n"
259
+ f"| {self.symbol:^3} |\n"
260
+ f"| |\n"
261
+ f"| {normal_round(self.atomic_mass):^3} |\n"
262
+ f"|______________|"
263
+ f"\033[0m\n"
264
+ )
265
+ def __repr__(self):
266
+ return f"{self.name}"
267
+
268
+ elementKeys = ("name", "appearance", "atomic_mass", "boil", "category", "density", "discovered_by", "melt", "molar_heat", "named_by", "number", "period", "group", "phase", "source", "bohr_model_image", "bohr_model_3d", "spectral_img", "summary", "symbol", "xpos", "ypos", "wxpos", "wypos", "shells", "electron_configuration", "electron_configuration_semantic", "electron_affinity", "electronegativity_pauling", "ionization_energies", "cpk-hex", "image", "block")
269
+
270
+ ELEMENTS = [Element(*(item[key] if item[key] is not None else "" for key in elementKeys)) for item in elements["elements"]]
271
+
272
+ # No longer need the JSON structure.
273
+
274
+ ELEMENTS.pop()
275
+
276
+ class Molecule:
277
+ __slots__ = ("name", "formula", "elements", "molecular_weight", "iupac_name", "smiles", "cid", "melting_point","boiling_point", "density", "state", "bonds", "total_bond_energy")
278
+ def __init__(self, name, formula, elements, molecular_weight, iupac_name, smiles, cid, melting_point, boiling_point, density, state):
279
+ self.name = name
280
+ self.formula = formula
281
+ self.elements = elements
282
+ self.molecular_weight = molecular_weight
283
+ self.iupac_name = iupac_name
284
+ self.smiles = smiles
285
+ self.cid = cid
286
+ self.melting_point = melting_point
287
+ self.boiling_point = boiling_point
288
+ self.density = density
289
+ self.state = state
290
+ self.bonds = moleculeBonds.get(name, {}).get("bonds", [])
291
+ self.total_bond_energy = sum(
292
+ bond.get("count", 0) * bondEnergies[
293
+ f"{'-'.join(sorted(bond['elements']))}-{bond['type']}"
294
+ ]["energy"]
295
+ for bond in self.bonds
296
+ )
297
+ def __getitem__(self, key):
298
+ return getattr(self, key)
299
+ def __str__(self):
300
+ return f"{self.formula.translate(str.maketrans('0123456789', '₀₁₂₃₄₅₆₇₈₉'))} ({self.name})"
301
+
302
+ def __repr__(self):
303
+ return self.name
304
+
305
+ moleculeKeys = ("name", "formula", "elements", "molecular_weight", "iupac_name", "smiles", "cid", "melting_point", "boiling_point", "density", "state", "bonds", "total_bond_energy")
306
+
307
+ PUBCHEM_BASE_URL = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
308
+ PUBCHEM_VIEW_URL = "https://pubchem.ncbi.nlm.nih.gov/rest/pug_view"
309
+ PUBCHEM_SERVICE_ERROR = False
310
+
311
+
312
+ def _normalize_molecule_value(value):
313
+ """Strip surrounding quotes and whitespace from a molecule lookup."""
314
+ if value is None:
315
+ return ""
316
+
317
+ text = str(value).strip()
318
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in {'"', "'"}:
319
+ text = text[1:-1].strip()
320
+
321
+ return text
322
+
323
+
324
+ def _fetch_url_text(url, timeout=15, retries=2):
325
+ """Fetch a raw text response from a URL, retrying transient failures."""
326
+ request = urllib.request.Request(
327
+ url,
328
+ headers={
329
+ "User-Agent": "ChemQL/1.0 (+https://example.invalid)",
330
+ "Accept": "text/html,application/json;q=0.9,*/*;q=0.8",
331
+ },
332
+ )
333
+
334
+ for attempt in range(retries + 1):
335
+ try:
336
+ with urllib.request.urlopen(request, timeout=timeout) as response:
337
+ return response.read().decode("utf-8", errors="replace")
338
+ except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError):
339
+ if attempt < retries:
340
+ time.sleep(0.5 * (attempt + 1))
341
+ continue
342
+ return ""
343
+
344
+ return ""
345
+
346
+
347
+ def _get_json(url, timeout=10, retries=3):
348
+ """Fetch JSON from a URL with retries for transient server errors."""
349
+ global PUBCHEM_SERVICE_ERROR
350
+
351
+ request = urllib.request.Request(
352
+ url,
353
+ headers={
354
+ "User-Agent": "ChemQL/1.0 (+https://example.invalid)",
355
+ "Accept": "application/json",
356
+ },
357
+ )
358
+
359
+ for attempt in range(retries + 1):
360
+ try:
361
+ with urllib.request.urlopen(request, timeout=timeout) as response:
362
+ data = json.load(response)
363
+ PUBCHEM_SERVICE_ERROR = False
364
+ return data
365
+
366
+ except urllib.error.HTTPError as exc:
367
+ if exc.code in (500, 502, 503, 504):
368
+ PUBCHEM_SERVICE_ERROR = True
369
+ if attempt < retries:
370
+ time.sleep(0.5 * (attempt + 1))
371
+ continue
372
+ return None
373
+ PUBCHEM_SERVICE_ERROR = False
374
+ return None
375
+
376
+ except (
377
+ urllib.error.URLError,
378
+ json.JSONDecodeError,
379
+ TimeoutError,
380
+ OSError,
381
+ ValueError,
382
+ ):
383
+ PUBCHEM_SERVICE_ERROR = True
384
+ if attempt < retries:
385
+ time.sleep(0.5 * (attempt + 1))
386
+ continue
387
+ return None
388
+
389
+
390
+ def _find_local_molecule(value):
391
+ """Return a built-in molecule when the network lookup is unavailable."""
392
+ if value is None:
393
+ return None
394
+
395
+ target = _normalize_molecule_value(value)
396
+ if not target:
397
+ return None
398
+
399
+ normalized = target.lower()
400
+
401
+ for molecule in MOLECULES:
402
+ if molecule.name.lower() == normalized:
403
+ return molecule
404
+ if molecule.formula.lower() == normalized:
405
+ return molecule
406
+ if molecule.iupac_name and molecule.iupac_name.lower() == normalized:
407
+ return molecule
408
+
409
+ for molecule in MOLECULES:
410
+ if molecule.name.lower().startswith(normalized):
411
+ return molecule
412
+ if molecule.formula.lower().startswith(normalized):
413
+ return molecule
414
+ if molecule.iupac_name and molecule.iupac_name.lower().startswith(normalized):
415
+ return molecule
416
+
417
+ return None
418
+
419
+
420
+ def _fetch_nist_molecule(value):
421
+ """Try NIST WebBook as a fallback chemistry source."""
422
+ target = _normalize_molecule_value(value)
423
+ if not target:
424
+ return None
425
+
426
+ encoded = urllib.parse.quote(target)
427
+ url = f"https://webbook.nist.gov/cgi/cbook.cgi?Name={encoded}&Units=SI"
428
+ html = _fetch_url_text(url)
429
+ if not html:
430
+ return None
431
+
432
+ formula_match = re.search(r"Formula\s*[:<]?[\s\n]*([A-Z][a-z]?(?:\d+)?(?:[A-Z][a-z]?(?:\d+)?)*)", html, re.I)
433
+ weight_match = re.search(r"Molecular weight\s*[:<]?[\s\n]*([0-9]+(?:\.[0-9]+)?)", html, re.I)
434
+
435
+ if not formula_match:
436
+ return None
437
+
438
+ formula = formula_match.group(1).strip()
439
+ molecular_weight = float(weight_match.group(1)) if weight_match else 0.0
440
+
441
+ return Molecule(
442
+ name=target,
443
+ formula=formula,
444
+ elements=[],
445
+ molecular_weight=molecular_weight,
446
+ iupac_name="",
447
+ smiles="",
448
+ cid=None,
449
+ melting_point=None,
450
+ boiling_point=None,
451
+ density=None,
452
+ state=None,
453
+ )
454
+
455
+
456
+ def fetch_external_molecule(value):
457
+ """Try public chemistry sources once the local JSON does not contain the molecule."""
458
+ target = _normalize_molecule_value(value)
459
+ if not target:
460
+ return None
461
+
462
+ # PubChem is the first external source because it is the most complete.
463
+ cid = _find_cid(target)
464
+ if cid is None:
465
+ return _fetch_nist_molecule(target)
466
+
467
+ properties = _get_basic_properties(cid)
468
+ if properties is None:
469
+ return _fetch_nist_molecule(target)
470
+
471
+ view_data = _get_view_data(cid)
472
+ melting_point = _get_first_temperature(view_data, "Melting Point") if view_data else None
473
+ boiling_point = _get_first_temperature(view_data, "Boiling Point") if view_data else None
474
+ density = _get_first_density(view_data) if view_data else None
475
+ state = _determine_state(melting_point, boiling_point, temperature=25.0) if melting_point is not None and boiling_point is not None else None
476
+
477
+ return Molecule(
478
+ name=target,
479
+ formula=properties.get("MolecularFormula", ""),
480
+ elements=[],
481
+ molecular_weight=properties.get("MolecularWeight", 0),
482
+ iupac_name=properties.get("IUPACName", ""),
483
+ smiles=properties.get("SMILES", ""),
484
+ cid=properties.get("CID", cid),
485
+ melting_point=melting_point,
486
+ boiling_point=boiling_point,
487
+ density=density,
488
+ state=state,
489
+ )
490
+
491
+
492
+ def _find_cid(value):
493
+ """
494
+ Find a PubChem CID.
495
+
496
+ Tries the input as:
497
+ 1. Chemical name
498
+ 2. Molecular formula
499
+ """
500
+
501
+ if value is None:
502
+ return None
503
+
504
+ encoded_value = urllib.parse.quote(str(value), safe="")
505
+
506
+ # ---------------------------------------------------------
507
+ # Try chemical name
508
+ # ---------------------------------------------------------
509
+ url = (
510
+ f"{PUBCHEM_BASE_URL}/compound/name/"
511
+ f"{encoded_value}/cids/JSON"
512
+ )
513
+
514
+ data = _get_json(url)
515
+
516
+ if data:
517
+ identifier_list = data.get("IdentifierList", {})
518
+ cids = identifier_list.get("CID", [])
519
+ if cids:
520
+ return cids[0]
521
+
522
+ # ---------------------------------------------------------
523
+ # Try molecular formula
524
+ # ---------------------------------------------------------
525
+ url = (
526
+ f"{PUBCHEM_BASE_URL}/compound/fastformula/"
527
+ f"{encoded_value}/cids/JSON"
528
+ )
529
+
530
+ data = _get_json(url)
531
+
532
+ if data:
533
+ identifier_list = data.get("IdentifierList", {})
534
+ cids = identifier_list.get("CID", [])
535
+ if cids:
536
+ return cids[0]
537
+
538
+ return None
539
+
540
+
541
+ def _get_basic_properties(cid):
542
+ """Get basic molecular properties from PubChem."""
543
+
544
+ url = (
545
+ f"{PUBCHEM_BASE_URL}/compound/cid/{cid}/property/"
546
+ "MolecularFormula,MolecularWeight,IUPACName,SMILES/JSON"
547
+ )
548
+
549
+ data = _get_json(url)
550
+
551
+ if not data:
552
+ return None
553
+
554
+ try:
555
+ return data["PropertyTable"]["Properties"][0]
556
+
557
+ except (KeyError, IndexError, TypeError):
558
+ return None
559
+
560
+
561
+ def _get_view_data(cid):
562
+ """
563
+ Get the complete PUG-View record.
564
+
565
+ PUG-View contains experimental properties such as:
566
+ - Melting Point
567
+ - Boiling Point
568
+ - Density
569
+ """
570
+
571
+ url = (
572
+ f"{PUBCHEM_VIEW_URL}/data/compound/{cid}/JSON"
573
+ )
574
+
575
+ return _get_json(url)
576
+
577
+
578
+ def _walk_sections(obj):
579
+ """Recursively find PubChem PUG-View sections."""
580
+
581
+ if isinstance(obj, dict):
582
+
583
+ if "TOCHeading" in obj:
584
+ yield obj
585
+
586
+ for value in obj.values():
587
+ yield from _walk_sections(value)
588
+
589
+ elif isinstance(obj, list):
590
+
591
+ for item in obj:
592
+ yield from _walk_sections(item)
593
+
594
+
595
+ def _find_section(data, heading):
596
+ """Find a PUG-View section by heading."""
597
+
598
+ if not data:
599
+ return None
600
+
601
+ for section in _walk_sections(data):
602
+
603
+ if section.get("TOCHeading", "").lower() == heading.lower():
604
+ return section
605
+
606
+ return None
607
+
608
+
609
+ def _extract_text(value):
610
+ """
611
+ Convert PubChem's nested Value structure into text.
612
+ """
613
+
614
+ if value is None:
615
+ return ""
616
+
617
+ if isinstance(value, str):
618
+ return value
619
+
620
+ if isinstance(value, (int, float)):
621
+ return str(value)
622
+
623
+ if isinstance(value, list):
624
+ return " ".join(
625
+ text
626
+ for item in value
627
+ if (text := _extract_text(item))
628
+ )
629
+
630
+ if isinstance(value, dict):
631
+
632
+ # Common PubChem formats
633
+ for key in (
634
+ "StringWithMarkup",
635
+ "String",
636
+ "StringValue",
637
+ ):
638
+ if key in value:
639
+ text = _extract_text(value[key])
640
+
641
+ if text:
642
+ return text
643
+
644
+ for key in (
645
+ "Number",
646
+ "FloatValue",
647
+ ):
648
+ if key in value:
649
+ return str(value[key])
650
+
651
+ # Fallback
652
+ return " ".join(
653
+ text
654
+ for item in value.values()
655
+ if (text := _extract_text(item))
656
+ )
657
+
658
+ return ""
659
+
660
+
661
+ def _get_section_values(data, heading):
662
+ """
663
+ Get the text values reported in a PubChem section.
664
+ """
665
+
666
+ section = _find_section(data, heading)
667
+
668
+ if not section:
669
+ return []
670
+
671
+ values = []
672
+
673
+ for information in section.get("Information", []):
674
+
675
+ if not isinstance(information, dict):
676
+ continue
677
+
678
+ value = information.get("Value")
679
+
680
+ text = _extract_text(value)
681
+
682
+ if text:
683
+ values.append(text.strip())
684
+
685
+ return values
686
+
687
+
688
+ def _parse_temperature(text):
689
+ """
690
+ Convert a temperature string into Celsius.
691
+
692
+ Examples:
693
+ "78 °C" -> 78.0
694
+ "78-80 °C" -> 79.0
695
+ "172 °F" -> 77.777...
696
+ "351 K" -> 77.85
697
+
698
+ Returns None if no temperature can be extracted.
699
+ """
700
+
701
+ if not text:
702
+ return None
703
+
704
+ # ---------------------------------------------------------
705
+ # Find numbers
706
+ # ---------------------------------------------------------
707
+ numbers = re.findall(
708
+ r"[-+]?\d+(?:\.\d+)?",
709
+ text
710
+ )
711
+
712
+ if not numbers:
713
+ return None
714
+
715
+ values = [float(number) for number in numbers]
716
+
717
+ # If it's a range, use the average
718
+ value = sum(values[:2]) / min(len(values), 2)
719
+
720
+ text_lower = text.lower()
721
+
722
+ # ---------------------------------------------------------
723
+ # Convert to Celsius
724
+ # ---------------------------------------------------------
725
+ if "°f" in text_lower or "deg f" in text_lower:
726
+ return (value - 32) * 5 / 9
727
+
728
+ if re.search(r"\bk\b", text_lower):
729
+ return value - 273.15
730
+
731
+ # Default / expected unit
732
+ return value
733
+
734
+
735
+ def _parse_density(text):
736
+ """
737
+ Convert density to g/cm^3.
738
+
739
+ Examples:
740
+ "1.05 g/mL" -> 1.05
741
+ "1.05 g/cm3" -> 1.05
742
+ "1050 kg/m3" -> 1.05
743
+ "1.05 g/cm³" -> 1.05
744
+ """
745
+
746
+ if not text:
747
+ return None
748
+
749
+ match = re.search(
750
+ r"[-+]?\d+(?:\.\d+)?",
751
+ text
752
+ )
753
+
754
+ if not match:
755
+ return None
756
+
757
+ value = float(match.group(0))
758
+ text_lower = text.lower()
759
+
760
+ # ---------------------------------------------------------
761
+ # Convert to g/cm^3
762
+ # ---------------------------------------------------------
763
+
764
+ if "kg/m3" in text_lower or "kg/m³" in text_lower:
765
+ return value / 1000
766
+
767
+ if "mg/ml" in text_lower:
768
+ return value / 1000
769
+
770
+ # g/mL and g/cm3 are numerically equivalent
771
+ if "g/ml" in text_lower:
772
+ return value
773
+
774
+ if "g/cm3" in text_lower or "g/cm³" in text_lower:
775
+ return value
776
+
777
+ # If PubChem didn't provide a recognizable unit,
778
+ # assume g/cm3 because that is the unit used by this app.
779
+ return value
780
+
781
+
782
+ def _get_first_temperature(data, heading):
783
+ """Get the first reported temperature in Celsius."""
784
+
785
+ values = _get_section_values(data, heading)
786
+
787
+ for value in values:
788
+
789
+ temperature = _parse_temperature(value)
790
+
791
+ if temperature is not None:
792
+ return temperature
793
+
794
+ return None
795
+
796
+
797
+ def _get_first_density(data):
798
+ """Get the first reported density in g/cm3."""
799
+
800
+ values = _get_section_values(data, "Density")
801
+
802
+ for value in values:
803
+
804
+ density = _parse_density(value)
805
+
806
+ if density is not None:
807
+ return density
808
+
809
+ return None
810
+
811
+
812
+ def _determine_state(
813
+ melting_point,
814
+ boiling_point,
815
+ temperature=25.0,
816
+ ):
817
+ """
818
+ Determine physical state at 25 °C.
819
+
820
+ Assumes approximately 1 atm pressure.
821
+ """
822
+
823
+ if melting_point is None or boiling_point is None:
824
+ return None
825
+
826
+ if temperature < melting_point:
827
+ return "solid"
828
+
829
+ if temperature >= boiling_point:
830
+ return "gas"
831
+
832
+ return "liquid"
833
+
834
+
835
+ def fetch_pubchem_molecule(value):
836
+ """
837
+ Look up a molecule locally, then using PubChem and fallback sources.
838
+
839
+ `value` can be:
840
+ - chemical name: "water"
841
+ - molecular formula: "H2O"
842
+
843
+ Returns:
844
+ Molecule instance
845
+ or None if the molecule cannot be found.
846
+ """
847
+
848
+ local_match = _find_local_molecule(value)
849
+ if local_match is not None:
850
+ return local_match
851
+
852
+ # ---------------------------------------------------------
853
+ # Search external chemistry sources when not in the local dataset.
854
+ # ---------------------------------------------------------
855
+ external_match = fetch_external_molecule(value)
856
+ return external_match
857
+
858
+
859
+
860
+ MOLECULES = [Molecule(item["name"], item["formula"], item["elements"], item["molecular_weight"], item["iupac_name"] or "", item["smiles"] or "", item["cid"], item["melting_point"], item["boiling_point"], item["density"], item["state"]) for item in molecules]
861
+
862
+ MOLECULES_BY_FORMULA = {molecule.formula: molecule for molecule in MOLECULES}
863
+
864
+ def molecule_bond_energy(formula):
865
+ molecule = MOLECULES_BY_FORMULA.get(formula)
866
+ return molecule.total_bond_energy if molecule is not None else 0
867
+
868
+
869
+
870
+ class Reaction:
871
+ __slots__ = ("id", "name", "equation", "reaction_type", "reversible", "reactants", "products", "conditions", "notes", "source")
872
+ def __init__(self, id_, name, reaction_type, reversible, reactants, products, conditions, notes, source=""):
873
+ self.id = id_
874
+ self.name = name
875
+ self.reaction_type = reaction_type
876
+ self.reversible = reversible
877
+ self.reactants = reactants
878
+ self.products = products
879
+ self.conditions = conditions
880
+ self.notes = notes
881
+ self.source = source
882
+ subscript_digits = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
883
+
884
+ reactant_text = " + ".join(
885
+ f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
886
+ for item in reactants
887
+ )
888
+ product_text = " + ".join(
889
+ f"{item['stoichiometric_coefficient']} {item['molecule'].translate(subscript_digits)}"
890
+ for item in products
891
+ )
892
+ arrow = " ⇌ " if self.reversible is True else " → " if self.reversible is False else " = "
893
+ self.equation = f"{reactant_text}{arrow}{product_text}"
894
+
895
+ if reversible is not None:
896
+ reactant_energy = sum(
897
+ molecule_bond_energy(item["molecule"]) * item["stoichiometric_coefficient"]
898
+ for item in reactants
899
+ )
900
+ product_energy = sum(
901
+ molecule_bond_energy(item["molecule"]) * item["stoichiometric_coefficient"]
902
+ for item in products
903
+ )
904
+ total_energy = reactant_energy - product_energy
905
+ self.equation += f"\tΔH={total_energy}kJ/mol"
906
+ if self.conditions:
907
+ self.equation += "\nConditions:\n"
908
+ for i in self.conditions:
909
+ self.equation += f"\t{i}: {self.conditions[i]}\n"
910
+ def __str__(self):
911
+ return self.equation
912
+ def __repr__(self):
913
+ return self.name
914
+
915
+
916
+ REACTIONS = [Reaction(item["id"], item["name"], item["reaction_type"], item["reversible"], item["reactants"], item["products"], item["conditions"], item["notes"], item.get("source", "")) for item in reactions]
917
+ reactionKeys = ("id", "name", "equation", "reaction_type", "reversible", "reactants", "products", "conditions", "notes", "source")
918
+ del elements
919
+ del reactions
920
+ del molecules
921
+ del MOLECULES_BY_FORMULA
922
+
923
+
924
+
925
+ def queryElement(key, func, value, *params):
926
+ if key not in elementKeys:
927
+ return f"There is no key `{key}`"
928
+
929
+ for element in ELEMENTS:
930
+ if getattr(element[key], func)(value, *params):
931
+ return element
932
+
933
+ return f"Turns out no element has a {key} of {value}"
934
+
935
+
936
+ def queryElementLast(key, func, value, *params):
937
+ if key not in elementKeys:
938
+ raise KeyError(f"There is no key `{key}`")
939
+
940
+ for element in reversed(ELEMENTS):
941
+ if getattr(element[key], func)(value, *params):
942
+ return element
943
+
944
+ return f"Turns out no element has a {key} of {value}"
945
+
946
+
947
+ def queryElementAll(key, func, value, *params):
948
+ if key not in elementKeys:
949
+ raise KeyError(f"There is no key `{key}`")
950
+
951
+ found = False
952
+
953
+ for element in ELEMENTS:
954
+ if getattr(element[key], func)(value, *params):
955
+ found = True
956
+ yield element
957
+
958
+ if not found:
959
+ return
960
+
961
+ class ReturnTable(dict):
962
+ """Dictionary result that prints as a table in the REPL."""
963
+
964
+ def __str__(self):
965
+ return format_return_table(self)
966
+
967
+
968
+ def format_return_table(data):
969
+ if not data:
970
+ return ""
971
+
972
+ columns = list(data.keys())
973
+ rows = []
974
+ row_count = max((len(data[column]) for column in columns), default=0)
975
+
976
+ for i in range(row_count):
977
+ rows.append([str(data[column][i]) if i < len(data[column]) else "" for column in columns])
978
+
979
+ widths = [max(len(str(column)), *(len(row[index]) for row in rows)) for index, column in enumerate(columns)]
980
+
981
+ header = " ".join(
982
+ str(column).upper().ljust(widths[index])
983
+ for index, column in enumerate(columns)
984
+ ).rstrip()
985
+
986
+ separator = ''.join("─" * i for i in widths) + "──" *(len(data.keys()) - 1)
987
+
988
+ body = [" ".join(row[index].ljust(widths[index]) for index in range(len(columns))).rstrip() for row in rows]
989
+
990
+ return "\n".join([header, separator, *body])
991
+
992
+
993
+ def project_results(results, attributes):
994
+ """Return one column as a 1D list, or multiple columns as a dict of 1D lists."""
995
+
996
+ if len(attributes) == 1:
997
+ return [getattr(item, attributes[0]) for item in results]
998
+
999
+ return ReturnTable({attribute: [getattr(item, attribute) for item in results] for attribute in attributes})
1000
+
1001
+
1002
+ def sort_results(results, key, descending=False):
1003
+ try:
1004
+ return sorted(
1005
+ results,
1006
+ key=lambda item: getattr(item, key),
1007
+ reverse=descending
1008
+ )
1009
+ except (TypeError, AttributeError):
1010
+ raise ValueError(f"Cannot sort by `{key}`")
1011
+
1012
+ def apply_query_operations(results, tokens, keys, positions=None):
1013
+ i = 0
1014
+
1015
+ while i < len(tokens):
1016
+ token = tokens[i]
1017
+
1018
+ # sort
1019
+ if token == "sort":
1020
+ if i + 1 >= len(tokens):
1021
+ raise ChemQLError("Expected a key after `sort`", *(error_position(positions, i) if positions else (None, 1))) #type:ignore
1022
+
1023
+ key = tokens[i + 1]
1024
+
1025
+ if key not in keys:
1026
+ pos, length = error_position(positions, i + 1) if positions else (None, 1)
1027
+ raise ChemQLError(f"There is no key `{key}`", pos, length)
1028
+
1029
+ descending = False
1030
+
1031
+ if i + 2 < len(tokens):
1032
+ direction = tokens[i + 2].lower()
1033
+
1034
+ if direction == "asc":
1035
+ i += 1
1036
+
1037
+ elif direction == "desc":
1038
+ descending = True
1039
+ i += 1
1040
+
1041
+ try:
1042
+ results = sort_results(
1043
+ results,
1044
+ key,
1045
+ descending
1046
+ )
1047
+ except ValueError as e:
1048
+ pos, length = error_position(positions, i + 1) if positions else (None, 1)
1049
+ raise ChemQLError(str(e), pos, length) from e
1050
+
1051
+ i += 2
1052
+
1053
+ # limit
1054
+ elif token == "limit":
1055
+ if i + 1 >= len(tokens):
1056
+ raise ChemQLError("Expected a number after `limit`", *(error_position(positions, i) if positions else (None, 1)))#type:ignore
1057
+
1058
+ try:
1059
+ amount = int(tokens[i + 1])
1060
+ except ValueError:
1061
+ pos, length = error_position(positions, i + 1) if positions else (None, 1)
1062
+ raise ChemQLError("`limit` requires an integer", pos, length)
1063
+
1064
+ if amount < 0:
1065
+ pos, length = error_position(positions, i + 1) if positions else (None, 1)
1066
+ raise ChemQLError("`limit` cannot be negative", pos, length)
1067
+
1068
+ results = results[:amount] # type: ignore
1069
+
1070
+ i += 2
1071
+
1072
+ # count
1073
+ elif token == "count":
1074
+ if i + 1 < len(tokens):
1075
+ pos, length = error_position(positions, i) if positions else (None, 1)
1076
+ raise ChemQLError("`count` must be the final query operation", pos, length)
1077
+
1078
+ results = len(results) # type: ignore
1079
+ i += 1
1080
+
1081
+ else:
1082
+ pos, length = error_position(positions, i) if positions else (None, 1)
1083
+ raise ChemQLError(f"Unknown query operation `{token}`", pos, length)
1084
+
1085
+ return results
1086
+
1087
+
1088
+ functionMap = {"=": "__eq__", ">": "__gt__", "<": "__lt__", ">=": "__ge__", "<=": "__le__"}
1089
+
1090
+
1091
+ def convert_value(value, sample):
1092
+ """Convert a query value to the type of the element value."""
1093
+
1094
+ if isinstance(sample, bool):
1095
+
1096
+ if value.lower() == "true":
1097
+ return True
1098
+
1099
+ if value.lower() == "false":
1100
+ return False
1101
+
1102
+ raise ValueError(f"`{value}` is not a boolean")
1103
+
1104
+ if isinstance(sample, (int, float)):
1105
+
1106
+ try:
1107
+ number = float(value)
1108
+
1109
+ if number.is_integer():
1110
+ return int(number)
1111
+
1112
+ return number
1113
+
1114
+ except ValueError:
1115
+ raise ValueError(f"`{value}` is not a number")
1116
+
1117
+ return value
1118
+
1119
+
1120
+ def tokenize_query(arguments):
1121
+ """Convert tokens into a form suitable for parsing."""
1122
+
1123
+ return arguments
1124
+
1125
+
1126
+ def parse_condition(tokens, index, source, keys, positions=None):
1127
+ if index + 2 >= len(tokens):
1128
+ raise ChemQLError("Incomplete condition", *(error_position(positions, index) if positions else (None, 1)))#type:ignore
1129
+
1130
+ key = tokens[index]
1131
+ operator = tokens[index + 1]
1132
+ value = tokens[index + 2]
1133
+
1134
+ if key not in keys:
1135
+ pos, length = error_position(positions, index) if positions else (None, 1)
1136
+ raise ChemQLError(f"There is no key `{key}`", pos, length)
1137
+
1138
+ if operator == "like" or operator == "like!":
1139
+ pattern = value
1140
+ case_sensitive = operator == "like!"
1141
+
1142
+ def like_condition(item):
1143
+ actual = getattr(item, key)
1144
+
1145
+ if not isinstance(actual, str):
1146
+ return False
1147
+
1148
+ return like_match(
1149
+ actual,
1150
+ pattern,
1151
+ case_sensitive
1152
+ )
1153
+
1154
+ return like_condition, index + 3
1155
+
1156
+ if operator == "has":
1157
+ def has_condition(item):
1158
+ actual = getattr(item, key)
1159
+
1160
+ try:
1161
+ return value in actual
1162
+ except TypeError:
1163
+ return False
1164
+
1165
+ return has_condition, index + 3
1166
+
1167
+ if operator not in functionMap:
1168
+ pos, length = error_position(positions, index + 1) if positions else (None, 1)
1169
+ raise ChemQLError(f"Unknown operator `{operator}`", pos, length)
1170
+
1171
+ sample = getattr(source[0], key)
1172
+
1173
+ try:
1174
+ value = convert_value(value, sample)
1175
+ except ValueError as e:
1176
+ pos, length = error_position(positions, index + 2) if positions else (None, 1)
1177
+ raise ChemQLError(str(e), pos, length) from e
1178
+
1179
+ function = functionMap[operator]
1180
+
1181
+ def comparison_condition(item):
1182
+ try:
1183
+ return getattr(
1184
+ getattr(item, key),
1185
+ function
1186
+ )(value)
1187
+ except (TypeError, AttributeError):
1188
+ return False
1189
+
1190
+ return comparison_condition, index + 3
1191
+
1192
+ def parse_primary(tokens, index, source, keys, positions=None):
1193
+ if index >= len(tokens):
1194
+ raise ChemQLError("Expected condition", *(error_position(positions, index) if positions else (None, 1)))#type:ignore
1195
+
1196
+ if tokens[index] == "(":
1197
+ expression, index = parse_or(
1198
+ tokens,
1199
+ index + 1,
1200
+ source,
1201
+ keys,
1202
+ positions
1203
+ )
1204
+
1205
+ if index >= len(tokens) or tokens[index] != ")":
1206
+ pos = positions[index][0] if positions and index < len(positions) else None
1207
+ raise ChemQLError("Missing `)`", pos)
1208
+
1209
+ return expression, index + 1
1210
+
1211
+ return parse_condition(
1212
+ tokens,
1213
+ index,
1214
+ source,
1215
+ keys,
1216
+ positions
1217
+ )
1218
+
1219
+
1220
+ def parse_and(tokens, index, source, keys, positions=None):
1221
+ left, index = parse_primary(
1222
+ tokens,
1223
+ index,
1224
+ source,
1225
+ keys,
1226
+ positions
1227
+ )
1228
+
1229
+ while index < len(tokens) and tokens[index] == "and":
1230
+ right, index = parse_primary(
1231
+ tokens,
1232
+ index + 1,
1233
+ source,
1234
+ keys,
1235
+ positions
1236
+ )
1237
+
1238
+ previous = left
1239
+
1240
+ left = lambda item, a=previous, b=right: (
1241
+ a(item) and b(item)
1242
+ )
1243
+
1244
+ return left, index
1245
+
1246
+
1247
+ def parse_or(tokens, index, source, keys, positions=None):
1248
+ left, index = parse_and(
1249
+ tokens,
1250
+ index,
1251
+ source,
1252
+ keys,
1253
+ positions
1254
+ )
1255
+
1256
+ while index < len(tokens) and tokens[index] == "or":
1257
+ right, index = parse_and(
1258
+ tokens,
1259
+ index + 1,
1260
+ source,
1261
+ keys,
1262
+ positions
1263
+ )
1264
+
1265
+ previous = left
1266
+
1267
+ left = lambda item, a=previous, b=right: (
1268
+ a(item) or b(item)
1269
+ )
1270
+
1271
+ return left, index
1272
+
1273
+
1274
+ def parse_query(tokens, source, keys, positions=None):
1275
+ if not tokens:
1276
+ raise ChemQLError("No conditions specified", *(error_position(positions, 0) if positions else (None, 1)))#type:ignore
1277
+
1278
+ # Allow a query to operate on the entire source when it begins
1279
+ # with a query operation, e.g. `search molecules count`.
1280
+ if tokens[0] in queryOperations:
1281
+ return (lambda item: True), 0
1282
+
1283
+ condition, index = parse_or(
1284
+ tokens,
1285
+ 0,
1286
+ source,
1287
+ keys,
1288
+ positions
1289
+ )
1290
+
1291
+ # Conditions end when a query operation begins.
1292
+ if index < len(tokens) and tokens[index] not in queryOperations:
1293
+ pos, length = error_position(positions, index) if positions else (None, 1)
1294
+ raise ChemQLError(
1295
+ f"Unexpected `{tokens[index]}`", pos, length
1296
+ )
1297
+
1298
+ return condition, index
1299
+
1300
+
1301
+
1302
+ SOURCES = {"elements": (ELEMENTS, elementKeys), "molecules": (MOLECULES, moleculeKeys), "reactions": (REACTIONS, reactionKeys)}
1303
+
1304
+
1305
+ def get_source(source):
1306
+ return SOURCES.get(source)
1307
+
1308
+ current_source = None
1309
+
1310
+ REACTION_TEMPERATURE_TOLERANCE_K = 5.0
1311
+ REACTION_PRESSURE_TOLERANCE = 0.05
1312
+ reaction_state = {
1313
+ "temperature_k": None,
1314
+ "pressure_pa": None,
1315
+ "catalysts": [],
1316
+ }
1317
+
1318
+ # Python variables persist for the whole session.
1319
+ python_globals = {"__builtins__": __builtins__, "math": math}
1320
+
1321
+
1322
+ def find_matching(text, start, opening, closing):
1323
+ """Find the matching closing delimiter, allowing nested delimiters."""
1324
+ depth = 1
1325
+ i = start + len(opening)
1326
+ quote = None
1327
+ escaped = False
1328
+
1329
+ while i < len(text):
1330
+ char = text[i]
1331
+
1332
+ if quote is not None:
1333
+ if escaped:
1334
+ escaped = False
1335
+ elif char == "\\":
1336
+ escaped = True
1337
+ elif char == quote:
1338
+ quote = None
1339
+ i += 1
1340
+ continue
1341
+
1342
+ if char in ("'", '"'):
1343
+ quote = char
1344
+ i += 1
1345
+ continue
1346
+
1347
+ if text.startswith(opening, i):
1348
+ depth += 1
1349
+ i += len(opening)
1350
+ continue
1351
+
1352
+ if text.startswith(closing, i):
1353
+ depth -= 1
1354
+ if depth == 0:
1355
+ return i
1356
+ i += len(closing)
1357
+ continue
1358
+
1359
+ i += 1
1360
+
1361
+ raise SyntaxError(f"Missing `{closing}`")
1362
+
1363
+
1364
+ def interpolate_query(text):
1365
+ """Evaluate [Python] expressions inside a query."""
1366
+ result = []
1367
+ i = 0
1368
+
1369
+ while i < len(text):
1370
+ if text[i] == "[":
1371
+ end = find_matching(text, i, "[", "]")
1372
+ code = text[i + 1:end]
1373
+ value = evaluate_python(code)
1374
+
1375
+ if isinstance(value, str):
1376
+ result.append(shlex.quote(value))
1377
+ elif value is None:
1378
+ result.append("None")
1379
+ else:
1380
+ result.append(shlex.quote(str(value)))
1381
+
1382
+ i = end + 1
1383
+ continue
1384
+
1385
+ result.append(text[i])
1386
+ i += 1
1387
+
1388
+ return "".join(result)
1389
+
1390
+ def interpolate_python_queries(code):
1391
+ rewritten = []
1392
+ query_values = {}
1393
+ query_index = 0
1394
+ i = 0
1395
+
1396
+ while i < len(code):
1397
+ if code.startswith("{{", i):
1398
+ end = find_matching(code, i, "{{", "}}")
1399
+ query = code[i + 2:end].strip()
1400
+
1401
+ value = execute_query_block(query)
1402
+
1403
+ name = f"__chemql_query_{query_index}"
1404
+ query_index += 1
1405
+
1406
+ query_values[name] = value
1407
+ rewritten.append(name)
1408
+
1409
+ i = end + 2
1410
+ continue
1411
+
1412
+ rewritten.append(code[i])
1413
+ i += 1
1414
+
1415
+ python_globals.update(query_values)
1416
+ return "".join(rewritten)
1417
+
1418
+ def execute_query_text(text):
1419
+ """Execute plain query-language text."""
1420
+ text = interpolate_query(text.strip())
1421
+ positions = token_positions(text)
1422
+
1423
+ try:
1424
+ arguments = shlex.split(text)
1425
+ except ValueError as e:
1426
+ raise ChemQLError(f"Query syntax error: {e}")
1427
+
1428
+ try:
1429
+ return execute(arguments, text, positions)
1430
+ except ChemQLError as e:
1431
+ if e.position is None:
1432
+ raise
1433
+ raise
1434
+
1435
+
1436
+ def execute_query(query: str) -> str | Molecule | Element | Reaction | list | None | Unknown:
1437
+ """Execute a Chemql query through the public programmatic API."""
1438
+ result = execute_query_text(query)
1439
+
1440
+ if isinstance(result, (str, Molecule, Element, Reaction, Unknown)):
1441
+ return result
1442
+
1443
+ # Keep the public API total for result types that have not yet been
1444
+ # given a dedicated return type (for example lists, ReturnTable, and
1445
+ # integer counts).
1446
+ return Unknown(result)
1447
+
1448
+
1449
+ def execute_query_block(text):
1450
+ # Hybrid code needs the raw query result, so it intentionally bypasses
1451
+ # the public Unknown wrapper.
1452
+ return execute_query_text(text)
1453
+
1454
+
1455
+ def evaluate_python(code):
1456
+ """Evaluate Python, with {{ query }} expressions available inside it.
1457
+
1458
+ If the Python is an expression, its value is returned.
1459
+ Otherwise it is executed and None is returned.
1460
+ """
1461
+ query_values = {}
1462
+ rewritten = []
1463
+ i = 0
1464
+ query_index = 0
1465
+
1466
+ while i < len(code):
1467
+ if code.startswith("{{", i):
1468
+ end = find_matching(code, i, "{{", "}}")
1469
+ query = code[i + 2:end].strip()
1470
+ value = execute_query_block(query)
1471
+
1472
+ name = f"__chemql_query_{query_index}"
1473
+ query_index += 1
1474
+ query_values[name] = value
1475
+ rewritten.append(name)
1476
+ i = end + 2
1477
+ continue
1478
+
1479
+ rewritten.append(code[i])
1480
+ i += 1
1481
+
1482
+ rewritten = "".join(rewritten)
1483
+
1484
+ python_globals.update(query_values)
1485
+
1486
+ try:
1487
+ tree = ast.parse(rewritten, mode="eval")
1488
+ except SyntaxError:
1489
+ exec(rewritten, python_globals)
1490
+ return None
1491
+
1492
+ return eval(
1493
+ compile(tree, "<embedded-python>", "eval"),
1494
+ python_globals
1495
+ )
1496
+
1497
+
1498
+ def is_python_block_header(text):
1499
+ """Recognize [for ...], [if ...], etc. without requiring a colon."""
1500
+ text = text.strip()
1501
+
1502
+ if not (text.startswith("[") and text.endswith("]")):
1503
+ return False
1504
+
1505
+ code = text[1:-1].strip()
1506
+
1507
+ # Replace {{...}} queries with valid Python variable names
1508
+ while "{{" in code:
1509
+ start = code.index("{{")
1510
+ end = find_matching(code, start, "{{", "}}")
1511
+ code = code[:start] + "__chemql_query_placeholder" + code[end + 2:]
1512
+
1513
+ try:
1514
+ tree = ast.parse(code + ":\n pass", mode="exec")
1515
+ except SyntaxError:
1516
+ return False
1517
+
1518
+ return bool(tree.body) and isinstance(
1519
+ tree.body[0],
1520
+ (
1521
+ ast.For,
1522
+ ast.While,
1523
+ ast.If,
1524
+ ast.With,
1525
+ ast.Try,
1526
+ ast.FunctionDef,
1527
+ ast.AsyncFunctionDef,
1528
+ ast.ClassDef,
1529
+ )
1530
+ )
1531
+
1532
+
1533
+ def find_endblock(lines, start):
1534
+ """Find the matching [endblock], allowing nested blocks."""
1535
+ depth = 1
1536
+
1537
+ for i in range(start, len(lines)):
1538
+ stripped = lines[i].strip()
1539
+
1540
+ if is_python_block_header(stripped):
1541
+ depth += 1
1542
+
1543
+ elif stripped == "[endblock]":
1544
+ depth -= 1
1545
+
1546
+ if depth == 0:
1547
+ return i
1548
+
1549
+ raise SyntaxError("Missing `[endblock]`")
1550
+
1551
+ def remove_comments(line):
1552
+ in_quote = None
1553
+ escaped = False
1554
+
1555
+ for i in range(len(line) - 1):
1556
+ char = line[i]
1557
+
1558
+ if escaped:
1559
+ escaped = False
1560
+ continue
1561
+
1562
+ if char == "\\" and in_quote:
1563
+ escaped = True
1564
+ continue
1565
+
1566
+ if char in ('"', "'"):
1567
+ if in_quote is None:
1568
+ in_quote = char
1569
+ elif in_quote == char:
1570
+ in_quote = None
1571
+ continue
1572
+
1573
+ if line[i:i + 2] == "//" and in_quote is None:
1574
+ return line[:i].rstrip()
1575
+
1576
+ return line
1577
+
1578
+
1579
+ class ChemQLBreak(Exception):
1580
+ pass
1581
+ class ChemQLContinue(Exception):
1582
+ pass
1583
+
1584
+
1585
+ def execute_for_block(header, body):
1586
+ code = header[1:-1].strip()
1587
+ code = interpolate_python_queries(code)
1588
+
1589
+ tree = ast.parse(code + ":\n pass", mode="exec")
1590
+
1591
+ node = tree.body[0]
1592
+
1593
+ if not isinstance(node, ast.For):
1594
+ raise SyntaxError("Expected a `for` block")
1595
+
1596
+ iterable = eval(
1597
+ compile(ast.Expression(node.iter), "<embedded-python>", "eval"),
1598
+ python_globals
1599
+ )
1600
+
1601
+ if not hasattr(iterable, "__iter__"):
1602
+ iterable = (iterable,)
1603
+
1604
+ for value in iterable:
1605
+ temp_name = "__chemql_for_value"
1606
+ python_globals[temp_name] = value
1607
+
1608
+ assignment = ast.Assign(
1609
+ targets=[node.target],
1610
+ value=ast.Name(
1611
+ id=temp_name,
1612
+ ctx=ast.Load()
1613
+ )
1614
+ )
1615
+
1616
+ module = ast.Module(
1617
+ body=[assignment],
1618
+ type_ignores=[]
1619
+ )
1620
+
1621
+ ast.fix_missing_locations(module)
1622
+
1623
+ exec(
1624
+ compile(
1625
+ module,
1626
+ "<embedded-python>",
1627
+ "exec"
1628
+ ),
1629
+ python_globals
1630
+ )
1631
+
1632
+ try:
1633
+ process_lines(body)
1634
+
1635
+ except ChemQLContinue:
1636
+ continue
1637
+
1638
+ except ChemQLBreak:
1639
+ break
1640
+
1641
+
1642
+ def execute_while_block(header, body):
1643
+ code = header[1:-1].strip()
1644
+
1645
+ tree = ast.parse(
1646
+ code + ":\n pass",
1647
+ mode="exec"
1648
+ )
1649
+
1650
+ node = tree.body[0]
1651
+
1652
+ if not isinstance(node, ast.While):
1653
+ raise SyntaxError("Expected a `while` block")
1654
+
1655
+ while eval(
1656
+ compile(
1657
+ ast.Expression(node.test),
1658
+ "<embedded-python>",
1659
+ "eval"
1660
+ ),
1661
+ python_globals
1662
+ ):
1663
+ try:
1664
+ process_lines(body)
1665
+
1666
+ except ChemQLContinue:
1667
+ continue
1668
+
1669
+ except ChemQLBreak:
1670
+ break
1671
+
1672
+
1673
+ def execute_if_block(header, body):
1674
+ """
1675
+ Execute an if/elif/else chain.
1676
+
1677
+ The body contains the entire block, including any
1678
+ [elif ...] and [else] clauses.
1679
+ """
1680
+
1681
+ sections = []
1682
+
1683
+ current_header = header
1684
+ current_body = []
1685
+
1686
+ depth = 0
1687
+
1688
+ for line in body:
1689
+ stripped = line.strip()
1690
+
1691
+ if is_python_block_header(stripped):
1692
+ depth += 1
1693
+
1694
+ elif stripped == "[endblock]":
1695
+ depth -= 1
1696
+
1697
+ # Only recognize elif/else belonging to THIS if.
1698
+ if depth == 0 and (
1699
+ stripped.startswith("[elif ")
1700
+ or stripped == "[else]"
1701
+ ):
1702
+ sections.append(
1703
+ (current_header, current_body)
1704
+ )
1705
+
1706
+ current_header = stripped
1707
+ current_body = []
1708
+
1709
+ else:
1710
+ current_body.append(line)
1711
+
1712
+ sections.append(
1713
+ (current_header, current_body)
1714
+ )
1715
+
1716
+ for section_header, section_body in sections:
1717
+ code = section_header[1:-1].strip()
1718
+
1719
+ if code.startswith("if "):
1720
+ condition = code[3:].strip()
1721
+
1722
+ elif code.startswith("elif "):
1723
+ condition = code[5:].strip()
1724
+
1725
+ elif code == "else":
1726
+ process_lines(section_body)
1727
+ return
1728
+
1729
+ else:
1730
+ raise SyntaxError(
1731
+ f"Invalid conditional `{code}`"
1732
+ )
1733
+
1734
+ if evaluate_python(condition):
1735
+ process_lines(section_body)
1736
+ return
1737
+
1738
+ def split_commands(text):
1739
+ """Split ; commands, but never split inside [], {{}} or strings."""
1740
+ commands = []
1741
+ start = 0
1742
+ i = 0
1743
+ square = 0
1744
+ curly = 0
1745
+ quote = None
1746
+ escaped = False
1747
+
1748
+ while i < len(text):
1749
+ char = text[i]
1750
+
1751
+ if quote is not None:
1752
+ if escaped:
1753
+ escaped = False
1754
+ elif char == "\\":
1755
+ escaped = True
1756
+ elif char == quote:
1757
+ quote = None
1758
+ i += 1
1759
+ continue
1760
+
1761
+ if char in ("'", '"'):
1762
+ quote = char
1763
+ elif text.startswith("{{", i):
1764
+ curly += 1
1765
+ i += 2
1766
+ continue
1767
+ elif text.startswith("}}", i) and curly:
1768
+ curly -= 1
1769
+ i += 2
1770
+ continue
1771
+ elif char == "[":
1772
+ square += 1
1773
+ elif char == "]" and square:
1774
+ square -= 1
1775
+ elif char == ";" and square == 0 and curly == 0:
1776
+ commands.append(text[start:i])
1777
+ start = i + 1
1778
+
1779
+ i += 1
1780
+
1781
+ commands.append(text[start:])
1782
+ return commands
1783
+
1784
+
1785
+ def process_inline(text):
1786
+ """Process one complete line of the chemql language."""
1787
+ stripped = text.strip()
1788
+
1789
+ if not stripped:
1790
+ return
1791
+
1792
+ # [ ... ] is Python.
1793
+ if stripped.startswith("["):
1794
+ end = find_matching(stripped, 0, "[", "]")
1795
+ if not stripped[end + 1:].strip():
1796
+ return evaluate_python(stripped[1:end])
1797
+
1798
+ # {{ ... }} is a query expression.
1799
+ if stripped.startswith("{{"):
1800
+ end = find_matching(stripped, 0, "{{", "}}")
1801
+ if not stripped[end + 2:].strip():
1802
+ return execute_query_block(stripped[2:end].strip())
1803
+
1804
+ # Everything else is query language.
1805
+ return execute_query_text(stripped)
1806
+
1807
+
1808
+ def join_continuations(lines):
1809
+ """Join lines ending in \\ with the following line."""
1810
+ joined = []
1811
+ buffer = None
1812
+
1813
+ for line in lines:
1814
+ if buffer is None:
1815
+ buffer = line
1816
+ else:
1817
+ buffer += line
1818
+
1819
+ if buffer.endswith("\\"):
1820
+ buffer = buffer[:-1] + " "
1821
+ continue
1822
+
1823
+ joined.append(buffer)
1824
+ buffer = None
1825
+
1826
+ if buffer is not None:
1827
+ joined.append(buffer)
1828
+
1829
+ return joined
1830
+
1831
+
1832
+ def process_lines(lines):
1833
+ lines = join_continuations(lines)
1834
+ lines = [remove_comments(line) for line in lines]
1835
+ lines = [line for line in lines if line.strip()]
1836
+ i = 0
1837
+
1838
+ while i < len(lines):
1839
+
1840
+ line = lines[i]
1841
+ stripped = line.strip()
1842
+
1843
+ if not line.strip():
1844
+ continue
1845
+
1846
+ if stripped == "[endblock]":
1847
+ raise SyntaxError("Unexpected `[endblock]`")
1848
+
1849
+ # ----------------------------------------
1850
+ # Control-flow block
1851
+ # ----------------------------------------
1852
+
1853
+ if is_python_block_header(stripped):
1854
+ end = find_endblock(lines, i + 1)
1855
+ body = lines[i + 1:end]
1856
+
1857
+ code = stripped[1:-1].strip()
1858
+
1859
+ if code.startswith("for "):
1860
+ execute_for_block(
1861
+ stripped,
1862
+ body
1863
+ )
1864
+
1865
+ elif code.startswith("while "):
1866
+ execute_while_block(
1867
+ stripped,
1868
+ body
1869
+ )
1870
+
1871
+ elif code.startswith("if "):
1872
+ execute_if_block(
1873
+ stripped,
1874
+ body
1875
+ )
1876
+
1877
+ else:
1878
+ raise SyntaxError(
1879
+ f"Unsupported block `{code}`"
1880
+ )
1881
+
1882
+ i = end + 1
1883
+ continue
1884
+
1885
+ # ----------------------------------------
1886
+ # break / continue
1887
+ # ----------------------------------------
1888
+
1889
+ if stripped == "[break]":
1890
+ raise ChemQLBreak()
1891
+
1892
+ if stripped == "[continue]":
1893
+ raise ChemQLContinue()
1894
+
1895
+ if stripped == "view":
1896
+ view_periodic_table()
1897
+ i += 1
1898
+ continue
1899
+
1900
+
1901
+ # ----------------------------------------
1902
+ # Normal chemql-language line
1903
+ # ----------------------------------------
1904
+
1905
+ for command in split_commands(line):
1906
+ result = process_inline(command)
1907
+
1908
+ if result is not None:
1909
+ print(result)
1910
+
1911
+ i += 1
1912
+
1913
+
1914
+ def normalize_catalysts(values):
1915
+ catalysts = []
1916
+ seen = set()
1917
+
1918
+ for value in values:
1919
+ catalyst = value.strip()
1920
+ normalized = catalyst.casefold()
1921
+ if catalyst and normalized not in seen:
1922
+ catalysts.append(catalyst)
1923
+ seen.add(normalized)
1924
+
1925
+ return catalysts
1926
+
1927
+
1928
+ def parse_temperature_setting(value):
1929
+ normalized = value.strip().casefold()
1930
+ if normalized == "standard":
1931
+ return 273.15
1932
+ if normalized == "room":
1933
+ return 298.15
1934
+
1935
+ match = re.fullmatch(r"([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*°?\s*([cfk])", normalized)
1936
+ if not match:
1937
+ raise ValueError("Temperature must use C, F, or K, or be `standard` or `room`")
1938
+
1939
+ temperature = float(match.group(1))
1940
+ unit = match.group(2)
1941
+ if unit == "f":
1942
+ temperature = (temperature - 32) * 5 / 9 + 273.15
1943
+ elif unit == "c":
1944
+ temperature += 273.15
1945
+
1946
+ if temperature < 0:
1947
+ raise ValueError("Temperature cannot be below absolute zero")
1948
+
1949
+ return temperature
1950
+
1951
+
1952
+ def parse_pressure_setting(value):
1953
+ normalized = value.strip().casefold()
1954
+ if normalized == "standard":
1955
+ return 100000.0
1956
+ if normalized == "room":
1957
+ return 101325.0
1958
+
1959
+ match = re.fullmatch(r"([+]?(?:\d+(?:\.\d*)?|\.\d+))\s*(.*?)", normalized)
1960
+ if not match:
1961
+ raise ValueError("Pressure must be a non-negative value with Pa, N/m^2, bar, or atm units")
1962
+
1963
+ pressure = float(match.group(1))
1964
+ unit = re.sub(r"\s+", "", match.group(2)).replace("²", "^2")
1965
+ unit_scales = {
1966
+ "pa": 1.0,
1967
+ "n/m^2": 1.0,
1968
+ "nm^-2": 1.0,
1969
+ "bar": 100000.0,
1970
+ "atm": 101325.0,
1971
+ }
1972
+ if unit not in unit_scales:
1973
+ raise ValueError("Pressure must use Pa, N/m^2, Nm^-2, bar, or atm")
1974
+
1975
+ return pressure * unit_scales[unit]
1976
+
1977
+
1978
+ def _reaction_formula(value):
1979
+ normalized = value.casefold()
1980
+ for molecule in MOLECULES:
1981
+ if molecule.formula.casefold() == normalized:
1982
+ return molecule.formula
1983
+ if molecule.name.casefold() == normalized or molecule.iupac_name.casefold() == normalized:
1984
+ return molecule.formula
1985
+ return value
1986
+
1987
+
1988
+ def parse_reactants(values):
1989
+ counts = {}
1990
+ index = 0
1991
+
1992
+ while index < len(values):
1993
+ value = values[index]
1994
+ coefficient = 1
1995
+
1996
+ if value.isdigit():
1997
+ coefficient = int(value)
1998
+ index += 1
1999
+ if index >= len(values):
2000
+ raise ValueError("Expected a molecule after its coefficient")
2001
+ value = values[index]
2002
+ else:
2003
+ match = re.match(r"^(\d+)(?=[A-Za-z])", value)
2004
+ if match:
2005
+ coefficient = int(match.group(1))
2006
+ value = value[match.end():]
2007
+
2008
+ if coefficient <= 0 or not value:
2009
+ raise ValueError("Reactant coefficients must be positive integers")
2010
+
2011
+ formula = _reaction_formula(value)
2012
+ key = formula.casefold()
2013
+ counts[key] = counts.get(key, 0) + coefficient
2014
+ index += 1
2015
+
2016
+ if not counts:
2017
+ raise ValueError("Usage: react <coefficient><molecule> ...")
2018
+
2019
+ return counts
2020
+
2021
+
2022
+ def _reaction_side_counts(participants):
2023
+ counts = {}
2024
+ for participant in participants:
2025
+ formula = participant["molecule"]
2026
+ key = formula.casefold()
2027
+ counts[key] = counts.get(key, 0) + participant["stoichiometric_coefficient"]
2028
+ return counts
2029
+
2030
+
2031
+ def _reaction_conditions_match(reaction):
2032
+ conditions = reaction.conditions
2033
+ temperature = conditions.get("temperature_k")
2034
+ if temperature is not None:
2035
+ current_temperature = reaction_state["temperature_k"]
2036
+ if current_temperature is None or abs(current_temperature - temperature) > REACTION_TEMPERATURE_TOLERANCE_K:
2037
+ return False
2038
+
2039
+ pressure = conditions.get("pressure_pa")
2040
+ if pressure is not None:
2041
+ current_pressure = reaction_state["pressure_pa"]
2042
+ if current_pressure is None:
2043
+ return False
2044
+ tolerance = abs(pressure) * REACTION_PRESSURE_TOLERANCE
2045
+ if abs(current_pressure - pressure) > tolerance:
2046
+ return False
2047
+
2048
+ required_catalysts = {
2049
+ item.casefold() for item in conditions.get("catalysts", [])
2050
+ }
2051
+ available_catalysts = {
2052
+ item.casefold() for item in reaction_state["catalysts"]
2053
+ }
2054
+ return required_catalysts.issubset(available_catalysts)
2055
+
2056
+
2057
+ def _resolve_reaction_product(participant):
2058
+ formula = participant["molecule"]
2059
+ for molecule in MOLECULES:
2060
+ if molecule.formula.casefold() == formula.casefold():
2061
+ return molecule
2062
+
2063
+ name = participant.get("name", formula)
2064
+ result = fetch_pubchem_molecule(name)
2065
+ if result is None:
2066
+ result = fetch_pubchem_molecule(formula)
2067
+ return result
2068
+
2069
+
2070
+ def react(values):
2071
+ try:
2072
+ reactants = parse_reactants(values)
2073
+ except ValueError as exc:
2074
+ return str(exc)
2075
+
2076
+ matches = []
2077
+ for reaction in REACTIONS:
2078
+ if not _reaction_conditions_match(reaction):
2079
+ continue
2080
+
2081
+ forward = _reaction_side_counts(reaction.reactants)
2082
+ if reactants == forward:
2083
+ matches.append((reaction, reaction.products))
2084
+ continue
2085
+
2086
+ if reaction.reversible is True and reactants == _reaction_side_counts(reaction.products):
2087
+ matches.append((reaction, reaction.reactants))
2088
+
2089
+ if not matches:
2090
+ return "No reaction matches those reactants and the current temperature, pressure, and catalysts"
2091
+
2092
+ if len(matches) > 1:
2093
+ names = ", ".join(f"{reaction.name} ({reaction.id})" for reaction, _ in matches[:5])
2094
+ return f"Multiple reactions match: {names}"
2095
+
2096
+ reaction, products = matches[0]
2097
+ resolved = []
2098
+ for product in products:
2099
+ molecule = _resolve_reaction_product(product)
2100
+ if molecule is None:
2101
+ label = product.get("name", product["molecule"])
2102
+ return f"Could not resolve product molecule `{label}` for reaction `{reaction.id}`"
2103
+ resolved.append((molecule, product["stoichiometric_coefficient"], product.get("phase")))
2104
+
2105
+ if len(resolved) == 1:
2106
+ return resolved[0][0]
2107
+
2108
+ return ReturnTable({
2109
+ "molecule": [item[0] for item in resolved],
2110
+ "stoichiometric_coefficient": [item[1] for item in resolved],
2111
+ "phase": [item[2] or "" for item in resolved],
2112
+ })
2113
+
2114
+
2115
+ def current_conditions():
2116
+ temperature = reaction_state["temperature_k"]
2117
+ pressure = reaction_state["pressure_pa"]
2118
+ catalysts = reaction_state["catalysts"]
2119
+
2120
+ temperature_text = f"{temperature:g} K" if temperature is not None else "Not Set"
2121
+ pressure_text = f"{pressure:g} Pa" if pressure is not None else "Not Set"
2122
+ catalysts_text = ", ".join(catalysts) if catalysts else "Not Set"
2123
+
2124
+ return (
2125
+ f"Temperature: {temperature_text}\n"
2126
+ f"Pressure: {pressure_text}\n"
2127
+ f"Catalysts: {catalysts_text}"
2128
+ )
2129
+
2130
+
2131
+ def execute(arguments, query_text=None, positions=None):
2132
+ global current_source
2133
+
2134
+ if not arguments:
2135
+ return
2136
+
2137
+ if arguments[0].lower() in {"help", "?"}:
2138
+ return HELP
2139
+
2140
+ if arguments[0].lower() == "sorry":
2141
+ return "\033[34mIt's ok, everybody makes mistakes👌\033[0m"
2142
+
2143
+ if arguments[0] == "conditions":
2144
+ if len(arguments) != 1:
2145
+ return "Usage: conditions"
2146
+ return current_conditions()
2147
+
2148
+ if arguments[0] == "set":
2149
+ if len(arguments) < 2:
2150
+ return "Usage: set temperature|pressure|catalysts <value>"
2151
+
2152
+ setting = arguments[1].lower()
2153
+ values = arguments[2:]
2154
+
2155
+ if setting == "temperature":
2156
+ if len(values) != 1:
2157
+ return "Usage: set temperature <valueC|valueF|valueK|standard|room>"
2158
+
2159
+ try:
2160
+ reaction_state["temperature_k"] = parse_temperature_setting(values[0])
2161
+ except ValueError as exc:
2162
+ return str(exc)
2163
+
2164
+ return f"Temperature set to {reaction_state['temperature_k']:.2f} K"
2165
+
2166
+ if setting == "pressure":
2167
+ if len(values) != 1:
2168
+ return "Usage: set pressure <valuePa|valueN/m^2|valuebar|valueatm|standard|room>"
2169
+
2170
+ try:
2171
+ reaction_state["pressure_pa"] = parse_pressure_setting(values[0])
2172
+ except ValueError as exc:
2173
+ return str(exc)
2174
+
2175
+ return f"Pressure set to {reaction_state['pressure_pa']:g} Pa"
2176
+
2177
+ if setting == "catalysts":
2178
+ reaction_state["catalysts"] = normalize_catalysts(values)
2179
+ if not reaction_state["catalysts"]:
2180
+ return "Catalysts cleared"
2181
+ return f"Catalysts set to {', '.join(reaction_state['catalysts'])}"
2182
+
2183
+ return f"Unknown reaction setting `{setting}`"
2184
+
2185
+ if arguments[0] == "add" and len(arguments) >= 2 and arguments[1].lower() == "catalyst":
2186
+ if len(arguments) < 3:
2187
+ return 'Usage: add catalyst "name"'
2188
+
2189
+ catalyst = " ".join(arguments[2:]).strip()
2190
+ current = reaction_state["catalysts"]
2191
+ if catalyst and catalyst.casefold() not in {item.casefold() for item in current}:
2192
+ current.append(catalyst)
2193
+ return f"Catalysts: {', '.join(current)}" if current else "Catalysts cleared"
2194
+
2195
+ if arguments[0] == "remove" and len(arguments) >= 2 and arguments[1].lower() in {"catalyst", "calatyst"}:
2196
+ if len(arguments) < 3:
2197
+ return 'Usage: remove catalyst "name"'
2198
+
2199
+ catalyst = " ".join(arguments[2:]).strip()
2200
+ reaction_state["catalysts"] = [
2201
+ item for item in reaction_state["catalysts"]
2202
+ if item.casefold() != catalyst.casefold()
2203
+ ]
2204
+ return f"Catalysts: {', '.join(reaction_state['catalysts'])}" if reaction_state["catalysts"] else "Catalysts cleared"
2205
+
2206
+ if arguments[0] == "react":
2207
+ return react(arguments[1:])
2208
+
2209
+ # Convenience aliases for very common lookups.
2210
+ # These are syntactic sugar for normal search queries.
2211
+ if arguments[0] == "findel":
2212
+ if len(arguments) != 2:
2213
+ return "Usage: findel <name-or-symbol>"
2214
+
2215
+ value = arguments[1]
2216
+ return execute(
2217
+ ["search", "elements", "name", "like", value, "or", "symbol", "like", value]
2218
+ )
2219
+
2220
+ if arguments[0] == "findmol":
2221
+ if len(arguments) < 2:
2222
+ return "Usage: findmol <name-or-formula> [return <attribute> ...]"
2223
+
2224
+ value = arguments[1]
2225
+
2226
+ # Optional return clause
2227
+ return_attributes = None
2228
+
2229
+ if "return" in arguments[2:]:
2230
+ return_index = arguments.index("return", 2)
2231
+ return_attributes = arguments[return_index + 1:]
2232
+
2233
+ if not return_attributes:
2234
+ return "Usage: findmol <name-or-formula> return <attribute> ..."
2235
+
2236
+ for attribute in return_attributes:
2237
+ if attribute not in moleculeKeys:
2238
+ return f"There is no key `{attribute}`"
2239
+
2240
+ if return_index != 2:
2241
+ return "Usage: findmol <name-or-formula> [return <attribute> ...]"
2242
+
2243
+ elif len(arguments) > 2:
2244
+ return "Usage: findmol <name-or-formula> [return <attribute> ...]"
2245
+
2246
+ # Search the local dataset first.
2247
+ result = execute(
2248
+ ["search", "molecules", "name", "like", value, "or", "formula", "like", value]
2249
+ )
2250
+
2251
+ if result == [] or result == "No matching item":
2252
+ result = fetch_pubchem_molecule(value)
2253
+
2254
+ if result is None:
2255
+ return f"Could not find molecule `{value}`"
2256
+
2257
+ if isinstance(result, str) and "PubChem is currently unavailable" in result:
2258
+ return result
2259
+
2260
+ if isinstance(result, str) and "could not be found in the recommended sources" in result.lower():
2261
+ return result
2262
+
2263
+ # Apply optional projection.
2264
+ if return_attributes:
2265
+ if isinstance(result, list):
2266
+ return project_results(result, return_attributes)
2267
+
2268
+ return ReturnTable({
2269
+ attribute: [getattr(result, attribute)]
2270
+ for attribute in return_attributes
2271
+ })
2272
+
2273
+ return result
2274
+
2275
+ if arguments[0] == "findre":
2276
+ if len(arguments) < 2:
2277
+ return "Usage: findre <name-or-id> [return <attribute> ...]"
2278
+
2279
+ value = arguments[1]
2280
+ return_attributes = None
2281
+
2282
+ if "return" in arguments[2:]:
2283
+ return_index = arguments.index("return", 2)
2284
+ return_attributes = arguments[return_index + 1:]
2285
+
2286
+ if not return_attributes:
2287
+ return "Usage: findre <name-or-id> return <attribute> ..."
2288
+
2289
+ for attribute in return_attributes:
2290
+ if attribute not in reactionKeys:
2291
+ return f"There is no key `{attribute}`"
2292
+
2293
+ if return_index != 2:
2294
+ return "Usage: findre <name-or-id> [return <attribute> ...]"
2295
+
2296
+ elif len(arguments) > 2:
2297
+ return "Usage: findre <name-or-id> [return <attribute> ...]"
2298
+
2299
+ result = execute([
2300
+ "search", "reactions",
2301
+ "name", "like", value,
2302
+ "or", "id", "like", value,
2303
+ "or", "equation", "like", value,
2304
+ ])
2305
+
2306
+ if result == [] or result == "No matching item":
2307
+ return f"Could not find reaction `{value}`"
2308
+
2309
+ if return_attributes:
2310
+ if isinstance(result, list):
2311
+ return project_results(result, return_attributes)
2312
+
2313
+ return ReturnTable({
2314
+ attribute: [getattr(result, attribute)]
2315
+ for attribute in return_attributes
2316
+ })
2317
+
2318
+ return result
2319
+
2320
+ if arguments[0] == "clear":
2321
+ if len(arguments) != 1:
2322
+ return "Usage: clear"
2323
+
2324
+ print("\033[2J\033[H", end="")
2325
+ return
2326
+
2327
+ if arguments[0] == "source":
2328
+ if len(arguments) != 2:
2329
+ return "Usage: source <source>"
2330
+
2331
+ source_name = arguments[1]
2332
+
2333
+ if source_name not in SOURCES:
2334
+ return f"There is no source `{source_name}`"
2335
+
2336
+ current_source = source_name
2337
+ return f"Source set to `{source_name}`"
2338
+
2339
+ if arguments[0] == "list":
2340
+ if len(arguments) != 2 or arguments[1] != "all":
2341
+ return "Usage: list all"
2342
+
2343
+ if current_source is None:
2344
+ return list(SOURCES)
2345
+
2346
+ _, keys = SOURCES[current_source]
2347
+ return list(keys)
2348
+
2349
+ if arguments[0] == "search":
2350
+ i = 1
2351
+ mode = "all"
2352
+
2353
+ if i < len(arguments) and arguments[i] in ("first", "last", "all"):
2354
+ mode = arguments[i]
2355
+ i += 1
2356
+
2357
+ if (
2358
+ i + 1 < len(arguments)
2359
+ and arguments[i] in SOURCES
2360
+ and (
2361
+ current_source is None
2362
+ or arguments[i + 1] not in operators
2363
+ )
2364
+ ):
2365
+ source_name = arguments[i]
2366
+ i += 1
2367
+ else:
2368
+ source_name = current_source
2369
+
2370
+ if source_name is None:
2371
+ return (
2372
+ "No source selected. "
2373
+ "Specify one, e.g. "
2374
+ "`search all elements atomic_mass < 100`"
2375
+ )
2376
+
2377
+ source, keys = SOURCES[source_name]
2378
+ return_attributes = None
2379
+
2380
+ if "return" in arguments[i:]:
2381
+ return_index = arguments.index("return", i)
2382
+ query_tokens = arguments[i:return_index]
2383
+ return_attributes = arguments[return_index + 1:]
2384
+
2385
+ if not return_attributes:
2386
+ return format_error("Usage: return <attribute> [<attribute> ...]", query_text)
2387
+
2388
+ for attribute_index, attribute in enumerate(return_attributes):
2389
+ if attribute not in keys:
2390
+ absolute_index = return_index + 1 + attribute_index
2391
+ pos, length = error_position(positions, absolute_index) if positions else (None, 1)
2392
+ return format_error(
2393
+ f"There is no key `{attribute}`",
2394
+ query_text,
2395
+ pos,
2396
+ length
2397
+ )
2398
+ else:
2399
+ query_tokens = arguments[i:]
2400
+
2401
+ try:
2402
+ condition, operation_index = parse_query(
2403
+ query_tokens,
2404
+ source,
2405
+ keys,
2406
+ positions[i:] if positions else None
2407
+ )
2408
+ except ChemQLError as e:
2409
+ return format_error(str(e), query_text, e.position, e.length)
2410
+ except ValueError as e:
2411
+ return format_error(str(e))
2412
+
2413
+ results = [item for item in source if condition(item)]
2414
+
2415
+ try:
2416
+ results = apply_query_operations(
2417
+ results,
2418
+ query_tokens[operation_index:],
2419
+ keys,
2420
+ positions[i + operation_index:] if positions else None
2421
+ )
2422
+ except ChemQLError as e:
2423
+ return format_error(str(e), query_text, e.position, e.length)
2424
+ except ValueError as e:
2425
+ return format_error(str(e))
2426
+
2427
+ if isinstance(results, int):
2428
+ if mode != "all":
2429
+ return format_error("`count` cannot be combined with `first` or `last`")
2430
+ if return_attributes is not None:
2431
+ return format_error("`return` cannot be used with `count`")
2432
+ return results
2433
+
2434
+ # `return` is a projection. It intentionally happens after
2435
+ # filtering, sorting, limiting, and first/last selection.
2436
+ if return_attributes is not None:
2437
+ if mode == "first":
2438
+ selected = results[:1]
2439
+ elif mode == "last":
2440
+ selected = results[-1:]
2441
+ else:
2442
+ selected = results
2443
+
2444
+ if not selected:
2445
+ if len(return_attributes) == 1:
2446
+ return []
2447
+ return ReturnTable({attribute: [] for attribute in return_attributes})
2448
+
2449
+ return project_results(selected, return_attributes)
2450
+
2451
+ if mode == "first":
2452
+ result = results[0] if results else None
2453
+ elif mode == "last":
2454
+ result = results[-1] if results else None
2455
+ else:
2456
+ if len(results) == 1:
2457
+ result = results[0]
2458
+ else:
2459
+ result = results
2460
+
2461
+ if result is None:
2462
+ return "No matching item"
2463
+
2464
+ return result
2465
+
2466
+ if query_text is not None and positions:
2467
+ pos, length = error_position(positions, 0)
2468
+ return format_error(
2469
+ f"Unknown command `{arguments[0]}`",
2470
+ query_text,
2471
+ pos,
2472
+ length
2473
+ )
2474
+
2475
+ return format_error(f"Unknown command `{arguments[0]}`")
2476
+ HELP = """\033[1;36mChemql - Python Chemistry Query Language\033[0m
2477
+
2478
+ \033[1;33mQUERYING\033[0m
2479
+ \033[1msearch\033[0m
2480
+ Search the current source.
2481
+
2482
+ search elements
2483
+ search molecules
2484
+ search reactions
2485
+ search all elements
2486
+ search all molecules
2487
+ search all reactions
2488
+
2489
+ Example:
2490
+ search elements name = "Hydrogen"
2491
+ search molecules molecular_weight < 20
2492
+ search reactions reaction_type = "combustion"
2493
+
2494
+ \033[1msource\033[0m
2495
+ Change the current source.
2496
+
2497
+ source elements
2498
+ source molecules
2499
+ source reactions
2500
+
2501
+ \033[1mfindel <value>\033[0m
2502
+ Find an element by name or symbol.
2503
+
2504
+ Example:
2505
+ findel "He"
2506
+ findel "Hydrogen"
2507
+
2508
+ \033[1mfindmol <value>\033[0m
2509
+ Find a molecule by name or formula.
2510
+
2511
+ Example:
2512
+ findmol "H2O"
2513
+ findmol "Water"
2514
+
2515
+ \033[1mfindre <name-or-id>\033[0m
2516
+ Find a reaction by name, identifier, or equation.
2517
+
2518
+ Example:
2519
+ findre "Haber process"
2520
+ findre "RHEA:10000" return name equation source
2521
+
2522
+ Reaction fields:
2523
+ id, name, equation, reaction_type, reversible,
2524
+ reactants, products, conditions, notes
2525
+
2526
+ Example:
2527
+ search reactions name like "*combustion*" return name equation
2528
+ search reactions reversible = true return name conditions
2529
+
2530
+ \033[1;33mFILTERING\033[0m
2531
+ Operators:
2532
+ = Equal
2533
+ > Greater than
2534
+ < Less than
2535
+ >= Greater than or equal
2536
+ <= Less than or equal
2537
+ like Pattern match (case-insensitive)
2538
+ like! Pattern match (case-sensitive)
2539
+ has Check whether a value contains an item
2540
+
2541
+ Logical operators:
2542
+ and
2543
+ or
2544
+
2545
+ Parentheses can be used to group conditions.
2546
+
2547
+ Example:
2548
+ search elements atomic_number > 10 and atomic_number < 20
2549
+ search molecules name like "*water*"
2550
+ search reactions reaction_type = "combustion"
2551
+
2552
+ \033[1;33mREACTIONS\033[0m
2553
+ Set reaction conditions before using `react`.
2554
+
2555
+ set temperature 723K
2556
+ set temperature standard
2557
+ set pressure 20265000pa
2558
+ set pressure room
2559
+ set catalysts iron zinc
2560
+ add catalyst "Vanadium oxide"
2561
+ remove catalyst "Vanadium oxide"
2562
+ set catalysts
2563
+ conditions
2564
+ react N2 3H2
2565
+
2566
+ Temperature accepts C, F, or K. Pressure accepts Pa, N/m^2, Nm^-2, or bar.
2567
+ `standard` means 0 C and 100000 Pa; `room` means 25 C and 101325 Pa.
2568
+ Unset values appear as `Not Set` in `conditions`.
2569
+ Reaction matching allows 5 K temperature and 5% pressure tolerance.
2570
+
2571
+ \033[1;33mRESULTS\033[0m
2572
+ \033[1mfirst\033[0m
2573
+ Return only the first result.
2574
+
2575
+ \033[1mlast\033[0m
2576
+ Return only the last result.
2577
+
2578
+ \033[1msort <key> [asc|desc]\033[0m
2579
+ Sort results by an attribute.
2580
+
2581
+ Example:
2582
+ search elements sort atomic_number
2583
+ search molecules sort molecular_weight desc
2584
+
2585
+ \033[1mlimit <number>\033[0m
2586
+ Limit the number of results.
2587
+
2588
+ Example:
2589
+ search molecules limit 10
2590
+
2591
+ \033[1mcount\033[0m
2592
+ Return the number of results.
2593
+
2594
+ Example:
2595
+ search molecules count
2596
+ search reactions reversible = true return name equation
2597
+
2598
+ \033[1mreturn <attributes...>\033[0m
2599
+ Return only the specified attributes.
2600
+
2601
+ One attribute returns a list.
2602
+ Multiple attributes return a table.
2603
+
2604
+ Example:
2605
+ search molecules molecular_weight < 20 return name
2606
+
2607
+ search molecules molecular_weight < 20 return name formula molecular_weight
2608
+
2609
+ \033[1;33mGENERAL COMMANDS\033[0m
2610
+ \033[1mlist all\033[0m
2611
+ List keys from the active source or all available sources.
2612
+
2613
+ \033[1mview\033[0m
2614
+ Open the periodic table viewer in the default browser.
2615
+
2616
+ \033[1mclear\033[0m
2617
+ Clear the current screen.
2618
+
2619
+ \033[1mdump\033[0m
2620
+ Export the recent command history to a file.
2621
+ Example: dump my_session.txt
2622
+
2623
+ \033[1mhelp\033[0m
2624
+ Show this help message.
2625
+
2626
+ \033[1mexit\033[0m
2627
+ Exit Chemql.
2628
+
2629
+ \033[1;33mHYBRID PYTHON\033[0m
2630
+ Chemql can be combined with Python using [ ... ].
2631
+
2632
+ [x = 10]
2633
+ [print(x)]
2634
+
2635
+ Python control blocks are also supported:
2636
+
2637
+ [for x in ...]
2638
+ [if ...]
2639
+ [elif ...]
2640
+ [else]
2641
+ [while ...]
2642
+ [break]
2643
+ [continue]
2644
+ [endblock]
2645
+
2646
+ ChemQL queries can be embedded directly using {{ ... }}.
2647
+ This evaluates the query and inserts its result into the surrounding Python.
2648
+
2649
+ {{ search elements name = "Hydrogen" }}
2650
+ [print({{ search elements atomic_number = 1 }})]
2651
+
2652
+ You can also use a bare query block as a value-producing expression:
2653
+
2654
+ {{ search molecules molecular_weight < 20 return name formula }}
2655
+
2656
+ \033[1;33mLINE CONTINUATION\033[0m
2657
+ A line ending with '\\' continues onto the next line.
2658
+
2659
+ search elements \\
2660
+ name = "Hydrogen"
2661
+
2662
+ This is treated as:
2663
+
2664
+ search elements name = "Hydrogen"
2665
+
2666
+ \033[1;33mOPTIONS\033[0m
2667
+ \033[1m-c <command>\033[0m
2668
+ Execute a command.
2669
+
2670
+ \033[1m-f <file>\033[0m
2671
+ Execute commands from a file.
2672
+
2673
+ \033[1m?\033[0m
2674
+ Show help.
2675
+
2676
+ \033[1;33mEXAMPLES\033[0m
2677
+ search elements name = "Hydrogen"
2678
+
2679
+ findel "He"
2680
+
2681
+ findmol "H2O"
2682
+
2683
+ search molecules molecular_weight < 20 \\
2684
+ return name formula molecular_weight
2685
+
2686
+ search elements atomic_number > 10 \\
2687
+ and atomic_number < 20 \\
2688
+ sort atomic_number desc \\
2689
+ limit 5
2690
+
2691
+ search molecules count
2692
+
2693
+ \033[1;36mChemql\033[0m
2694
+ Python Chemistry Query Language
2695
+ """
2696
+ import webbrowser
2697
+ from pathlib import Path
2698
+
2699
+ def view_periodic_table():
2700
+ path = Path(__file__).parent / "html" /"periodic_table.html"
2701
+ webbrowser.open(path.resolve().as_uri())
2702
+
2703
+ class ChemqlLexer(Lexer):
2704
+ def lex_document(self, document):
2705
+ lines = document.lines
2706
+
2707
+ def get_line(lineno):
2708
+ if lineno >= len(lines):
2709
+ return []
2710
+
2711
+ line = lines[lineno]
2712
+ tokens = []
2713
+
2714
+ i = 0
2715
+ words = line.split()
2716
+
2717
+ # Determine basic query context
2718
+ first_word = words[0] if words else ""
2719
+
2720
+ # search / source / list / return / clear / exit
2721
+ if first_word in {"search", "source", "list", "clear", "exit", "findel", "findmol", "findre", "set", "add", "remove", "react", "conditions", "view", "help", "dump"}:
2722
+ command_start = line.find(first_word)
2723
+ command_end = command_start + len(first_word)
2724
+
2725
+ # Preserve leading whitespace exactly.
2726
+ if command_start > 0:
2727
+ tokens.append((
2728
+ "",
2729
+ line[:command_start]
2730
+ ))
2731
+
2732
+ tokens.append((
2733
+ "class:command",
2734
+ first_word
2735
+ ))
2736
+
2737
+ i = command_end
2738
+
2739
+ # Everything else is scanned character-by-character
2740
+ while i < len(line):
2741
+ # Whitespace
2742
+ if line[i].isspace():
2743
+ start = i
2744
+
2745
+ while i < len(line) and line[i].isspace():
2746
+ i += 1
2747
+
2748
+ tokens.append(("", line[start:i]))
2749
+ continue
2750
+
2751
+ # Python/query blocks
2752
+ if line.startswith("{{", i):
2753
+ end = line.find("}}", i + 2)
2754
+
2755
+ if end == -1:
2756
+ end = len(line) - 2
2757
+
2758
+ tokens.append((
2759
+ "class:python",
2760
+ line[i:end + 2]
2761
+ ))
2762
+
2763
+ i = end + 2
2764
+ continue
2765
+
2766
+ if line[i] == "[":
2767
+ end = line.find("]", i + 1)
2768
+
2769
+ if end == -1:
2770
+ end = len(line) - 1
2771
+
2772
+ tokens.append((
2773
+ "class:python",
2774
+ line[i:end + 1]
2775
+ ))
2776
+
2777
+ i = end + 1
2778
+ continue
2779
+
2780
+ # Strings
2781
+ if line[i] in "\"'":
2782
+ quote = line[i]
2783
+ start = i
2784
+ i += 1
2785
+
2786
+ while i < len(line):
2787
+ if line[i] == "\\":
2788
+ i += 2
2789
+ continue
2790
+
2791
+ if line[i] == quote:
2792
+ i += 1
2793
+ break
2794
+
2795
+ i += 1
2796
+
2797
+ tokens.append((
2798
+ "class:string",
2799
+ line[start:i]
2800
+ ))
2801
+
2802
+ continue
2803
+
2804
+ # Operators
2805
+ matched_operator = None
2806
+
2807
+ for operator in (">=", "<=", "=", ">", "<"):
2808
+ if line.startswith(operator, i):
2809
+ matched_operator = operator
2810
+ break
2811
+
2812
+ if matched_operator:
2813
+ tokens.append((
2814
+ "class:operator",
2815
+ matched_operator
2816
+ ))
2817
+
2818
+ i += len(matched_operator)
2819
+ continue
2820
+
2821
+ # Numbers
2822
+ if line[i].isdigit():
2823
+ start = i
2824
+
2825
+ while i < len(line) and (
2826
+ line[i].isdigit() or line[i] in ".-"
2827
+ ):
2828
+ i += 1
2829
+
2830
+ tokens.append((
2831
+ "class:number",
2832
+ line[start:i]
2833
+ ))
2834
+
2835
+ continue
2836
+
2837
+ # Words
2838
+ if line[i].isalpha() or line[i] == "_":
2839
+ start = i
2840
+
2841
+ while i < len(line) and (
2842
+ line[i].isalnum() or line[i] == "_"
2843
+ ):
2844
+ i += 1
2845
+
2846
+ word = line[start:i]
2847
+
2848
+ # Commands
2849
+ if word in {
2850
+ "search",
2851
+ "source",
2852
+ "list",
2853
+ "clear",
2854
+ "exit",
2855
+ "return",
2856
+ "set",
2857
+ "add",
2858
+ "remove",
2859
+ "react",
2860
+ "conditions",
2861
+ "view",
2862
+ "help",
2863
+ "dump"
2864
+ }:
2865
+ style = "class:command"
2866
+
2867
+ # Sources
2868
+ elif word in SOURCES:
2869
+ style = "class:source"
2870
+
2871
+ elif word.casefold() in {"temperature", "pressure", "catalyst", "catalysts", "standard", "room", "c", "f", "k", "pa", "bar"}:
2872
+ style = "class:condition"
2873
+
2874
+ # Operators
2875
+ elif word in {
2876
+ "and",
2877
+ "or",
2878
+ "has",
2879
+ "like",
2880
+ "like!",
2881
+ "sort",
2882
+ "limit",
2883
+ "count",
2884
+ "asc",
2885
+ "desc"
2886
+ }:
2887
+ style = "class:operator"
2888
+
2889
+ # Control flow
2890
+ elif word in {
2891
+ "if",
2892
+ "elif",
2893
+ "else",
2894
+ "for",
2895
+ "while",
2896
+ "in",
2897
+ "break",
2898
+ "continue",
2899
+ "endblock"
2900
+ }:
2901
+ style = "class:control"
2902
+
2903
+ # Python constants
2904
+ elif word in {
2905
+ "True",
2906
+ "False",
2907
+ "None"
2908
+ }:
2909
+ style = "class:number"
2910
+
2911
+ # Dataset keys
2912
+ elif word in elementKeys or word in moleculeKeys or word in reactionKeys:
2913
+ style = "class:key"
2914
+
2915
+ else:
2916
+ style = ""
2917
+
2918
+ tokens.append((style, word))
2919
+ continue
2920
+
2921
+ # Anything else
2922
+ tokens.append(("", line[i]))
2923
+ i += 1
2924
+
2925
+ return tokens
2926
+
2927
+ return get_line
2928
+
2929
+ class ChemqlCompleter(Completer):
2930
+ def get_completions(self, document, complete_event):
2931
+ text = document.text_before_cursor
2932
+ parts = text.split()
2933
+ trailing_space = bool(text and text[-1].isspace())
2934
+ current = "" if trailing_space or not parts else parts[-1]
2935
+ command_words = parts if trailing_space else parts[:-1]
2936
+ commands = (
2937
+ "search", "source", "list", "clear", "return", "exit",
2938
+ "findel", "findmol", "findre", "set", "add", "remove",
2939
+ "react", "conditions",
2940
+ )
2941
+
2942
+ if not command_words:
2943
+ for command in commands:
2944
+ if command.startswith(current):
2945
+ yield Completion(command, start_position=-len(current))
2946
+ return
2947
+
2948
+ command = command_words[0]
2949
+ suggestions = []
2950
+
2951
+ if len(command_words) == 1:
2952
+ if command == "search":
2953
+ suggestions = list(SOURCES)
2954
+ elif command == "source":
2955
+ suggestions = list(SOURCES)
2956
+ elif command == "set":
2957
+ suggestions = ["temperature", "pressure", "catalysts"]
2958
+ elif command in {"add", "remove"}:
2959
+ suggestions = ["catalyst"]
2960
+
2961
+ elif command == "set" and len(command_words) == 2:
2962
+ setting = command_words[1].casefold()
2963
+ if setting == "temperature":
2964
+ suggestions = ["standard", "room", "0C", "0F", "0K"]
2965
+ elif setting == "pressure":
2966
+ suggestions = ["standard", "room", "0Pa", "0N/m^2", "0Nm^-2", "0bar"]
2967
+ elif setting == "catalysts":
2968
+ suggestions = list(reaction_state["catalysts"])
2969
+
2970
+ elif command == "search" and len(command_words) >= 2:
2971
+ source_name = command_words[1]
2972
+ if source_name in SOURCES:
2973
+ _, keys = SOURCES[source_name]
2974
+ suggestions = list(keys) + [
2975
+ "=", ">", "<", ">=", "<=", "like", "like!", "has",
2976
+ "sort", "limit", "count", "asc", "desc", "return",
2977
+ ]
2978
+
2979
+ elif command == "remove" and len(command_words) == 2 and command_words[1] == "catalyst":
2980
+ suggestions = list(reaction_state["catalysts"])
2981
+
2982
+ for suggestion in suggestions:
2983
+ if suggestion.casefold().startswith(current.casefold()):
2984
+ yield Completion(suggestion, start_position=-len(current))
2985
+
2986
+
2987
+
2988
+ chemql_style = Style.from_dict({
2989
+ "command": "ansicyan",
2990
+ "source": "ansiblue",
2991
+ "key": "ansimagenta",
2992
+ "operator": "ansiyellow",
2993
+ "string": "ansigreen",
2994
+ "number": "ansimagenta",
2995
+ "control": "ansired",
2996
+ "python": "ansibrightcyan",
2997
+ "condition": "ansibrightyellow",
2998
+ })
2999
+
3000
+
3001
+ def main():
3002
+ parser = argparse.ArgumentParser(
3003
+ description="Chemql query and chemistry language"
3004
+ )
3005
+
3006
+ parser.add_argument(
3007
+ "-c",
3008
+ "--command",
3009
+ help="Execute Chemql code"
3010
+ )
3011
+
3012
+ parser.add_argument(
3013
+ "-f",
3014
+ "--file",
3015
+ help="Execute Chemql code from a file"
3016
+ )
3017
+
3018
+ parser.add_argument(
3019
+ "-v",
3020
+ "--version",
3021
+ action="version",
3022
+ version="0.1.0",
3023
+ )
3024
+
3025
+ args = parser.parse_args()
3026
+
3027
+ if args.command:
3028
+ process_lines(args.command.splitlines())
3029
+
3030
+ elif args.file:
3031
+ with open(args.file) as f:
3032
+ code = f.read()
3033
+ process_lines(code.splitlines())
3034
+
3035
+ else:
3036
+ run_interactive()
3037
+
3038
+ def run_interactive():
3039
+ history = []
3040
+ print("\033[1mType help or ? for help\033[0m")
3041
+ session = PromptSession(
3042
+ lexer=ChemqlLexer(),
3043
+ completer=ChemqlCompleter(),
3044
+ style=chemql_style,
3045
+ reserve_space_for_menu=0,
3046
+ )
3047
+ while True:
3048
+ try:
3049
+ first_line = session.prompt(">>> ")
3050
+
3051
+ except KeyboardInterrupt:
3052
+ print()
3053
+ break
3054
+
3055
+ except EOFError:
3056
+ print("\n\033[34mUse \033[1mCTRL+C\033[22m or type '\033[1mexit\033[22m' to exit\033[0m")
3057
+ continue
3058
+
3059
+ if first_line.strip() == "exit":
3060
+ break
3061
+
3062
+ elif first_line.strip() in ("?", "help"):
3063
+ print(HELP)
3064
+ continue
3065
+
3066
+ elif first_line.strip().lower() == "sorry":
3067
+ print("\033[34mIt's ok, everybody makes mistakes👌\033[0m")
3068
+ continue
3069
+
3070
+ elif first_line.strip().split()[0] == "dump":
3071
+ arguments = first_line.strip().split()
3072
+
3073
+ if len(arguments) != 2:
3074
+ print("Usage: dump <file>")
3075
+ else:
3076
+ filename = arguments[1]
3077
+
3078
+ try:
3079
+ with open(filename, "x") as f:
3080
+ f.write("\n".join(history))
3081
+
3082
+ except FileExistsError:
3083
+ action = choice(
3084
+ message=f"`{filename}` already exists. What would you like to do?",
3085
+ options=[("append", "Append to existing file"), ("overwrite", "Overwrite existing file"), ("cancel", "Cancel")],
3086
+ )
3087
+
3088
+ if action == "append":
3089
+ with open(filename, "a") as f:
3090
+ f.write("\n" + "\n".join(history))
3091
+
3092
+ elif action == "overwrite":
3093
+ with open(filename, "w") as f:
3094
+ f.write("\n".join(history))
3095
+
3096
+ continue
3097
+
3098
+ if not first_line.strip():
3099
+ continue
3100
+
3101
+ # Collect continued input.
3102
+ lines = []
3103
+ current_line = first_line
3104
+
3105
+ while True:
3106
+ if current_line.rstrip().endswith("\\"):
3107
+ current_line = current_line.rstrip()[:-1] + " "
3108
+
3109
+ try:
3110
+ next_line = session.prompt("... ")
3111
+ except KeyboardInterrupt:
3112
+ print()
3113
+ break
3114
+ except EOFError:
3115
+ print("\n\033[31mError: Incomplete continued input\033[0m")
3116
+ break
3117
+
3118
+ current_line += next_line
3119
+ else:
3120
+ lines.append(current_line)
3121
+ break
3122
+
3123
+ # Collect a Python control block until its matching [endblock].
3124
+ if lines and is_python_block_header(lines[0].strip()):
3125
+ depth = 1
3126
+
3127
+ while depth:
3128
+ try:
3129
+ line = session.prompt(">>> ")
3130
+ except KeyboardInterrupt:
3131
+ print()
3132
+ break
3133
+ except EOFError:
3134
+ print("\n\033[31mMissing `\033[1m[endblock]\033[22m`\033[0m")
3135
+ break
3136
+
3137
+ lines.append(line)
3138
+
3139
+ if is_python_block_header(line.strip()):
3140
+ depth += 1
3141
+ elif line.strip() == "[endblock]":
3142
+ depth -= 1
3143
+ else:
3144
+ pass
3145
+
3146
+ try:
3147
+ process_lines(lines)
3148
+ history.extend(lines)
3149
+ except Exception as e:
3150
+ print(f"\033[31mError: {e}\033[0m")
3151
+
3152
+
3153
+ if __name__ == "__main__":
3154
+ main()