execsql2 2.1.2__py3-none-any.whl → 2.4.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.
Files changed (94) hide show
  1. execsql/cli/__init__.py +436 -0
  2. execsql/cli/dsn.py +86 -0
  3. execsql/cli/help.py +140 -0
  4. execsql/{cli.py → cli/run.py} +14 -589
  5. execsql/config.py +65 -1
  6. execsql/db/access.py +27 -15
  7. execsql/db/base.py +328 -215
  8. execsql/db/dsn.py +10 -5
  9. execsql/db/duckdb.py +6 -2
  10. execsql/db/factory.py +21 -0
  11. execsql/db/firebird.py +27 -19
  12. execsql/db/mysql.py +12 -7
  13. execsql/db/oracle.py +15 -11
  14. execsql/db/postgres.py +31 -16
  15. execsql/db/sqlite.py +15 -11
  16. execsql/db/sqlserver.py +16 -5
  17. execsql/exceptions.py +25 -7
  18. execsql/exporters/base.py +12 -1
  19. execsql/exporters/delimited.py +80 -35
  20. execsql/exporters/duckdb.py +6 -2
  21. execsql/exporters/feather.py +10 -6
  22. execsql/exporters/html.py +89 -69
  23. execsql/exporters/json.py +52 -45
  24. execsql/exporters/latex.py +37 -27
  25. execsql/exporters/ods.py +32 -11
  26. execsql/exporters/parquet.py +5 -2
  27. execsql/exporters/pretty.py +16 -9
  28. execsql/exporters/raw.py +22 -16
  29. execsql/exporters/sqlite.py +6 -2
  30. execsql/exporters/templates.py +39 -21
  31. execsql/exporters/values.py +26 -20
  32. execsql/exporters/xls.py +30 -11
  33. execsql/exporters/xml.py +31 -13
  34. execsql/exporters/zip.py +15 -0
  35. execsql/importers/base.py +6 -4
  36. execsql/importers/csv.py +8 -6
  37. execsql/importers/feather.py +6 -4
  38. execsql/importers/ods.py +6 -4
  39. execsql/importers/xls.py +6 -4
  40. execsql/metacommands/__init__.py +208 -1548
  41. execsql/metacommands/conditions.py +101 -27
  42. execsql/metacommands/control.py +8 -4
  43. execsql/metacommands/data.py +6 -6
  44. execsql/metacommands/debug.py +6 -2
  45. execsql/metacommands/dispatch.py +2011 -0
  46. execsql/metacommands/io.py +67 -1310
  47. execsql/metacommands/io_export.py +442 -0
  48. execsql/metacommands/io_fileops.py +287 -0
  49. execsql/metacommands/io_import.py +398 -0
  50. execsql/metacommands/io_write.py +248 -0
  51. execsql/metacommands/prompt.py +22 -66
  52. execsql/metacommands/system.py +7 -2
  53. execsql/models.py +7 -0
  54. execsql/parser.py +10 -0
  55. execsql/py.typed +0 -0
  56. execsql/script/__init__.py +95 -0
  57. execsql/script/control.py +162 -0
  58. execsql/{script.py → script/engine.py} +184 -402
  59. execsql/script/variables.py +281 -0
  60. execsql/types.py +49 -20
  61. execsql/utils/auth.py +2 -0
  62. execsql/utils/crypto.py +4 -6
  63. execsql/utils/datetime.py +1 -0
  64. execsql/utils/errors.py +11 -0
  65. execsql/utils/fileio.py +33 -8
  66. execsql/utils/gui.py +46 -0
  67. execsql/utils/mail.py +7 -17
  68. execsql/utils/numeric.py +2 -0
  69. execsql/utils/regex.py +9 -0
  70. execsql/utils/strings.py +16 -0
  71. execsql/utils/timer.py +2 -0
  72. execsql2-2.4.0.data/data/execsql2_extras/README.md +65 -0
  73. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/execsql.conf +1 -1
  74. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/METADATA +13 -6
  75. execsql2-2.4.0.dist-info/RECORD +108 -0
  76. execsql2-2.1.2.data/data/execsql2_extras/READ_ME.rst +0 -127
  77. execsql2-2.1.2.dist-info/RECORD +0 -96
  78. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  79. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  80. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  81. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  82. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  83. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  84. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  85. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  86. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  87. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/script_template.sql +0 -0
  88. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  89. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  90. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  91. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/WHEEL +0 -0
  92. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/entry_points.txt +0 -0
  93. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/LICENSE.txt +0 -0
  94. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/NOTICE +0 -0
@@ -1,50 +1,29 @@
1
1
  from __future__ import annotations
2
2
 
