execsql2 2.2.1__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 (78) hide show
  1. execsql/config.py +52 -0
  2. execsql/db/access.py +11 -3
  3. execsql/db/base.py +180 -135
  4. execsql/db/dsn.py +4 -0
  5. execsql/db/duckdb.py +4 -0
  6. execsql/db/factory.py +21 -0
  7. execsql/db/firebird.py +4 -0
  8. execsql/db/mysql.py +4 -0
  9. execsql/db/oracle.py +4 -0
  10. execsql/db/postgres.py +3 -0
  11. execsql/db/sqlite.py +3 -0
  12. execsql/db/sqlserver.py +11 -2
  13. execsql/exceptions.py +18 -0
  14. execsql/exporters/base.py +6 -0
  15. execsql/exporters/delimited.py +36 -0
  16. execsql/exporters/duckdb.py +4 -0
  17. execsql/exporters/feather.py +4 -0
  18. execsql/exporters/html.py +6 -0
  19. execsql/exporters/json.py +5 -6
  20. execsql/exporters/latex.py +4 -0
  21. execsql/exporters/ods.py +28 -7
  22. execsql/exporters/parquet.py +3 -0
  23. execsql/exporters/pretty.py +5 -0
  24. execsql/exporters/raw.py +5 -3
  25. execsql/exporters/sqlite.py +4 -0
  26. execsql/exporters/templates.py +16 -6
  27. execsql/exporters/values.py +4 -0
  28. execsql/exporters/xls.py +26 -7
  29. execsql/exporters/xml.py +3 -0
  30. execsql/exporters/zip.py +15 -0
  31. execsql/importers/base.py +2 -0
  32. execsql/importers/csv.py +2 -0
  33. execsql/importers/feather.py +2 -0
  34. execsql/importers/ods.py +2 -0
  35. execsql/importers/xls.py +2 -0
  36. execsql/metacommands/__init__.py +177 -1968
  37. execsql/metacommands/dispatch.py +2011 -0
  38. execsql/models.py +7 -0
  39. execsql/parser.py +10 -0
  40. execsql/script/__init__.py +95 -0
  41. execsql/script/control.py +162 -0
  42. execsql/{script.py → script/engine.py} +144 -406
  43. execsql/script/variables.py +281 -0
  44. execsql/types.py +29 -0
  45. execsql/utils/auth.py +2 -0
  46. execsql/utils/crypto.py +4 -6
  47. execsql/utils/datetime.py +1 -0
  48. execsql/utils/errors.py +11 -0
  49. execsql/utils/fileio.py +18 -0
  50. execsql/utils/gui.py +46 -0
  51. execsql/utils/mail.py +7 -17
  52. execsql/utils/numeric.py +2 -0
  53. execsql/utils/regex.py +9 -0
  54. execsql/utils/strings.py +16 -0
  55. execsql/utils/timer.py +2 -0
  56. execsql2-2.4.0.data/data/execsql2_extras/README.md +65 -0
  57. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/execsql.conf +1 -1
  58. {execsql2-2.2.1.dist-info → execsql2-2.4.0.dist-info}/METADATA +8 -1
  59. execsql2-2.4.0.dist-info/RECORD +108 -0
  60. execsql2-2.2.1.data/data/execsql2_extras/READ_ME.rst +0 -127
  61. execsql2-2.2.1.dist-info/RECORD +0 -104
  62. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  63. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  64. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  65. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  66. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  67. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  68. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  69. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  70. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  71. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/script_template.sql +0 -0
  72. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  73. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  74. {execsql2-2.2.1.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  75. {execsql2-2.2.1.dist-info → execsql2-2.4.0.dist-info}/WHEEL +0 -0
  76. {execsql2-2.2.1.dist-info → execsql2-2.4.0.dist-info}/entry_points.txt +0 -0
  77. {execsql2-2.2.1.dist-info → execsql2-2.4.0.dist-info}/licenses/LICENSE.txt +0 -0
  78. {execsql2-2.2.1.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,
@@ -437,6 +81,10 @@ class MetaCommand:
437
81
  )
438
82
 
439
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
+ """
440
88
  # Runs the metacommand if the command string matches the regex.
441
89
  m = self.rx.match(cmd_str.strip())
442
90
  if m:
@@ -468,20 +116,49 @@ class MetaCommand:
468
116
 
469
117
 
470
118
  class MetaCommandList:
471
- """Ordered list of :class:`MetaCommand` entries.
119
+ """Ordered list of :class:`MetaCommand` entries with keyword-indexed dispatch.
472
120
 
473
121
  Commands are stored with the most-recently-added entry first, matching
474
- the original linked-list prepend semantics. ``eval()`` and ``get_match()``
475
- scan the list linearly; the move-to-front heuristic from the original
476
- 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).
477
128
  """
478
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
+
479
139
  def __init__(self) -> None:
480
140
  self._commands: list[MetaCommand] = []
141
+ self._by_keyword: dict[str, list[MetaCommand]] = {}
142
+ self._unkeyed: list[MetaCommand] = []
481
143
 
482
144
  def __iter__(self) -> Any:
483
145
  return iter(self._commands)
484
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
+
485
162
  def add(
486
163
  self,
487
164
  matching_regexes: Any,
@@ -492,24 +169,42 @@ class MetaCommandList:
492
169
  set_error_flag: bool = True,
493
170
  category: str | None = None,
494
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
+ """
495
178
  if type(matching_regexes) in (tuple, list):
496
- 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]
497
181
  else:
182
+ raw_patterns = [matching_regexes]
498
183
  regexes = [re.compile(matching_regexes, re.I)]
499
- for rx in regexes:
500
- # Prepend to preserve "last registered, first checked" ordering.
501
- self._commands.insert(
502
- 0,
503
- MetaCommand(
504
- rx,
505
- exec_func,
506
- description,
507
- run_in_batch,
508
- run_when_false,
509
- set_error_flag,
510
- category,
511
- ),
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,
512
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
513
208
 
514
209
  def keywords_by_category(self) -> dict[str, list[str]]:
515
210
  """Return ``{category: [keyword, ...]}`` from entries that have both.
@@ -530,7 +225,7 @@ class MetaCommandList:
530
225
  Returns ``(True, return_value)`` if a matching command was found and
531
226
  run, ``(False, None)`` if no command matched.
532
227
  """
533
- for cmd in self._commands:
228
+ for cmd in self._candidates(cmd_str):
534
229
  if _state.if_stack.all_true() or cmd.run_when_false:
535
230
  success, value = cmd.run(cmd_str)
536
231
  if success:
@@ -541,8 +236,9 @@ class MetaCommandList:
541
236
  """Return ``(MetaCommand, re.Match)`` for the first entry matching *cmd*,
542
237
  or ``None`` if no entry matches.
543
238
  """
544
- for node in self._commands:
545
- m = node.rx.match(cmd.strip())
239
+ stripped = cmd.strip()
240
+ for node in self._candidates(stripped):
241
+ m = node.rx.match(stripped)
546
242
  if m is not None:
547
243
  return (node, m)
548
244
  return None
@@ -554,6 +250,8 @@ class MetaCommandList:
554
250
 
555
251
 
556
252
  class SqlStmt:
253
+ """A single SQL statement ready to be executed against the active database."""
254
+
557
255
  # A SQL statement to be passed to a database to execute.
558
256
  def __init__(self, sql_statement: str) -> None:
559
257
  self.statement = re.sub(r"\s*;(\s*;\s*)+$", ";", sql_statement)
@@ -562,6 +260,7 @@ class SqlStmt:
562
260
  return f"SqlStmt({self.statement})"
563
261
 
564
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."""
565
264
  # Run the SQL statement on the current database.
566
265
  if _state.if_stack.all_true():
567
266
  e = None
@@ -597,10 +296,13 @@ class SqlStmt:
597
296
  _state.subvars.add_substitution("$LAST_SQL", cmd)
598
297
 
599
298
  def commandline(self) -> str:
299
+ """Return the raw SQL statement text."""
600
300
  return self.statement
601
301
 
602
302
 
603
303
  class MetacommandStmt:
304
+ """A single execsql metacommand line ready to be dispatched."""
305
+
604
306
  # A metacommand to be handled by execsql.
605
307
  def __init__(self, metacommand_statement: str) -> None:
606
308
  self.statement = metacommand_statement
@@ -609,6 +311,7 @@ class MetacommandStmt:
609
311
  return f"MetacommandStmt({self.statement})"
610
312
 
611
313
  def run(self, localvars: SubVarSet | None = None, commit: bool = False) -> Any:
314
+ """Expand substitution variables then dispatch through the metacommand table."""
612
315
  # Tries all metacommands in the dispatch table until one runs.
613
316
  errmsg = "Unknown metacommand"
614
317
  cmd = substitute_vars(self.statement, localvars)
@@ -637,6 +340,7 @@ class MetacommandStmt:
637
340
  return None
638
341
 
639
342
  def commandline(self) -> str:
343
+ """Return the metacommand line in its canonical ``-- !x! ...`` form."""
640
344
  return "-- !x! " + self.statement
641
345
 
642
346
 
@@ -646,6 +350,8 @@ class MetacommandStmt:
646
350
 
647
351
 
648
352
  class ScriptCmd:
353
+ """A parsed script item: either a :class:`SqlStmt` or a :class:`MetacommandStmt`, with source location."""
354
+
649
355
  # A SQL script object that is either a SQL statement or a metacommand.
650
356
  def __init__(
651
357
  self,
@@ -675,6 +381,12 @@ class ScriptCmd:
675
381
 
676
382
 
677
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
+
678
390
  # A list of ScriptCmd objects including execution state.
679
391
  def __init__(
680
392
  self,
@@ -693,6 +405,7 @@ class CommandList:
693
405
  self.init_if_level: int | None = None
694
406
 
695
407
  def add(self, script_command: ScriptCmd) -> None:
408
+ """Append *script_command* to the end of this command list."""
696
409
  self.cmdlist.append(script_command)
697
410
 
698
411
  def set_paramvals(self, paramvals: SubVarSet) -> None:
@@ -706,11 +419,13 @@ class CommandList:
706
419
  )
707
420
 
708
421
  def current_command(self) -> ScriptCmd | None:
422
+ """Return the :class:`ScriptCmd` at the current cursor position, or ``None`` if exhausted."""
709
423
  if self.cmdptr > len(self.cmdlist) - 1:
710
424
  return None
711
425
  return self.cmdlist[self.cmdptr]
712
426
 
713
427
  def check_iflevels(self) -> None:
428
+ """Warn if the IF-stack depth changed during execution of this command list."""
714
429
  if_excess = len(_state.if_stack.if_levels) - self.init_if_level
715
430
  if if_excess > 0:
716
431
  sources = _state.if_stack.script_lines(if_excess)
@@ -759,6 +474,7 @@ class CommandList:
759
474
  self.cmdptr += 1
760
475
 
761
476
  def run_next(self) -> None:
477
+ """Execute the command at the current cursor and advance; raise StopIteration when done."""
762
478
  if self.cmdptr == 0:
763
479
  self.init_if_level = len(_state.if_stack.if_levels)
764
480
  if self.cmdptr > len(self.cmdlist) - 1:
@@ -778,6 +494,8 @@ class CommandList:
778
494
 
779
495
 
780
496
  class CommandListWhileLoop(CommandList):
497
+ """A :class:`CommandList` that repeats its commands while a condition evaluates to true."""
498
+
781
499
  # Subclass of CommandList that loops WHILE a condition is met.
782
500
  def __init__(
783
501
  self,
@@ -804,6 +522,8 @@ class CommandListWhileLoop(CommandList):
804
522
 
805
523
 
806
524
  class CommandListUntilLoop(CommandList):
525
+ """A :class:`CommandList` that repeats its commands until a condition evaluates to true."""
526
+
807
527
  # Subclass of CommandList that loops UNTIL a condition is met.
808
528
  def __init__(
809
529
  self,
@@ -835,6 +555,13 @@ class CommandListUntilLoop(CommandList):
835
555
 
836
556
 
837
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
+
838
565
  # A file reader that returns lines and records the line number.
839
566
  def __init__(self, scriptfname: str, file_encoding: str) -> None:
840
567
  super().__init__(scriptfname, file_encoding)
@@ -859,6 +586,13 @@ class ScriptFile(EncodedFile):
859
586
 
860
587
 
861
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
+
862
596
  # Stores specifications for executing a SCRIPT, for later use.
863
597
  args_rx = re.compile(
864
598
  r'(?P<param>#?\w+)\s*=\s*(?P<arg>(?:(?:[^"\'\[][^,\)]*)|(?:"[^"]*")|(?:\'[^\']*\')|(?:\[[^\]]*\])))',
@@ -908,6 +642,7 @@ class ScriptExecSpec:
908
642
 
909
643
 
910
644
  def set_system_vars() -> None:
645
+ """Refresh all built-in system substitution variables (``$CURRENT_TIME``, ``$DB_NAME``, etc.)."""
911
646
  # (Re)define the system substitution variables that are not script-specific.
912
647
  _state.subvars.add_substitution("$CANCEL_HALT_STATE", "ON" if _state.status.cancel_halt else "OFF")
913
648
  _state.subvars.add_substitution("$ERROR_HALT_STATE", "ON" if _state.status.halt_on_err else "OFF")
@@ -947,6 +682,7 @@ _MAX_SUBSTITUTION_DEPTH = 100
947
682
 
948
683
 
949
684
  def substitute_vars(command_str: str, localvars: SubVarSet | None = None) -> str:
685
+ """Expand all ``!!$VAR!!`` tokens in *command_str*, merging *localvars* when provided."""
950
686
  # Substitutes global variables, global counters, and local variables.
951
687
  if localvars is not None:
952
688
  subs = _state.subvars.merge(localvars)
@@ -979,7 +715,7 @@ def substitute_vars(command_str: str, localvars: SubVarSet | None = None) -> str
979
715
 
980
716
 
981
717
  def runscripts() -> None:
982
-
718
+ """Drive execution until the command-list stack is empty."""
983
719
  # Repeatedly run the next statement from the script at the top of the
984
720
  # command list stack until there are no more statements.
985
721
  while len(_state.commandliststack) > 0:
@@ -999,6 +735,7 @@ def runscripts() -> None:
999
735
 
1000
736
 
1001
737
  def current_script_line() -> tuple:
738
+ """Return ``(source_name, line_number)`` for the command currently executing."""
1002
739
  if len(_state.commandliststack) > 0:
1003
740
  current_cmds = _state.commandliststack[-1]
1004
741
  if current_cmds.current_command() is not None:
@@ -1009,7 +746,7 @@ def current_script_line() -> tuple:
1009
746
  return ("", 0)
1010
747
 
1011
748
 
1012
- def _parse_script_lines(lines_iter, source_name: str) -> list:
749
+ def _parse_script_lines(lines_iter: Any, source_name: str) -> list:
1013
750
  # Parse an iterable of lines into a list of ScriptCmd objects.
1014
751
  from execsql.utils.errors import write_warning
1015
752
 
@@ -1161,6 +898,7 @@ def _parse_script_lines(lines_iter, source_name: str) -> list:
1161
898
 
1162
899
 
1163
900
  def read_sqlfile(sql_file_name: str) -> None:
901
+ """Parse a ``.sql`` file and push the resulting :class:`CommandList` onto the execution stack."""
1164
902
  # Read lines from the given script file, create a list of ScriptCmd objects,
1165
903
  # and append the list to the top of the stack of script commands.
1166
904
  from execsql.utils.errors import file_size_date