chemql-python 1.5.0__tar.gz → 1.5.1__tar.gz

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.
Files changed (40) hide show
  1. {chemql_python-1.5.0/src/chemql_python.egg-info → chemql_python-1.5.1}/PKG-INFO +18 -2
  2. chemql_python-1.5.0/PKG-INFO → chemql_python-1.5.1/README.md +17 -14
  3. {chemql_python-1.5.0 → chemql_python-1.5.1}/pyproject.toml +3 -1
  4. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/__init__.py +1 -1
  5. chemql_python-1.5.1/src/chemql/codecql.py +477 -0
  6. chemql_python-1.5.1/src/chemql/colors.json +77 -0
  7. chemql_python-1.5.0/README.md → chemql_python-1.5.1/src/chemql_python.egg-info/PKG-INFO +30 -1
  8. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql_python.egg-info/SOURCES.txt +3 -0
  9. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql_python.egg-info/entry_points.txt +1 -0
  10. chemql_python-1.5.1/tests/test_codecql.py +23 -0
  11. {chemql_python-1.5.0 → chemql_python-1.5.1}/LICENSE +0 -0
  12. {chemql_python-1.5.0 → chemql_python-1.5.1}/setup.cfg +0 -0
  13. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/__main__.py +0 -0
  14. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/data/BondEnergies.json +0 -0
  15. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/data/MoleculeBonds.json +0 -0
  16. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/data/Molecules.json +0 -0
  17. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/data/PeriodicTableJSON.json +0 -0
  18. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/data/Reactions.json +0 -0
  19. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/data/THIRD_PARTY_LICENSES/PeriodicTableJSON.txt +0 -0
  20. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/data/THIRD_PARTY_LICENSES/RHEA.txt +0 -0
  21. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/html/periodic_table.html +0 -0
  22. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/stdlib/README.md +0 -0
  23. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/stdlib/collections.cql +0 -0
  24. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/stdlib/datetime.cql +0 -0
  25. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/stdlib/decimal.cql +0 -0
  26. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/stdlib/fractions.cql +0 -0
  27. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/stdlib/json.cql +0 -0
  28. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/stdlib/math/statistics.cql +0 -0
  29. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/stdlib/math.cql +0 -0
  30. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/stdlib/pathlib.cql +0 -0
  31. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/stdlib/random.cql +0 -0
  32. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql/stdlib/text.cql +0 -0
  33. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql_python.egg-info/dependency_links.txt +0 -0
  34. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql_python.egg-info/requires.txt +0 -0
  35. {chemql_python-1.5.0 → chemql_python-1.5.1}/src/chemql_python.egg-info/top_level.txt +0 -0
  36. {chemql_python-1.5.0 → chemql_python-1.5.1}/tests/test_external_molecule_lookup.py +0 -0
  37. {chemql_python-1.5.0 → chemql_python-1.5.1}/tests/test_public_api.py +0 -0
  38. {chemql_python-1.5.0 → chemql_python-1.5.1}/tests/test_reactions.py +0 -0
  39. {chemql_python-1.5.0 → chemql_python-1.5.1}/tests/test_stdlib.py +0 -0
  40. {chemql_python-1.5.0 → chemql_python-1.5.1}/tests/test_structured_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chemql-python
3
- Version: 1.5.0
3
+ Version: 1.5.1
4
4
  Summary: A chemistry-focused query language and REPL
5
5
  Author-email: Mohammad Rakibul Islam <rakibulofficial1017@gmail.com>
6
6
  Requires-Python: >=3.10
@@ -19,7 +19,7 @@ SQL-inspired syntax, inspect elements and molecules, look up reactions,
19
19
  balance equations, and combine chemistry queries with Python and shell
20
20
  commands.
21
21
 
22
- - **Version:** `1.5.0`
22
+ - **Version:** `1.5.1`
23
23
  - **PyPI distribution:** `chemql-python`
24
24
  - **Python import / CLI name:** `chemql`
25
25
  - **Author:** Mohammad Rakibul Islam
@@ -143,6 +143,18 @@ exit
143
143
  - `list all` shows available sources and fields.
144
144
  - `exit` leaves the prompt.
145
145
 
146
+ ### Terminal editor
147
+
148
+ Install the package, then open a ChemQL file with the `codecql` command:
149
+
150
+ ``` bash
151
+ codecql script.cql
152
+ ```
153
+
154
+ The editor supports syntax highlighting, direct text editing, undo (`Ctrl+Z`),
155
+ redo (`Ctrl+Y`), save (`Ctrl+S`), and quit (`Ctrl+Q`). Its syntax and UI
156
+ colors are configurable in the packaged `chemql/colors.json` file.
157
+
146
158
  ## Query language
147
159
 
148
160
  A typical query starts with `search`, a source, optional conditions, and