3
- """
4
- Core script-execution engine for execsql.
5
-
6
- This module contains the data structures and functions that load, parse, and
7
- drive execution of execsql ``.sql`` script files. It is the heart of the
8
- runtime.
9
-
10
- Key classes:
11
-
12
- - :class:`BatchLevels` — tracks which databases are used in nested BEGIN/END
13
- BATCH blocks for commit/rollback handling.
14
- - :class:`IfItem` / :class:`IfLevels` — stack-based IF/ELSE/ENDIF nesting.
15
- - :class:`CounterVars` — named integer counters (``@NAME``).
16
- - :class:`SubVarSet` — global ``!!$VAR!!`` substitution-variable store, plus
17
- ``&ENV``, ``@COUNTER``, ``~LOCAL``, and ``#ARG`` prefixes.
18
- - :class:`LocalSubVarSet` / :class:`ScriptArgSubVarSet` — per-script-scope
19
- variable overlays.
20
- - :class:`MetaCommand` — one entry in the metacommand dispatch table (regex +
21
- handler function + flags).
22
- - :class:`MetaCommandList` — ordered list of :class:`MetaCommand` entries;
23
- ``get_match()`` finds the first matching entry for a given line.
24
- - :class:`SqlStmt` — wraps a single SQL string; ``run()`` executes it via the
25
- active database connection.
26
- - :class:`MetacommandStmt` — wraps a metacommand line; ``run()`` dispatches
27
- through :attr:`execsql.state.metacommandlist`.
3
+ """Script execution engine for execsql.
4
+
5
+ Classes and functions that load, parse, and drive execution of execsql
6
+ ``.sql`` script files.
7
+
8
+ Classes:
9
+ - :class:`MetaCommand` — one entry in the metacommand dispatch table.
10
+ - :class:`MetaCommandList` — ordered list of :class:`MetaCommand` entries.
11
+ - :class:`SqlStmt` — wraps a single SQL string for execution.
12
+ - :class:`MetacommandStmt` — wraps a metacommand line for dispatch.
28
13
  - :class:`ScriptCmd` — pairs a statement with its source-file location.
29
- - :class:`CommandList` — ordered list of :class:`ScriptCmd` objects plus an
30
- execution cursor; ``run_next()`` drives one step of execution.
31
- - :class:`CommandListWhileLoop` / :class:`CommandListUntilLoop` — loop
32
- variants of :class:`CommandList` that re-evaluate a condition each pass.
33
- - :class:`ScriptFile` — reads and tokenises a ``.sql`` file into
34
- :class:`ScriptCmd` objects.
14
+ - :class:`CommandList` — ordered list of :class:`ScriptCmd` objects.
15
+ - :class:`CommandListWhileLoop` loop variant that repeats while a condition is true.
16
+ - :class:`CommandListUntilLoop` — loop variant that repeats until a condition is true.
17
+ - :class:`ScriptFile` reads and tokenises a ``.sql`` file.
35
18
  - :class:`ScriptExecSpec` — specification for deferred script execution.
36
19
 
37
- Key functions:
38
-
20
+ Functions:
39
21
  - :func:`set_system_vars` — populates built-in ``$VARNAME`` system variables.
40
22
  - :func:`substitute_vars` — performs ``!!$VAR!!`` and ``!{$var}!`` expansion.
41
- - :func:`runscripts` — central execution loop; pops the top
42
- :class:`CommandList` from ``_state.commandliststack`` and drives
43
- ``run_next()`` until the stack is empty.
44
- - :func:`current_script_line` — returns the source location of the currently
45
- executing command.
46
- - :func:`read_sqlfile` — parses a SQL script file into a new
47
- :class:`CommandList` and pushes it onto ``_state.commandliststack``.
23
+ - :func:`runscripts` — central execution loop.
24
+ - :func:`current_script_line` returns the source location of the currently executing command.
25
+ - :func:`read_sqlfile` parses a SQL script file into a new :class:`CommandList`.
26
+ - :func:`read_sqlstring` — parses an inline script string into a new :class:`CommandList`.
48
27
  """
49
28
 
50
29
  import copy
@@ -57,359 +36,24 @@ from typing import Any
57
36
 
58
37
  import execsql.state as _state
59
38
  from execsql.exceptions import ErrInfo
39
+ from execsql.script.variables import LocalSubVarSet, ScriptArgSubVarSet, SubVarSet
60
40
  from execsql.utils.errors import exception_desc
61
41
  from execsql.utils.fileio import EncodedFile
62
42
 
63
43
 
64
- # ---------------------------------------------------------------------------
65
- # BatchLevels
66
- # ---------------------------------------------------------------------------
67
-
68
-
69
- class BatchLevels:
70
- # A stack to keep a record of the databases used in nested batches.
71
- class Batch:
72
- def __init__(self) -> None:
73
- self.dbs_used: list[Any] = []
74
-
75
- def __init__(self) -> None:
76
- self.batchlevels: list[BatchLevels.Batch] = []
77
-
78
- def in_batch(self) -> bool:
79
- return len(self.batchlevels) > 0
80
-
81
- def new_batch(self) -> None:
82
- self.batchlevels.append(self.Batch())
83
-
84
- def using_db(self, db: Any) -> None:
85
- if len(self.batchlevels) > 0 and db not in self.batchlevels[-1].dbs_used:
86
- self.batchlevels[-1].dbs_used.append(db)
87
-
88
- def uses_db(self, db: Any) -> bool:
89
- if len(self.batchlevels) == 0:
90
- return False
91
- return any(db in batch.dbs_used for batch in self.batchlevels)
92
-
93
- def rollback_batch(self) -> None:
94
- if len(self.batchlevels) > 0:
95
- b = self.batchlevels[-1]
96
- for db in b.dbs_used:
97
- db.rollback()
98
-
99
- def end_batch(self) -> None:
100
- b = self.batchlevels.pop()
101
- for db in b.dbs_used:
102
- db.commit()
103
-
104
-
105
- # ---------------------------------------------------------------------------
106
- # IfItem / IfLevels
107
- # ---------------------------------------------------------------------------
108
-
109
-
110
- class IfItem:
111
- # An object representing an 'if' level, with context data.
112
- def __init__(self, tf_value: bool) -> None:
113
- self.tf_value = tf_value
114
- self.scriptname, self.scriptline = current_script_line()
115
-
116
- def value(self) -> bool:
117
- return self.tf_value
118
-
119
- def invert(self) -> None:
120
- self.tf_value = not self.tf_value
121
-
122
- def change_to(self, tf_value: bool) -> None:
123
- self.tf_value = tf_value
124
-
125
- def script_line(self) -> tuple:
126
- return (self.scriptname, self.scriptline)
127
-
128
-
129
- class IfLevels:
130
- # A stack of True/False values corresponding to a nested set of conditionals,
131
- # with methods to manipulate and query the set of conditional states.
132
- def __init__(self) -> None:
133
- self.if_levels: list[IfItem] = []
134
-
135
- def nest(self, tf_value: bool) -> None:
136
- self.if_levels.append(IfItem(tf_value))
137
-
138
- def unnest(self) -> None:
139
- if len(self.if_levels) == 0:
140
- raise ErrInfo(type="error", other_msg="Can't exit an IF block; no IF block is active.")
141
- else:
142
- self.if_levels.pop()
143
-
144
- def invert(self) -> None:
145
- if len(self.if_levels) == 0:
146
- raise ErrInfo(type="error", other_msg="Can't change the IF state; no IF block is active.")
147
- else:
148
- self.if_levels[-1].invert()
149
-
150
- def replace(self, tf_value: bool) -> None:
151
- if len(self.if_levels) == 0:
152
- raise ErrInfo(type="error", other_msg="Can't change the IF state; no IF block is active.")
153
- else:
154
- self.if_levels[-1].change_to(tf_value)
155
-
156
- def current(self) -> bool:
157
- if len(self.if_levels) == 0:
158
- raise ErrInfo(type="error", other_msg="No IF block is active.")
159
- else:
160
- return self.if_levels[-1].value()
161
-
162
- def all_true(self) -> bool:
163
- if self.if_levels == []:
164
- return True
165
- return all(tf.value() for tf in self.if_levels)
166
-
167
- def only_current_false(self) -> bool:
168
- # Returns True if the current if level is false and all higher levels are True.
169
- if len(self.if_levels) == 0:
170
- return False
171
- elif len(self.if_levels) == 1:
172
- return not self.if_levels[-1].value()
173
- else:
174
- return not self.if_levels[-1].value() and all(tf.value() for tf in self.if_levels[:-1])
175
-
176
- def script_lines(self, top_n: int) -> list[tuple]:
177
- # Returns a list of tuples containing the script name and line number
178
- # for the topmost 'top_n' if levels, in bottom-up order.
179
- if len(self.if_levels) < top_n:
180
- raise ErrInfo(type="error", other_msg="Invalid IF stack depth reference.")
181
- levels = self.if_levels[len(self.if_levels) - top_n :]
182
- return [lvl.script_line() for lvl in levels]
183
-
184
-
185
- # ---------------------------------------------------------------------------
186
- # CounterVars
187
- # ---------------------------------------------------------------------------
188
-
189
-
190
- class CounterVars:
191
- # A dictionary of dynamically created named counter variables.
192
- _COUNTER_RX = re.compile(r"!!\$(COUNTER_\d+)!!", re.I)
193
-
194
- def __init__(self) -> None:
195
- self.counters: dict[str, int] = {}
196
-
197
- def _ctrid(self, ctr_no: int) -> str:
198
- return f"counter_{ctr_no}"
199
-
200
- def set_counter(self, ctr_no: int, ctr_val: int) -> None:
201
- self.counters[self._ctrid(ctr_no)] = ctr_val
202
-
203
- def remove_counter(self, ctr_no: int) -> None:
204
- ctr_id = self._ctrid(ctr_no)
205
- if ctr_id in self.counters:
206
- del self.counters[ctr_id]
207
-
208
- def remove_all_counters(self) -> None:
209
- self.counters = {}
210
-
211
- def substitute(self, command_str: str) -> tuple:
212
- # Substitutes any counter variable references with the counter value and
213
- # returns the modified command string and a flag indicating replacements.
214
- m = self._COUNTER_RX.search(command_str, re.I)
215
- if m:
216
- ctr_id = m.group(1).lower()
217
- if ctr_id not in self.counters:
218
- self.counters[ctr_id] = 0
219
- new_count = self.counters[ctr_id] + 1
220
- self.counters[ctr_id] = new_count
221
- return command_str.replace("!!$" + m.group(1) + "!!", str(new_count)), True
222
- return command_str, False
223
-
224
- def substitute_all(self, any_text: str) -> tuple:
225
- subbed = True
226
- any_subbed = False
227
- while subbed:
228
- any_text, subbed = self.substitute(any_text)
229
- if subbed:
230
- any_subbed = True
231
- return any_text, any_subbed
232
-
233
-
234
- # ---------------------------------------------------------------------------
235
- # SubVarSet / LocalSubVarSet / ScriptArgSubVarSet
236
- # ---------------------------------------------------------------------------
237
-
238
-
239
- class SubVarSet:
240
- # A pool of substitution variables. Each variable consists of a name and
241
- # a (string) value. All variable names are stored as lowercase text.
242
- # Internally uses a dict for O(1) lookups; the ``substitutions`` property
243
- # exposes the data as a list of ``(name, value)`` tuples for backward
244
- # compatibility with external code.
245
- def __init__(self) -> None:
246
- self._subs_dict: dict[str, Any] = {}
247
- self._compiled_patterns: dict[str, tuple] = {}
248
- self.prefix_list: list[str] = ["$", "&", "@"]
249
- # Don't construct/compile on init because deepcopy() can't handle compiled regexes.
250
- self.var_rx = None
251
-
252
- @property
253
- def substitutions(self) -> list[tuple]:
254
- """Backward-compatible view of substitutions as a list of (name, value) tuples."""
255
- return list(self._subs_dict.items())
256
-
257
- @substitutions.setter
258
- def substitutions(self, value: Any) -> None:
259
- """Accept a list of (name, value) tuples or a dict and populate the internal dict."""
260
- if isinstance(value, dict):
261
- self._subs_dict = dict(value)
262
- else:
263
- self._subs_dict = dict(value)
264
- self._rebuild_all_patterns()
265
-
266
- def _compile_patterns_for(self, varname: str) -> tuple:
267
- """Compile and return the three regex patterns (plain, single-quoted, double-quoted) for *varname*."""
268
- match_escaped = "\\" + varname if varname[0] == "$" else varname
269
- pat = re.compile(f"!!{match_escaped}!!", re.I)
270
- patq = re.compile(f"!'!{match_escaped}!'!", re.I)
271
- patdq = re.compile(f'!"!{match_escaped}!"!', re.I)
272
- return (pat, patq, patdq)
273
-
274
- def _rebuild_all_patterns(self) -> None:
275
- """Rebuild compiled patterns for every variable currently stored."""
276
- self._compiled_patterns = {}
277
- for varname in self._subs_dict:
278
- self._compiled_patterns[varname] = self._compile_patterns_for(varname)
279
-
280
- def compile_var_rx(self) -> None:
281
- self.var_rx_str = r"^[" + "".join(self.prefix_list) + r"]?\w+$"
282
- self.var_rx = re.compile(self.var_rx_str, re.I)
283
-
284
- def var_name_ok(self, varname: str) -> bool:
285
- if self.var_rx is None:
286
- self.compile_var_rx()
287
- return self.var_rx.match(varname) is not None
288
-
289
- def check_var_name(self, varname: str) -> None:
290
- if not self.var_name_ok(varname.lower()):
291
- raise ErrInfo("error", other_msg=f"Invalid variable name ({varname}) in this context.")
292
-
293
- def remove_substitution(self, template_str: str) -> None:
294
- self.check_var_name(template_str)
295
- old_sub = template_str.lower()
296
- self._subs_dict.pop(old_sub, None)
297
- self._compiled_patterns.pop(old_sub, None)
298
-
299
- def add_substitution(self, varname: str, repl_str: Any) -> None:
300
- self.check_var_name(varname)
301
- varname = varname.lower()
302
- self._subs_dict[varname] = repl_str
303
- self._compiled_patterns[varname] = self._compile_patterns_for(varname)
304
-
305
- def append_substitution(self, varname: str, repl_str: str) -> None:
306
- self.check_var_name(varname)
307
- varname = varname.lower()
308
- if varname in self._subs_dict:
309
- self.add_substitution(varname, f"{self._subs_dict[varname]}\n{repl_str}")
310
- else:
311
- self.add_substitution(varname, repl_str)
312
-
313
- def varvalue(self, varname: str) -> str | None:
314
- self.check_var_name(varname)
315
- return self._subs_dict.get(varname.lower())
316
-
317
- def increment_by(self, varname: str, numeric_increment: Any) -> None:
318
- self.check_var_name(varname)
319
- varvalue = self.varvalue(varname)
320
- if varvalue is None:
321
- varvalue = "0"
322
- self.add_substitution(varname, varvalue)
323
- # Import as_numeric lazily to avoid circular dependency
324
- from execsql.utils.numeric import as_numeric
325
-
326
- numvalue = as_numeric(varvalue)
327
- numinc = as_numeric(numeric_increment)
328
- if numvalue is None or numinc is None:
329
- newval = f"{varvalue}+{numeric_increment}"
330
- else:
331
- newval = str(numvalue + numinc)
332
- self.add_substitution(varname, newval)
333
-
334
- def sub_exists(self, template_str: str) -> bool:
335
- self.check_var_name(template_str)
336
- return template_str.lower() in self._subs_dict
337
-
338
- def merge(self, other_subvars: SubVarSet | None) -> SubVarSet:
339
- # Return a new SubVarSet with this object's variables merged with other_subvars.
340
- if other_subvars is not None:
341
- newsubs = SubVarSet()
342
- newsubs._subs_dict = dict(self._subs_dict)
343
- newsubs._compiled_patterns = dict(self._compiled_patterns)
344
- newsubs.prefix_list = list(set(self.prefix_list + other_subvars.prefix_list))
345
- newsubs.compile_var_rx()
346
- for varname, value in other_subvars._subs_dict.items():
347
- newsubs.add_substitution(varname, value)
348
- return newsubs
349
- return self
350
-
351
- def substitute(self, command_str: str) -> tuple:
352
- # Replace any substitution variables in the command string.
353
- if isinstance(command_str, str):
354
- for varname, sub in self._subs_dict.items():
355
- if sub is None:
356
- sub = ""
357
- sub = str(sub)
358
- if os.name != "posix":
359
- sub = sub.replace("\\", "\\\\")
360
- pat_rx, patq_rx, patdq_rx = self._compiled_patterns[varname]
361
- if pat_rx.search(command_str):
362
- return pat_rx.sub(sub, command_str), True
363
- if patq_rx.search(command_str):
364
- sub = sub.replace("'", "''")
365
- return patq_rx.sub(sub, command_str), True
366
- if patdq_rx.search(command_str):
367
- sub = '"' + sub + '"'
368
- return patdq_rx.sub(sub, command_str), True
369
- return command_str, False
370
-
371
- def substitute_all(self, any_text: str) -> tuple:
372
- subbed = True
373
- any_subbed = False
374
- while subbed:
375
- any_text, subbed = self.substitute(any_text)
376
- if subbed:
377
- any_subbed = True
378
- return any_text, any_subbed
379
-
380
-
381
- class LocalSubVarSet(SubVarSet):
382
- # A pool of local substitution variables.
383
- # Only '~' is allowed as a prefix and MUST be present.
384
- def __init__(self) -> None:
385
- SubVarSet.__init__(self)
386
- self.prefix_list = ["~"]
387
-
388
- def compile_var_rx(self) -> None:
389
- # Prefix is required, not optional.
390
- self.var_rx_str = r"^[" + "".join(self.prefix_list) + r"]\w+$"
391
- self.var_rx = re.compile(self.var_rx_str, re.I)
392
-
393
-
394
- class ScriptArgSubVarSet(SubVarSet):
395
- # A pool of script argument names.
396
- # Only '#' is allowed as a prefix and MUST be present.
397
- def __init__(self) -> None:
398
- SubVarSet.__init__(self)
399
- self.prefix_list = ["#"]
400
-
401
- def compile_var_rx(self) -> None:
402
- # Prefix is required, not optional.
403
- self.var_rx_str = r"^[" + "".join(self.prefix_list) + r"]\w+$"
404
- self.var_rx = re.compile(self.var_rx_str, re.I)
405
-
406
-
407
44
  # ---------------------------------------------------------------------------
408
45
  # MetaCommand / MetaCommandList
409
46
  # ---------------------------------------------------------------------------
410
47
 
411
48
 
412
49
  class MetaCommand:
50
+ """A single entry in the metacommand dispatch table.
51
+
52
+ Holds a compiled regex, a handler function, and execution-control flags.
53
+ Call :meth:`run` with a raw command string to attempt a match and invoke
54
+ the handler.
55
+ """
56
+
413
57
  # A compiled metacommand that can be run if it matches a metacommand command string.
414
58
  def __init__(
415
59
  self,
@@ -419,6 +63,7 @@ class MetaCommand:
419
63
  run_in_batch: bool = False,
420
64
  run_when_false: bool = False,
421
65
  set_error_flag: bool = True,
66
+ category: str | None = None,
422
67
  ) -> None:
423
68
  self.rx = rx
424
69
  self.exec_fn = exec_func
@@ -426,6 +71,7 @@ class MetaCommand:
426
71
  self.run_in_batch = run_in_batch
427
72
  self.run_when_false = run_when_false
428
73
  self.set_error_flag = set_error_flag
74
+ self.category = category
429
75
  self.hitcount = 0
430
76
 
431
77
  def __repr__(self) -> str:
@@ -435,6 +81,10 @@ class MetaCommand:
435
81
  )
436
82
 
437
83
  def run(self, cmd_str: str) -> tuple:
84
+ """Match *cmd_str* against this entry's regex and, if it matches, invoke the handler.
85
+
86
+ Returns ``(True, return_value)`` on a match, ``(False, None)`` otherwise.
87
+ """
438
88
  # Runs the metacommand if the command string matches the regex.
439
89
  m = self.rx.match(cmd_str.strip())
440
90
  if m:
@@ -466,20 +116,49 @@ class MetaCommand:
466
116
 
467
117
 
468
118
  class MetaCommandList:
469
- """Ordered list of :class:`MetaCommand` entries.
119
+ """Ordered list of :class:`MetaCommand` entries with keyword-indexed dispatch.
470
120
 
471
121
  Commands are stored with the most-recently-added entry first, matching
472
- the original linked-list prepend semantics. ``eval()`` and ``get_match()``
473
- scan the list linearly; the move-to-front heuristic from the original
474
- implementation has been removed for predictable, stable ordering.
122
+ the original linked-list prepend semantics. A keyword index
123
+ (``_by_keyword``) groups entries by their leading keyword so that
124
+ ``eval()`` and ``get_match()`` test only the small subset of regexes
125
+ that could possibly match, reducing dispatch from O(N) to O(K) where
126
+ K is the number of patterns sharing the same leading keyword (typically
127
+ 1–5 vs. 205 total).
475
128
  """