@@ -951,6 +963,10 @@ Supported forms include `dump all|good|bad FILE`,
951
963
  `dump all|good|bad queries FILE`, and `dump all|good|bad results FILE`.
952
964
  `good` and `bad` select successful and failed executions respectively.
953
965
 
966
+ ## CLI code editor
967
+
968
+ You can edit code in the terminal using `codecql script.cql` if your script is named `script.cql`
969
+
954
970
  ## Python API
955
971
 
956
972
  ``` python
@@ -1,16 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: chemql-python
3
- Version: 1.5.0
4
- Summary: A chemistry-focused query language and REPL
5
- Author-email: Mohammad Rakibul Islam <rakibulofficial1017@gmail.com>
6
- Requires-Python: >=3.10
7
- Description-Content-Type: text/markdown
8
- License-File: LICENSE
9
- Requires-Dist: prompt_toolkit>=3.0
10
- Provides-Extra: test
11
- Requires-Dist: pytest>=8.0; extra == "test"
12
- Dynamic: license-file
13
-
14
1
  # ChemQL
15
2
 
16
3
  **ChemQL** is a Python-powered chemistry query language and interactive
@@ -19,7 +6,7 @@ SQL-inspired syntax, inspect elements and molecules, look up reactions,
19
6
  balance equations, and combine chemistry queries with Python and shell
20
7
  commands.
21
8
 
22
- - **Version:** `1.5.0`
9
+ - **Version:** `1.5.1`
23
10
  - **PyPI distribution:** `chemql-python`
24
11
  - **Python import / CLI name:** `chemql`
25
12
  - **Author:** Mohammad Rakibul Islam
@@ -143,6 +130,18 @@ exit
143
130
  - `list all` shows available sources and fields.
144
131
  - `exit` leaves the prompt.
145
132
 
133
+ ### Terminal editor
134
+
135
+ Install the package, then open a ChemQL file with the `codecql` command:
136
+
137
+ ``` bash
138
+ codecql script.cql
139
+ ```
140
+
141
+ The editor supports syntax highlighting, direct text editing, undo (`Ctrl+Z`),
142
+ redo (`Ctrl+Y`), save (`Ctrl+S`), and quit (`Ctrl+Q`). Its syntax and UI
143
+ colors are configurable in the packaged `chemql/colors.json` file.
144
+
146
145
  ## Query language
147
146
 
148
147
  A typical query starts with `search`, a source, optional conditions, and
@@ -951,6 +950,10 @@ Supported forms include `dump all|good|bad FILE`,
951
950
  `dump all|good|bad queries FILE`, and `dump all|good|bad results FILE`.
952
951
  `good` and `bad` select successful and failed executions respectively.
953
952
 
953
+ ## CLI code editor
954
+
955
+ You can edit code in the terminal using `codecql script.cql` if your script is named `script.cql`
956
+
954
957
  ## Python API
955
958
 
956
959
  ``` python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "chemql-python"
7
- version = "1.5.0"
7
+ version = "1.5.1"
8
8
  authors = [
9
9
  { name = "Mohammad Rakibul Islam", email = "rakibulofficial1017@gmail.com" }
10
10
  ]
@@ -20,6 +20,7 @@ test = ["pytest>=8.0"]
20
20
 
21
21
  [project.scripts]
22
22
  chemql = "chemql:main"
23
+ codecql = "chemql.codecql:main"
23
24
 
24
25
  [tool.setuptools]
25
26
  package-dir = {"" = "src"}
@@ -29,6 +30,7 @@ where = ["src"]
29
30
 
30
31
  [tool.setuptools.package-data]
31
32
  chemql = [
33
+ "colors.json",
32
34
  "data/*.json",
33
35
  "data/THIRD_PARTY_LICENSES/*.txt",
34
36
  "html/*.html",
@@ -64,7 +64,7 @@ __all__ = [
64
64
  "__version__"
65
65
  ]
66
66
 
67
- __version__ = "1.5.0"
67
+ __version__ = "1.5.1"
68
68
 
69
69
  # Persistent preprocessor/function state for the current ChemQL session.
70
70
  DEFINITIONS = {}
@@ -0,0 +1,477 @@
1
+ import os
2
+ import re
3
+ import json
4
+ import argparse
5
+ import keyword
6
+ import builtins
7
+ import sys
8
+ # Cross-platform terminal setup for ANSI Processing
9
+ if os.name == 'nt':
10
+ try:
11
+ import colorama
12
+ colorama.init()
13
+ except ImportError:
14
+ import ctypes
15
+ kernel32 = ctypes.windll.kernel32
16
+ # Enable virtual terminal processing flags (ENABLE_PROCESSED_OUTPUT, ENABLE_WRAP_AT_EOL_OUTPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING)
17
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
18
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-10), 1) # Enable echo input / window input flags if needed, or minimal raw setup
19
+
20
+ # Cross-platform raw keystroke capture
21
+ if os.name == 'nt':
22
+ import msvcrt
23
+ def get_key():
24
+ ch = msvcrt.getch()
25
+ if ch in (b'\x00', b'\xe0'): # Arrow key prefixes on Windows
26
+ ch2 = msvcrt.getch()
27
+ if ch2 == b'\x53':
28
+ return "delete"
29
+ return f"arrow_{ch2.hex()}"
30
+ return ch.decode('utf-8', errors='ignore')
31
+ else:
32
+ import tty
33
+ import termios
34
+ import select
35
+
36
+ def get_key():
37
+ fd = sys.stdin.fileno()
38
+ old_settings = termios.tcgetattr(fd)
39
+ try:
40
+ tty.setraw(fd)
41
+ first = os.read(fd, 1)
42
+ if first == b"\x1b":
43
+ sequence = bytearray()
44
+ while select.select([fd], [], [], 0.05)[0]:
45
+ sequence.extend(os.read(fd, 1))
46
+ final = bytes(sequence[-1:])
47
+ if len(sequence) > 1 and 0x40 <= sequence[-1] <= 0x7e:
48
+ break
49
+ if sequence.startswith(b"["):
50
+ final = bytes(sequence[-1:]) if sequence else b""
51
+ if final in {b"A", b"B", b"C", b"D", b"H", b"F"}:
52
+ return {
53
+ b"A": "arrow_up", b"B": "arrow_down",
54
+ b"C": "arrow_right", b"D": "arrow_left",
55
+ b"H": "arrow_home", b"F": "arrow_end",
56
+ }[final]
57
+ if final == b"~":
58
+ code = bytes(sequence[1:-1]).split(b";", 1)[0]
59
+ if code in {b"1", b"7"}:
60
+ return "arrow_home"
61
+ if code in {b"4", b"8"}:
62
+ return "arrow_end"
63
+ if code == b"3":
64
+ return "delete"
65
+ elif sequence.startswith(b"O") and bytes(sequence[-1:]) in {b"A", b"B", b"C", b"D", b"H", b"F"}:
66
+ return {
67
+ b"A": "arrow_up", b"B": "arrow_down",
68
+ b"C": "arrow_right", b"D": "arrow_left",
69
+ b"H": "arrow_home", b"F": "arrow_end",
70
+ }[bytes(sequence[-1:])]
71
+ return "\x1b"
72
+ if not first:
73
+ return ""
74
+ char_length = (
75
+ 1 if first[0] < 0x80 else
76
+ 2 if first[0] < 0xe0 else
77
+ 3 if first[0] < 0xf0 else 4
78
+ )
79
+ char_bytes = bytearray(first)
80
+ while len(char_bytes) < char_length and select.select([fd], [], [], 0.05)[0]:
81
+ char_bytes.extend(os.read(fd, 1))
82
+ return char_bytes.decode("utf-8", errors="replace")
83
+ finally:
84
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
85
+
86
+ # ANSI styling uses a muted, high-contrast palette that works in common terminals.
87
+ RESET = "\x1b[0m"
88
+ DIM = "\x1b[2m"
89
+
90
+
91
+ def load_color_styles():
92
+ config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "colors.json")
93
+ with open(config_path, encoding="utf-8") as config_file:
94
+ config = json.load(config_file)
95
+ groups = {}
96
+ for group_name in ("syntax", "ui"):
97
+ colors = config.get(group_name)
98
+ if not isinstance(colors, dict):
99
+ raise ValueError(f"Expected a '{group_name}' object in {config_path}")
100
+ styles = {}
101
+ for category, details in colors.items():
102
+ if not isinstance(details, dict):
103
+ raise ValueError(f"Color definition for '{category}' must be an object")
104
+ color = details.get("ansi256")
105
+ channel = details.get("channel", "foreground")
106
+ if not isinstance(color, int) or isinstance(color, bool) or not 0 <= color <= 255:
107
+ raise ValueError(f"Color definition for '{category}' needs an ANSI 256 color number")
108
+ if channel not in {"foreground", "background"}:
109
+ raise ValueError(f"Color definition for '{category}' has an invalid channel")
110
+ ansi_channel = 38 if channel == "foreground" else 48
111
+ styles[category] = f"\x1b[{ansi_channel};5;{color}m"
112
+ groups[group_name] = styles
113
+ return groups
114
+
115
+
116
+ COLOR_STYLES = load_color_styles()
117
+ HIGHLIGHT_STYLES = COLOR_STYLES["syntax"]
118
+ UI_STYLES = COLOR_STYLES["ui"]
119
+ MUTED = UI_STYLES["line_numbers"]
120
+ STATUS_BG = UI_STYLES["status_background"]
121
+ STATUS_FG = UI_STYLES["status_foreground"]
122
+
123
+ COMMANDS = {
124
+ "search", "source", "list", "clear", "return", "exit", "findel",
125
+ "findmol", "findre", "set", "add", "remove", "react", "balance",
126
+ "conditions", "view", "help", "dump", "version", "let", "unset",
127
+ "variables", "func", "define", "load", "import", "signal", "fix",
128
+ "cleanup",
129
+ }
130
+ CONTROL_KEYWORDS = {
131
+ "if", "elif", "else", "endif", "for", "while", "in", "endfor",
132
+ "endwhile", "break", "continue", "endblock", "from", "as", "public",
133
+ "private", "required", "struct", "record", "enum", "endstruct",
134
+ "endrecord", "endenum",
135
+ }
136
+ WORD_OPERATORS = {
137
+ "and", "or", "has", "like", "like!", "likep", "sort", "limit",
138
+ "count", "first", "last", "asc", "desc", "union", "intersection",
139
+ }
140
+ SOURCES = {"elements", "molecules", "reactions"}
141
+ CONDITIONS = {
142
+ "temperature", "pressure", "catalyst", "catalysts", "standard",
143
+ "room", "c", "f", "k", "pa", "kpa", "mpa", "hpa", "bar",
144
+ "atm", "torr", "mmhg", "psi",
145
+ }
146
+ PROPERTIES = set("""
147
+ name appearance atomic_mass boil category density discovered_by melt
148
+ molar_heat named_by number period group phase source bohr_model_image
149
+ bohr_model_3d spectral_img summary symbol xpos ypos wxpos wypos shells
150
+ electron_configuration electron_configuration_semantic electron_affinity
151
+ electronegativity_pauling ionization_energies cpk_hex cpk-hex image block
152
+ formula elements molecular_weight iupac_name smiles cid melting_point
153
+ boiling_point state bonds total_bond_energy id equation reaction_type
154
+ reversible reactants products conditions notes
155
+ """.split())
156
+ PYTHON_BUILTINS = set(dir(builtins))
157
+ TOKEN_RE = re.compile(
158
+ r"""(?P<comment>//.*$|#.*$)"""
159
+ r"""|(?P<string>'''(?:\\.|[^\\])*?'''|\"\"\"(?:\\.|[^\\])*?\"\"\"|"""
160
+ r"""'(?:\\.|[^'\\])*'?|"(?:\\.|[^"\\])*"?)"""
161
+ r"""|(?P<formula>(?<![A-Za-z0-9_])(?-i:(?:[A-Z][a-z]?\d*)+)(?![A-Za-z0-9_]))"""
162
+ r"""|(?P<number>(?<![\w.])[+-]?(?:\d+(?:\.\d*)?|\.\d+)"""
163
+ r"""\s*(?:°\s*[cfk]|nm\^-?2|n/m\^?2|pa|kpa|mpa|hpa|bar|atm|torr|mmhg|psi|[cfk])?(?!\w))"""
164
+ r"""|(?P<identifier>[A-Za-z_]\w*!?)"""
165
+ r"""|(?P<operator><->|->|!=|==|>=|<=|:=|[-+*/%=<>])"""
166
+ r"""|(?P<delimiter>\{\{|\}\}|\[\[\[|\]\]\]|\{|\}|\[|\]|\(|\)|,|:|\.|;|\\|\?)""",
167
+ re.IGNORECASE,
168
+ )
169
+ AUTO_INDENT = re.compile(
170
+ r"^\s*(?:(?:func|define)\b.*(?:\{\}|\{)$|"
171
+ r"(?:struct|record|enum)\s+\w+(?:\s+from\b.*)?$|"
172
+ r"(?:if|elif|else|for|while)\b.*|.*\\)$",
173
+ re.IGNORECASE,
174
+ )
175
+
176
+
177
+ def _token_style(kind, value):
178
+ if kind == "comment":
179
+ return HIGHLIGHT_STYLES["comment"]
180
+ if kind == "string":
181
+ return HIGHLIGHT_STYLES["string"]
182
+ if kind == "number":
183
+ return HIGHLIGHT_STYLES["number"]
184
+ if kind == "formula" and (any(char.isdigit() for char in value) or len(re.findall(r"[A-Z]", value)) > 1):
185
+ return HIGHLIGHT_STYLES["formula"]
186
+ if kind == "operator":
187
+ return HIGHLIGHT_STYLES["operator"]
188
+ if kind == "delimiter":
189
+ return HIGHLIGHT_STYLES["command"] if value == "?" else HIGHLIGHT_STYLES["delimiter"]
190
+ word = value.casefold()
191
+ if word in {"true", "false", "none"}:
192
+ category = "constant"
193
+ elif word in {"enum", "struct", "record"}:
194
+ category = "type"
195
+ elif word in COMMANDS or word == "?":
196
+ category = "command"
197
+ elif word in CONTROL_KEYWORDS or keyword.iskeyword(word):
198
+ category = "keyword"
199
+ elif word in WORD_OPERATORS:
200
+ category = "operator"
201
+ elif word in SOURCES:
202
+ category = "source"
203
+ elif word in CONDITIONS:
204
+ category = "condition"
205
+ elif word in PROPERTIES:
206
+ category = "property"
207
+ elif word in PYTHON_BUILTINS:
208
+ category = "builtin"
209
+ else:
210
+ return ""
211
+ return HIGHLIGHT_STYLES[category]
212
+
213
+
214
+ def apply_syntax_highlighting(text):
215
+ parts = []
216
+ last_end = 0
217
+ for match in TOKEN_RE.finditer(text):
218
+ parts.append(text[last_end:match.start()])
219
+ kind = match.lastgroup
220
+ token = match.group()
221
+ style = _token_style(kind, token) if kind else ""
222
+ if kind == "identifier" and re.search(
223
+ r"\b(?:enum|struct|record)\s+$", text[:match.start()], re.IGNORECASE
224
+ ):
225
+ style = HIGHLIGHT_STYLES["type"]
226
+ if style:
227
+ parts.extend((style, token, RESET))
228
+ else:
229
+ parts.append(token)
230
+ last_end = match.end()
231
+ parts.append(text[last_end:])
232
+ return "".join(parts)
233
+
234
+ class Editor:
235
+ HISTORY_LIMIT = 200
236
+
237
+ def __init__(self, filename):
238
+ self.filename = filename
239
+ self.lines = [""]
240
+ self.cursor_x = 0
241
+ self.cursor_y = 0
242
+ self.scroll_x = 0
243
+ self.scroll_y = 0
244
+ self.dirty = False
245
+ self.quit_requested = False
246
+ self.message = f"Opened {filename}" if filename else "New file"
247
+ self.undo_stack = []
248
+ self.redo_stack = []
249
+
250
+ if filename and os.path.exists(filename):
251
+ with open(filename, 'r', encoding='utf-8') as f:
252
+ content = f.read().splitlines()
253
+ self.lines = content if content else [""]
254
+ self.saved_lines = tuple(self.lines)
255
+
256
+ def run(self):
257
+ sys.stdout.write("\x1b[?1049h")
258
+ sys.stdout.flush()
259
+ try:
260
+ while True:
261
+ self.draw_screen()
262
+ key = get_key()
263
+ if self.handle_key(key):
264
+ break
265
+ finally:
266
+ sys.stdout.write("\x1b[?1049l")
267
+ sys.stdout.flush()
268
+
269
+ def draw_screen(self):
270
+ try:
271
+ cols, rows = os.get_terminal_size()
272
+ except OSError:
273
+ cols, rows = 80, 24
274
+ cols = max(cols, 20)
275
+ rows = max(rows, 4)
276
+ gutter_width = len(str(max(len(self.lines), self.cursor_y + 1))) + 2
277
+ viewport_height = rows - 2
278
+ viewport_width = max(1, cols - gutter_width)
279
+ visual_cursor_x = len(self.lines[self.cursor_y][:self.cursor_x].expandtabs(4))
280
+ self._keep_cursor_visible(viewport_height, viewport_width, visual_cursor_x)
281
+
282
+ output = ["\x1b[H"]
283
+ for screen_y in range(viewport_height):
284
+ line_y = self.scroll_y + screen_y
285
+ if line_y < len(self.lines):
286
+ line_number = f"{line_y + 1:>{gutter_width - 2}} "
287
+ visible_text = self.lines[line_y].expandtabs(4)[
288
+ self.scroll_x:self.scroll_x + viewport_width
289
+ ]
290
+ output.extend((
291
+ DIM, MUTED, line_number, RESET,
292
+ apply_syntax_highlighting(visible_text), "\x1b[K\r\n",
293
+ ))
294
+ else:
295
+ output.extend((DIM, MUTED, " " * (gutter_width - 1), "~", RESET, "\x1b[K\r\n"))
296
+
297
+ title = f" ChemQL {os.path.basename(self.filename) if self.filename else 'untitled'}"
298
+ status = f"Ln {self.cursor_y + 1}, Col {self.cursor_x + 1} | {len(self.lines)} lines"
299
+ if self.dirty:
300
+ status += " | Unsaved"
301
+ status_text = f"{title:<{max(0, cols - len(status))}}{status}"
302
+ output.extend((STATUS_BG, STATUS_FG, status_text[:cols].ljust(cols), RESET, "\r\n"))
303
+ footer = self.message or "Ctrl+S Save Ctrl+Z Undo Ctrl+Y Redo Ctrl+Q Quit"
304
+ output.extend((DIM, footer[:cols].ljust(cols), RESET))
305
+ cursor_row = self.cursor_y - self.scroll_y + 1
306
+ cursor_col = gutter_width + visual_cursor_x - self.scroll_x
307
+ output.append(f"\x1b[{cursor_row};{cursor_col}H")
308
+
309
+ sys.stdout.write("".join(output))
310
+ sys.stdout.flush()
311
+
312
+ def _keep_cursor_visible(self, height, width, visual_cursor_x):
313
+ if self.cursor_y < self.scroll_y:
314
+ self.scroll_y = self.cursor_y
315
+ elif self.cursor_y >= self.scroll_y + height:
316
+ self.scroll_y = self.cursor_y - height + 1
317
+ if visual_cursor_x < self.scroll_x:
318
+ self.scroll_x = visual_cursor_x
319
+ elif visual_cursor_x >= self.scroll_x + width:
320
+ self.scroll_x = visual_cursor_x - width + 1
321
+
322
+ def handle_key(self, key):
323
+ if key == "\x13":
324
+ self.save_file()
325
+ return False
326
+ if key == "\x11":
327
+ if self.dirty and not self.quit_requested:
328
+ self.message = "Unsaved changes. Press Ctrl+Q again to quit without saving."
329
+ self.quit_requested = True
330
+ return False
331
+ return True
332
+ if key == "\x1a":
333
+ self.undo()
334
+ return False
335
+ if key == "\x19":
336
+ self.redo()
337
+ return False
338
+ if key in ("arrow_up", "arrow_H", "arrow_A", "arrow_48"):
339
+ self.cursor_y = max(0, self.cursor_y - 1)
340
+ elif key in ("arrow_down", "arrow_P", "arrow_B", "arrow_50"):
341
+ self.cursor_y = min(len(self.lines) - 1, self.cursor_y + 1)
342
+ elif key in ("arrow_left", "arrow_K", "arrow_D", "arrow_4b"):
343
+ self.cursor_x = max(0, self.cursor_x - 1)
344
+ elif key in ("arrow_right", "arrow_M", "arrow_C", "arrow_4d"):
345
+ self.cursor_x = min(len(self.lines[self.cursor_y]), self.cursor_x + 1)
346
+ elif key in ("arrow_end", "arrow_4f", "arrow_F"):
347
+ self.cursor_x = len(self.lines[self.cursor_y])
348
+ elif key in ("arrow_home", "arrow_47", "arrow_G"):
349
+ self.cursor_x = 0
350
+ elif key in ("\x7f", "\x08"):
351
+ self._backspace()
352
+ elif key in ("delete", "\x1b[3~"):
353
+ self._delete_forward()
354
+ elif key in ("\r", "\n"):
355
+ self._insert_newline()
356
+ elif key == "\t":
357
+ self._insert_text("\t")
358
+ elif key and key.isprintable():
359
+ self._insert_text(key)
360
+ if key not in ("\x11",):
361
+ self.quit_requested = False
362
+ if key != "\x11":
363
+ self.message = ""
364
+ self.cursor_y = min(self.cursor_y, len(self.lines) - 1)
365
+ self.cursor_x = min(self.cursor_x, len(self.lines[self.cursor_y]))
366
+ return False
367
+
368
+ def _insert_text(self, text):
369
+ current_line = self.lines[self.cursor_y]
370
+ self._record_history()
371
+ self.lines[self.cursor_y] = (
372
+ current_line[:self.cursor_x] + text + current_line[self.cursor_x:]
373
+ )
374
+ self.cursor_x += len(text)
375
+ self.dirty = True
376
+
377
+ def _insert_newline(self):
378
+ current_line = self.lines[self.cursor_y]
379
+ left_side = current_line[:self.cursor_x]
380
+ right_side = current_line[self.cursor_x:]
381
+ indentation = re.match(r"^\s*", current_line).group() if re.match(r"^\s*", current_line) is not None else "" # type:ignore
382
+ if AUTO_INDENT.match(left_side.rstrip()):
383
+ indentation += "\t"
384
+ self._record_history()
385
+ self.lines[self.cursor_y] = left_side
386
+ self.lines.insert(self.cursor_y + 1, indentation + right_side)
387
+ self.cursor_y += 1
388
+ self.cursor_x = len(indentation)
389
+ self.dirty = True
390
+
391
+ def _backspace(self):
392
+ current_line = self.lines[self.cursor_y]
393
+ if self.cursor_x > 0:
394
+ self._record_history()
395
+ self.lines[self.cursor_y] = (
396
+ current_line[:self.cursor_x - 1] + current_line[self.cursor_x:]
397
+ )
398
+ self.cursor_x -= 1
399
+ self.dirty = True
400
+ elif self.cursor_y > 0:
401
+ self._record_history()
402
+ previous_length = len(self.lines[self.cursor_y - 1])
403
+ self.lines[self.cursor_y - 1] += current_line
404
+ self.lines.pop(self.cursor_y)
405
+ self.cursor_y -= 1
406
+ self.cursor_x = previous_length
407
+ self.dirty = True
408
+
409
+ def _delete_forward(self):
410
+ current_line = self.lines[self.cursor_y]
411
+ if self.cursor_x < len(current_line):
412
+ self._record_history()
413
+ self.lines[self.cursor_y] = (
414
+ current_line[:self.cursor_x] + current_line[self.cursor_x + 1:]
415
+ )
416
+ self.dirty = True
417
+ elif self.cursor_y < len(self.lines) - 1:
418
+ self._record_history()
419
+ self.lines[self.cursor_y] += self.lines.pop(self.cursor_y + 1)
420
+ self.dirty = True
421
+
422
+ def _current_state(self):
423
+ return tuple(self.lines), self.cursor_x, self.cursor_y
424
+
425
+ def _restore_state(self, state):
426
+ lines, self.cursor_x, self.cursor_y = state
427
+ self.lines = list(lines)
428
+ self.dirty = tuple(self.lines) != self.saved_lines
429
+
430
+ def _record_history(self):
431
+ self.undo_stack.append(self._current_state())
432
+ if len(self.undo_stack) > self.HISTORY_LIMIT:
433
+ self.undo_stack.pop(0)
434
+ self.redo_stack.clear()
435
+
436
+ def undo(self):
437
+ if not self.undo_stack:
438
+ self.message = "Nothing to undo"
439
+ return
440
+ self.redo_stack.append(self._current_state())
441
+ self._restore_state(self.undo_stack.pop())
442
+ self.message = "Undone"
443
+
444
+ def redo(self):
445
+ if not self.redo_stack:
446
+ self.message = "Nothing to redo"
447
+ return
448
+ self.undo_stack.append(self._current_state())
449
+ self._restore_state(self.redo_stack.pop())
450
+ self.message = "Redone"
451
+
452
+ def save_file(self):
453
+ if not self.filename:
454
+ self.message = "Error: No file specified. Run with an argument name!"
455
+ return
456
+ try:
457
+ with open(self.filename, 'w', encoding='utf-8') as f:
458
+ f.write("\n".join(self.lines))
459
+ self.saved_lines = tuple(self.lines)
460
+ self.dirty = False
461
+ self.message = f"Saved {self.filename}"
462
+ except Exception as e:
463
+ self.message = f"Failed to save file: {str(e)}"
464
+
465
+ def main(argv: list[str] | None = None) -> None:
466
+ """Launch the ChemQL terminal editor for the requested file."""
467
+ parser = argparse.ArgumentParser(
468
+ prog="codecql",
469
+ description="Open and edit a ChemQL source file.",
470
+ )
471
+ parser.add_argument("file", help="ChemQL file to edit")
472
+ arguments = parser.parse_args(argv)
473
+ Editor(arguments.file).run()
474
+
475
+
476
+ if __name__ == "__main__":
477
+ main()
@@ -0,0 +1,77 @@
1
+ {
2
+ "syntax": {
3
+ "comment": {
4
+ "ansi256": 108,
5
+ "description": "Line comments"
6
+ },
7
+ "string": {
8
+ "ansi256": 114,
9
+ "description": "Quoted text and string literals"
10
+ },
11
+ "number": {
12
+ "ansi256": 141,
13
+ "description": "Numeric values and measurements"
14
+ },
15
+ "command": {
16
+ "ansi256": 81,
17
+ "description": "ChemQL commands and help markers"
18
+ },
19
+ "keyword": {
20
+ "ansi256": 177,
21
+ "description": "Control flow and declaration keywords"
22
+ },
23
+ "operator": {
24
+ "ansi256": 215,
25
+ "description": "Comparison, logic, and query operators"
26
+ },
27
+ "source": {
28
+ "ansi256": 80,
29
+ "description": "Built-in data sources"
30
+ },
31
+ "property": {
32
+ "ansi256": 153,
33
+ "description": "Chemistry record property names"
34
+ },
35
+ "condition": {
36
+ "ansi256": 220,
37
+ "description": "Reaction conditions and units"
38
+ },
39
+ "constant": {
40
+ "ansi256": 203,
41
+ "description": "Boolean and null constants"
42
+ },
43
+ "type": {
44
+ "ansi256": 222,
45
+ "description": "Struct, record, enum, and declared type names"
46
+ },
47
+ "builtin": {
48
+ "ansi256": 117,
49
+ "description": "Python built-in names in embedded expressions"
50
+ },
51
+ "formula": {
52
+ "ansi256": 86,
53
+ "description": "Chemical formulas"
54
+ },
55
+ "delimiter": {
56
+ "ansi256": 245,
57
+ "description": "Brackets, separators, and punctuation"
58
+ }
59
+ },
60
+ "ui": {
61
+ "line_numbers": {
62
+ "ansi256": 245,
63
+ "channel": "foreground",
64
+ "description": "Muted line-number gutter"
65
+ },
66
+ "status_background": {
67
+ "ansi256": 236,
68
+ "channel": "background",
69
+ "description": "Editor status-bar background"
70
+ },
71
+ "status_foreground": {
72
+ "ansi256": 252,
73
+ "channel": "foreground",
74
+ "description": "Editor status-bar text"
75
+ }
76
+ }
77
+ }
@@ -1,3 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: chemql-python
3
+ Version: 1.5.1
4
+ Summary: A chemistry-focused query language and REPL
5
+ Author-email: Mohammad Rakibul Islam <rakibulofficial1017@gmail.com>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: prompt_toolkit>=3.0
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest>=8.0; extra == "test"
12
+ Dynamic: license-file
13
+
1
14
  # ChemQL