476
129
 
130
+ # Regex to extract the leading keyword from a metacommand regex pattern.
131
+ # Handles ^\s*KEYWORD, ^KEYWORD, and ^\s*(?:PREFIX\s+)?KEYWORD.
132
+ _KEYWORD_RX = re.compile(
133
+ r"^\^"
134
+ r"(?:\\s\*)?(?:\(\?:[^)]+\))?(?:\\s\+)?"
135
+ r"(?:\\s\*)?"
136
+ r"([A-Z_]+)",
137
+ )
138
+
477
139
  def __init__(self) -> None:
478
140
  self._commands: list[MetaCommand] = []
141
+ self._by_keyword: dict[str, list[MetaCommand]] = {}
142
+ self._unkeyed: list[MetaCommand] = []
479
143
 
480
144
  def __iter__(self) -> Any:
481
145
  return iter(self._commands)
482
146
 
147
+ @staticmethod
148
+ def _extract_keyword(cmd_str: str) -> str | None:
149
+ """Extract the leading keyword from a metacommand string."""
150
+ word = cmd_str.strip().split(None, 1)
151
+ return word[0].upper() if word else None
152
+
153
+ def _index_command(self, mc: MetaCommand, rx_pattern: str) -> None:
154
+ """Add *mc* to the keyword index based on its regex pattern."""
155
+ m = self._KEYWORD_RX.match(rx_pattern)
156
+ if m:
157
+ kw = m.group(1)
158
+ self._by_keyword.setdefault(kw, []).insert(0, mc)
159
+ else:
160
+ self._unkeyed.insert(0, mc)
161
+
483
162
  def add(
484
163
  self,
485
164
  matching_regexes: Any,
@@ -488,17 +167,57 @@ class MetaCommandList:
488
167
  run_in_batch: bool = False,
489
168
  run_when_false: bool = False,
490
169
  set_error_flag: bool = True,
170
+ category: str | None = None,
491
171
  ) -> None:
172
+ """Register one or more regex patterns as a new :class:`MetaCommand` entry.
173
+
174
+ *matching_regexes* may be a single pattern string or a list/tuple of
175
+ patterns; each compiles into a separate :class:`MetaCommand` prepended to
176
+ the dispatch list so that later registrations take priority.
177
+ """
492
178
  if type(matching_regexes) in (tuple, list):
493
- regexes = [re.compile(rx, re.I) for rx in tuple(matching_regexes)]
179
+ raw_patterns = list(matching_regexes)
180
+ regexes = [re.compile(rx, re.I) for rx in raw_patterns]
494
181
  else:
182
+ raw_patterns = [matching_regexes]
495
183
  regexes = [re.compile(matching_regexes, re.I)]
496
- for rx in regexes:
497
- # Prepend to preserve "last registered, first checked" ordering.
498
- self._commands.insert(
499
- 0,
500
- MetaCommand(rx, exec_func, description, run_in_batch, run_when_false, set_error_flag),
184
+ for rx, raw in zip(regexes, raw_patterns):
185
+ mc = MetaCommand(
186
+ rx,
187
+ exec_func,
188
+ description,
189
+ run_in_batch,
190
+ run_when_false,
191
+ set_error_flag,
192
+ category,
501
193
  )
194
+ # Prepend to preserve "last registered, first checked" ordering.
195
+ self._commands.insert(0, mc)
196
+ self._index_command(mc, raw)
197
+
198
+ def _candidates(self, cmd_str: str) -> list[MetaCommand]:
199
+ """Return the subset of commands whose keyword matches *cmd_str*.
200
+
201
+ Falls back to the full command list if no keyword match is found.
202
+ """
203
+ kw = self._extract_keyword(cmd_str)
204
+ if kw and kw in self._by_keyword:
205
+ # Keyword-matched entries plus any unkeyed entries that could match anything.
206
+ return self._by_keyword[kw] + self._unkeyed
207
+ return self._commands
208
+
209
+ def keywords_by_category(self) -> dict[str, list[str]]:
210
+ """Return ``{category: [keyword, ...]}`` from entries that have both.
211
+
212
+ Used by ``--dump-keywords`` to introspect the dispatch table.
213
+ """
214
+ result: dict[str, list[str]] = {}
215
+ for mc in self._commands:
216
+ if mc.category and mc.description:
217
+ kw_list = result.setdefault(mc.category, [])
218
+ if mc.description not in kw_list:
219
+ kw_list.append(mc.description)
220
+ return result
502
221
 
503
222
  def eval(self, cmd_str: str) -> tuple:
504
223
  """Evaluate *cmd_str* against the registered metacommands.
@@ -506,7 +225,7 @@ class MetaCommandList:
506
225
  Returns ``(True, return_value)`` if a matching command was found and
507
226
  run, ``(False, None)`` if no command matched.
508
227
  """
509
- for cmd in self._commands:
228
+ for cmd in self._candidates(cmd_str):
510
229
  if _state.if_stack.all_true() or cmd.run_when_false:
511
230
  success, value = cmd.run(cmd_str)
512
231
  if success:
@@ -517,8 +236,9 @@ class MetaCommandList:
517
236
  """Return ``(MetaCommand, re.Match)`` for the first entry matching *cmd*,
518
237
  or ``None`` if no entry matches.
519
238
  """
520
- for node in self._commands:
521
- m = node.rx.match(cmd.strip())
239
+ stripped = cmd.strip()
240
+ for node in self._candidates(stripped):
241
+ m = node.rx.match(stripped)
522
242
  if m is not None:
523
243
  return (node, m)
524
244
  return None
@@ -530,6 +250,8 @@ class MetaCommandList:
530
250
 
531
251
 
532
252
  class SqlStmt:
253
+ """A single SQL statement ready to be executed against the active database."""
254
+
533
255
  # A SQL statement to be passed to a database to execute.
534
256
  def __init__(self, sql_statement: str) -> None:
535
257
  self.statement = re.sub(r"\s*;(\s*;\s*)+$", ";", sql_statement)
@@ -538,6 +260,7 @@ class SqlStmt:
538
260
  return f"SqlStmt({self.statement})"
539
261
 
540
262
  def run(self, localvars: SubVarSet | None = None, commit: bool = True) -> None:
263
+ """Execute the statement on the current database, committing unless in a batch."""
541
264
  # Run the SQL statement on the current database.
542
265
  if _state.if_stack.all_true():
543
266
  e = None
@@ -549,6 +272,10 @@ class SqlStmt:
549
272
  )