2
15
 
3
16
  **ChemQL** is a Python-powered chemistry query language and interactive
@@ -6,7 +19,7 @@ SQL-inspired syntax, inspect elements and molecules, look up reactions,
6
19
  balance equations, and combine chemistry queries with Python and shell
7
20
  commands.
8
21
 
9
- - **Version:** `1.5.0`
22
+ - **Version:** `1.5.1`
10
23
  - **PyPI distribution:** `chemql-python`
11
24
  - **Python import / CLI name:** `chemql`
12
25
  - **Author:** Mohammad Rakibul Islam
@@ -130,6 +143,18 @@ exit
130
143
  - `list all` shows available sources and fields.
131
144
  - `exit` leaves the prompt.
132
145
 
146
+ ### Terminal editor
147
+
148
+ Install the package, then open a ChemQL file with the `codecql` command:
149
+
150
+ ``` bash
151
+ codecql script.cql
152
+ ```
153
+
154
+ The editor supports syntax highlighting, direct text editing, undo (`Ctrl+Z`),
155
+ redo (`Ctrl+Y`), save (`Ctrl+S`), and quit (`Ctrl+Q`). Its syntax and UI
156
+ colors are configurable in the packaged `chemql/colors.json` file.
157
+
133
158
  ## Query language
134
159
 