550
273
  try:
551
274
  db = _state.dbs.current()
275
+ if _state.conf.log_sql and _state.exec_log:
276
+ lno = getattr(_state, "last_command", None)
277
+ lno = lno.line_no if lno and hasattr(lno, "line_no") else None
278
+ _state.exec_log.log_sql_query(cmd, db.name(), lno)
552
279
  db.execute(cmd)
553
280
  if commit:
554
281
  db.commit()
@@ -569,10 +296,13 @@ class SqlStmt:
569
296
  _state.subvars.add_substitution("$LAST_SQL", cmd)
570
297
 
571
298
  def commandline(self) -> str:
299
+ """Return the raw SQL statement text."""
572
300
  return self.statement
573
301
 
574
302
 
575
303
  class MetacommandStmt:
304
+ """A single execsql metacommand line ready to be dispatched."""
305
+
576
306
  # A metacommand to be handled by execsql.
577
307
  def __init__(self, metacommand_statement: str) -> None:
578
308
  self.statement = metacommand_statement
@@ -581,6 +311,7 @@ class MetacommandStmt:
581
311
  return f"MetacommandStmt({self.statement})"
582
312
 
583
313
  def run(self, localvars: SubVarSet | None = None, commit: bool = False) -> Any:
314
+ """Expand substitution variables then dispatch through the metacommand table."""
584
315
  # Tries all metacommands in the dispatch table until one runs.
585
316
  errmsg = "Unknown metacommand"
586
317
  cmd = substitute_vars(self.statement, localvars)
@@ -609,6 +340,7 @@ class MetacommandStmt:
609
340
  return None
610
341
 
611
342
  def commandline(self) -> str:
343
+ """Return the metacommand line in its canonical ``-- !x! ...`` form."""
612
344
  return "-- !x! " + self.statement
613
345
 
614
346
 
@@ -618,6 +350,8 @@ class MetacommandStmt:
618
350
 
619
351
 
620
352
  class ScriptCmd:
353
+ """A parsed script item: either a :class:`SqlStmt` or a :class:`MetacommandStmt`, with source location."""
354
+
621
355
  # A SQL script object that is either a SQL statement or a metacommand.
622
356
  def __init__(
623
357
  self,
@@ -647,6 +381,12 @@ class ScriptCmd:
647
381
 
648
382
 
649
383
  class CommandList:
384
+ """Ordered sequence of :class:`ScriptCmd` objects with a forward-only execution cursor.
385
+
386
+ Push onto ``_state.commandliststack`` and call :meth:`run_next` in a loop
387
+ (or let :func:`runscripts` drive it) to execute each command in turn.
388
+ """
389
+
650
390
  # A list of ScriptCmd objects including execution state.
651
391
  def __init__(
652
392
  self,
@@ -665,6 +405,7 @@ class CommandList:
665
405
  self.init_if_level: int | None = None
666
406
 
667
407
  def add(self, script_command: ScriptCmd) -> None:
408
+ """Append *script_command* to the end of this command list."""
668
409
  self.cmdlist.append(script_command)
669
410
 
670
411
  def set_paramvals(self, paramvals: SubVarSet) -> None:
@@ -678,11 +419,13 @@ class CommandList:
678
419
  )
679
420
 
680
421
  def current_command(self) -> ScriptCmd | None:
422
+ """Return the :class:`ScriptCmd` at the current cursor position, or ``None`` if exhausted."""
681
423
  if self.cmdptr > len(self.cmdlist) - 1:
682
424
  return None
683
425
  return self.cmdlist[self.cmdptr]
684
426
 
685
427
  def check_iflevels(self) -> None:
428
+ """Warn if the IF-stack depth changed during execution of this command list."""
686
429
  if_excess = len(_state.if_stack.if_levels) - self.init_if_level
687
430
  if if_excess > 0:
688
431
  sources = _state.if_stack.script_lines(if_excess)
@@ -731,6 +474,7 @@ class CommandList:
731
474
  self.cmdptr += 1
732
475
 
733
476
  def run_next(self) -> None:
477
+ """Execute the command at the current cursor and advance; raise StopIteration when done."""
734
478
  if self.cmdptr == 0:
735
479
  self.init_if_level = len(_state.if_stack.if_levels)
736
480
  if self.cmdptr > len(self.cmdlist) - 1:
@@ -750,6 +494,8 @@ class CommandList:
750
494
 
751
495
 
752
496
  class CommandListWhileLoop(CommandList):
497
+ """A :class:`CommandList` that repeats its commands while a condition evaluates to true."""
498
+
753
499
  # Subclass of CommandList that loops WHILE a condition is met.
754
500
  def __init__(
755
501
  self,
@@ -776,6 +522,8 @@ class CommandListWhileLoop(CommandList):
776
522
 
777
523
 
778
524
  class CommandListUntilLoop(CommandList):
525
+ """A :class:`CommandList` that repeats its commands until a condition evaluates to true."""
526
+
779
527
  # Subclass of CommandList that loops UNTIL a condition is met.
780
528
  def __init__(
781
529
  self,
@@ -807,6 +555,13 @@ class CommandListUntilLoop(CommandList):
807
555
 
808
556
 
809
557
  class ScriptFile(EncodedFile):
558
+ """An iterable file reader that tracks the current line number.
559
+
560
+ Wraps :class:`~execsql.utils.fileio.EncodedFile` and increments
561
+ :attr:`lno` on each ``next()`` call so that callers always know which
562
+ source line is being processed.
563
+ """
564
+
810
565
  # A file reader that returns lines and records the line number.
811
566
  def __init__(self, scriptfname: str, file_encoding: str) -> None:
812
567
  super().__init__(scriptfname, file_encoding)
@@ -831,6 +586,13 @@ class ScriptFile(EncodedFile):
831
586
 
832
587
 
833
588
  class ScriptExecSpec:
589
+ """Deferred execution specification for a named SCRIPT block.
590
+
591
+ Parses argument expressions and loop-type flags at construction time;
592
+ call :meth:`execute` to push the resolved :class:`CommandList` onto the
593
+ execution stack.
594
+ """
595
+
834
596
  # Stores specifications for executing a SCRIPT, for later use.
835
597
  args_rx = re.compile(
836
598
  r'(?P<param>#?\w+)\s*=\s*(?P<arg>(?:(?:[^"\'\[][^,\)]*)|(?:"[^"]*")|(?:\'[^\']*\')|(?:\[[^\]]*\])))',
@@ -880,6 +642,7 @@ class ScriptExecSpec:
880
642
 
881
643
 
882
644
  def set_system_vars() -> None:
645
+ """Refresh all built-in system substitution variables (``$CURRENT_TIME``, ``$DB_NAME``, etc.)."""
883
646
  # (Re)define the system substitution variables that are not script-specific.
884
647
  _state.subvars.add_substitution("$CANCEL_HALT_STATE", "ON" if _state.status.cancel_halt else "OFF")
885
648
  _state.subvars.add_substitution("$ERROR_HALT_STATE", "ON" if _state.status.halt_on_err else "OFF")
@@ -915,7 +678,11 @@ def set_system_vars() -> None:
915
678
  _state.subvars.add_substitution("$VERSION3", str(_state.tertiary_vno))
916
679
 
917
680
 
681
+ _MAX_SUBSTITUTION_DEPTH = 100
682
+
683
+
918
684
  def substitute_vars(command_str: str, localvars: SubVarSet | None = None) -> str:
685
+ """Expand all ``!!$VAR!!`` tokens in *command_str*, merging *localvars* when provided."""
919
686
  # Substitutes global variables, global counters, and local variables.
920
687
  if localvars is not None:
921
688
  subs = _state.subvars.merge(localvars)
@@ -923,11 +690,21 @@ def substitute_vars(command_str: str, localvars: SubVarSet | None = None) -> str
923
690
  subs = _state.subvars
924
691
  cmdstr = copy.copy(command_str)
925
692
  subs_made = True
693
+ iterations = 0
926
694
  while subs_made:
927
695
  subs_made = False
928
696
  cmdstr, subs_made = subs.substitute_all(cmdstr)
929
697
  cmdstr, any_subbed = _state.counters.substitute_all(cmdstr)
930
698
  subs_made = subs_made or any_subbed
699
+ iterations += 1
700
+ if iterations >= _MAX_SUBSTITUTION_DEPTH:
701
+ raise ErrInfo(
702
+ type="error",
703
+ other_msg=(
704
+ f"Substitution variable cycle detected: exceeded {_MAX_SUBSTITUTION_DEPTH} "
705
+ f"iterations while expanding variables in: {command_str[:200]}"
706
+ ),
707
+ )
931
708
  m = _state.defer_rx.findall(cmdstr)
932
709
  # Substitute any deferred substitution variables with regular substitution var flags.
933
710
  if m is not None:
@@ -938,7 +715,7 @@ def substitute_vars(command_str: str, localvars: SubVarSet | None = None) -> str
938
715
 
939
716
 
940
717
  def runscripts() -> None:
941
-
718
+ """Drive execution until the command-list stack is empty."""
942
719
  # Repeatedly run the next statement from the script at the top of the
943
720
  # command list stack until there are no more statements.
944
721
  while len(_state.commandliststack) > 0:
@@ -952,12 +729,13 @@ def runscripts() -> None:
952
729
  raise
953
730
  except ErrInfo:
954
731
  raise
955
- except Exception:
956
- raise ErrInfo(type="exception", exception_msg=exception_desc())
732
+ except Exception as e:
733
+ raise ErrInfo(type="exception", exception_msg=exception_desc()) from e
957
734
  _state.cmds_run += 1
958
735
 
959
736
 
960
737
  def current_script_line() -> tuple:
738
+ """Return ``(source_name, line_number)`` for the command currently executing."""
961
739
  if len(_state.commandliststack) > 0:
962
740
  current_cmds = _state.commandliststack[-1]
963
741
  if current_cmds.current_command() is not None:
@@ -968,7 +746,7 @@ def current_script_line() -> tuple:
968
746
  return ("", 0)
969
747
 
970
748
 
971
- def _parse_script_lines(lines_iter, source_name: str) -> list:
749
+ def _parse_script_lines(lines_iter: Any, source_name: str) -> list:
972
750
  # Parse an iterable of lines into a list of ScriptCmd objects.
973
751
  from execsql.utils.errors import write_warning
974
752
 
@@ -1120,14 +898,18 @@ def _parse_script_lines(lines_iter, source_name: str) -> list:
1120
898
 
1121
899
 
1122
900
  def read_sqlfile(sql_file_name: str) -> None:
901
+ """Parse a ``.sql`` file and push the resulting :class:`CommandList` onto the execution stack."""
1123
902
  # Read lines from the given script file, create a list of ScriptCmd objects,
1124
903
  # and append the list to the top of the stack of script commands.
1125
904
  from execsql.utils.errors import file_size_date
1126
905
 
1127
906
  sz, dt = file_size_date(sql_file_name)
1128
907
  _state.exec_log.log_status_info(f"Reading script file {sql_file_name} (size: {sz}; date: {dt})")
1129
- scriptfile_obj = ScriptFile(sql_file_name, _state.conf.script_encoding).open("r")
1130
- sqllist = _parse_script_lines(scriptfile_obj, sql_file_name)
908
+ scriptfile_obj = ScriptFile(sql_file_name, _state.conf.script_encoding)
909
+ try:
910
+ sqllist = _parse_script_lines(scriptfile_obj, sql_file_name)
911
+ finally:
912
+ scriptfile_obj.close()
1131
913
  if sqllist:
1132
914
  _state.commandliststack.append(CommandList(sqllist, Path(sql_file_name).name))
1133
915