135
160
  A typical query starts with `search`, a source, optional conditions, and
@@ -938,6 +963,10 @@ Supported forms include `dump all|good|bad FILE`,
938
963
  `dump all|good|bad queries FILE`, and `dump all|good|bad results FILE`.
939
964
  `good` and `bad` select successful and failed executions respectively.
940
965
 
966
+ ## CLI code editor
967
+
968
+ You can edit code in the terminal using `codecql script.cql` if your script is named `script.cql`
969
+
941
970
  ## Python API
942
971
 
943
972
  ``` python
@@ -3,6 +3,8 @@ README.md
3
3
  pyproject.toml
4
4
  src/chemql/__init__.py
5
5
  src/chemql/__main__.py
6
+ src/chemql/codecql.py
7
+ src/chemql/colors.json
6
8
  src/chemql/data/BondEnergies.json
7
9
  src/chemql/data/MoleculeBonds.json
8
10
  src/chemql/data/Molecules.json
@@ -28,6 +30,7 @@ src/chemql_python.egg-info/dependency_links.txt
28
30
  src/chemql_python.egg-info/entry_points.txt
29
31
  src/chemql_python.egg-info/requires.txt
30
32
  src/chemql_python.egg-info/top_level.txt
33
+ tests/test_codecql.py
31
34
  tests/test_external_molecule_lookup.py
32
35
  tests/test_public_api.py
33
36
  tests/test_reactions.py
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  chemql = chemql:main
3
+ codecql = chemql.codecql:main
@@ -0,0 +1,23 @@
1
+ from chemql import codecql
2
+
3
+
4
+ def test_codecql_launches_editor_for_requested_file(monkeypatch):
5
+ launched = {}
6
+
7
+ class FakeEditor:
8
+ def __init__(self, filename):
9
+ launched["filename"] = filename
10
+
11
+ def run(self):
12
+ launched["ran"] = True
13
+
14
+ monkeypatch.setattr(codecql, "Editor", FakeEditor)
15
+
16
+ codecql.main(["script.cql"])
17
+
18
+ assert launched == {"filename": "script.cql", "ran": True}
19
+
20
+
21
+ def test_codecql_uses_packaged_color_categories():
22
+ assert codecql.HIGHLIGHT_STYLES["command"]
23
+ assert codecql.UI_STYLES["status_background"]
File without changes
File without changes