execsql2 2.0.1__py3-none-any.whl → 2.1.2__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 (90) hide show
  1. execsql/cli.py +322 -108
  2. execsql/config.py +134 -114
  3. execsql/db/access.py +89 -65
  4. execsql/db/base.py +97 -68
  5. execsql/db/dsn.py +45 -29
  6. execsql/db/duckdb.py +4 -5
  7. execsql/db/factory.py +27 -27
  8. execsql/db/firebird.py +30 -18
  9. execsql/db/mysql.py +38 -14
  10. execsql/db/oracle.py +58 -33
  11. execsql/db/postgres.py +68 -28
  12. execsql/db/sqlite.py +36 -27
  13. execsql/db/sqlserver.py +45 -30
  14. execsql/exceptions.py +68 -64
  15. execsql/exporters/__init__.py +1 -1
  16. execsql/exporters/base.py +42 -17
  17. execsql/exporters/delimited.py +60 -59
  18. execsql/exporters/duckdb.py +8 -12
  19. execsql/exporters/feather.py +32 -24
  20. execsql/exporters/html.py +33 -30
  21. execsql/exporters/json.py +18 -17
  22. execsql/exporters/latex.py +11 -13
  23. execsql/exporters/ods.py +50 -46
  24. execsql/exporters/parquet.py +32 -0
  25. execsql/exporters/pretty.py +16 -15
  26. execsql/exporters/raw.py +9 -11
  27. execsql/exporters/sqlite.py +38 -38
  28. execsql/exporters/templates.py +15 -72
  29. execsql/exporters/values.py +13 -12
  30. execsql/exporters/xls.py +26 -26
  31. execsql/exporters/xml.py +12 -12
  32. execsql/exporters/zip.py +0 -3
  33. execsql/gui/__init__.py +2 -2
  34. execsql/gui/console.py +0 -1
  35. execsql/gui/desktop.py +6 -7
  36. execsql/gui/tui.py +8 -14
  37. execsql/importers/base.py +6 -9
  38. execsql/importers/csv.py +10 -17
  39. execsql/importers/feather.py +16 -22
  40. execsql/importers/ods.py +3 -4
  41. execsql/importers/xls.py +5 -6
  42. execsql/metacommands/__init__.py +8 -8
  43. execsql/metacommands/conditions.py +41 -33
  44. execsql/metacommands/connect.py +113 -99
  45. execsql/metacommands/control.py +38 -26
  46. execsql/metacommands/data.py +35 -33
  47. execsql/metacommands/debug.py +13 -9
  48. execsql/metacommands/io.py +288 -229
  49. execsql/metacommands/prompt.py +179 -157
  50. execsql/metacommands/script_ext.py +11 -9
  51. execsql/metacommands/system.py +44 -25
  52. execsql/models.py +9 -16
  53. execsql/parser.py +10 -10
  54. execsql/script.py +183 -157
  55. execsql/state.py +170 -208
  56. execsql/types.py +46 -81
  57. execsql/utils/auth.py +114 -14
  58. execsql/utils/crypto.py +31 -4
  59. execsql/utils/datetime.py +7 -7
  60. execsql/utils/errors.py +34 -29
  61. execsql/utils/fileio.py +90 -55
  62. execsql/utils/gui.py +22 -23
  63. execsql/utils/mail.py +15 -17
  64. execsql/utils/numeric.py +2 -3
  65. execsql/utils/regex.py +9 -12
  66. execsql/utils/strings.py +10 -12
  67. execsql/utils/timer.py +0 -2
  68. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/execsql.conf +1 -1
  69. execsql2-2.1.2.dist-info/METADATA +300 -0
  70. execsql2-2.1.2.dist-info/RECORD +96 -0
  71. execsql2-2.0.1.dist-info/METADATA +0 -406
  72. execsql2-2.0.1.dist-info/RECORD +0 -95
  73. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/READ_ME.rst +0 -0
  74. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  75. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  76. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
  77. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/md_compare.sql +0 -0
  78. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
  79. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
  80. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
  81. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  82. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  83. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/script_template.sql +0 -0
  84. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
  85. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  86. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  87. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/WHEEL +0 -0
  88. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/entry_points.txt +0 -0
  89. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/licenses/LICENSE.txt +0 -0
  90. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/licenses/NOTICE +0 -0
execsql/script.py CHANGED
@@ -49,14 +49,11 @@ Key functions:
49
49
 
50
50
  import copy
51
51
  import datetime
52
- import glob
53
- import io
54
52
  import os
55
- import os.path
56
53
  import re
57
- import sys
58
54
  import uuid
59
- from typing import Any, Dict, List, Optional
55
+ from pathlib import Path
56
+ from typing import Any
60
57
 
61
58
  import execsql.state as _state
62
59
  from execsql.exceptions import ErrInfo
@@ -73,10 +70,10 @@ class BatchLevels:
73
70
  # A stack to keep a record of the databases used in nested batches.
74
71
  class Batch:
75
72
  def __init__(self) -> None:
76
- self.dbs_used: List[Any] = []
73
+ self.dbs_used: list[Any] = []
77
74
 
78
75
  def __init__(self) -> None:
79
- self.batchlevels: List[BatchLevels.Batch] = []
76
+ self.batchlevels: list[BatchLevels.Batch] = []
80
77
 
81
78
  def in_batch(self) -> bool:
82
79
  return len(self.batchlevels) > 0
@@ -91,10 +88,7 @@ class BatchLevels:
91
88
  def uses_db(self, db: Any) -> bool:
92
89
  if len(self.batchlevels) == 0:
93
90
  return False
94
- for batch in self.batchlevels:
95
- if db in batch.dbs_used:
96
- return True
97
- return False
91
+ return any(db in batch.dbs_used for batch in self.batchlevels)
98
92
 
99
93
  def rollback_batch(self) -> None:
100
94
  if len(self.batchlevels) > 0:
@@ -136,7 +130,7 @@ class IfLevels:
136
130
  # A stack of True/False values corresponding to a nested set of conditionals,
137
131
  # with methods to manipulate and query the set of conditional states.
138
132
  def __init__(self) -> None:
139
- self.if_levels: List[IfItem] = []
133
+ self.if_levels: list[IfItem] = []
140
134
 
141
135
  def nest(self, tf_value: bool) -> None:
142
136
  self.if_levels.append(IfItem(tf_value))
@@ -168,7 +162,7 @@ class IfLevels:
168
162
  def all_true(self) -> bool:
169
163
  if self.if_levels == []:
170
164
  return True
171
- return all([tf.value() for tf in self.if_levels])
165
+ return all(tf.value() for tf in self.if_levels)
172
166
 
173
167
  def only_current_false(self) -> bool:
174
168
  # Returns True if the current if level is false and all higher levels are True.
@@ -177,9 +171,9 @@ class IfLevels:
177
171
  elif len(self.if_levels) == 1:
178
172
  return not self.if_levels[-1].value()
179
173
  else:
180
- return not self.if_levels[-1].value() and all([tf.value() for tf in self.if_levels[:-1]])
174
+ return not self.if_levels[-1].value() and all(tf.value() for tf in self.if_levels[:-1])
181
175
 
182
- def script_lines(self, top_n: int) -> List[tuple]:
176
+ def script_lines(self, top_n: int) -> list[tuple]:
183
177
  # Returns a list of tuples containing the script name and line number
184
178
  # for the topmost 'top_n' if levels, in bottom-up order.
185
179
  if len(self.if_levels) < top_n:
@@ -198,7 +192,7 @@ class CounterVars:
198
192
  _COUNTER_RX = re.compile(r"!!\$(COUNTER_\d+)!!", re.I)
199
193
 
200
194
  def __init__(self) -> None:
201
- self.counters: Dict[str, int] = {}
195
+ self.counters: dict[str, int] = {}
202
196
 
203
197
  def _ctrid(self, ctr_no: int) -> str:
204
198
  return f"counter_{ctr_no}"
@@ -217,7 +211,6 @@ class CounterVars:
217
211
  def substitute(self, command_str: str) -> tuple:
218
212
  # Substitutes any counter variable references with the counter value and
219
213
  # returns the modified command string and a flag indicating replacements.
220
- match_found = False
221
214
  m = self._COUNTER_RX.search(command_str, re.I)
222
215
  if m:
223
216
  ctr_id = m.group(1).lower()
@@ -246,12 +239,44 @@ class CounterVars:
246
239
  class SubVarSet:
247
240
  # A pool of substitution variables. Each variable consists of a name and
248
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.
249
245
  def __init__(self) -> None:
250
- self.substitutions: List[tuple] = []
251
- self.prefix_list: List[str] = ["$", "&", "@"]
246
+ self._subs_dict: dict[str, Any] = {}
247
+ self._compiled_patterns: dict[str, tuple] = {}
248
+ self.prefix_list: list[str] = ["$", "&", "@"]
252
249
  # Don't construct/compile on init because deepcopy() can't handle compiled regexes.
253
250
  self.var_rx = None
254
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
+
255
280
  def compile_var_rx(self) -> None:
256
281
  self.var_rx_str = r"^[" + "".join(self.prefix_list) + r"]?\w+$"
257
282
  self.var_rx = re.compile(self.var_rx_str, re.I)
@@ -268,30 +293,26 @@ class SubVarSet:
268
293
  def remove_substitution(self, template_str: str) -> None:
269
294
  self.check_var_name(template_str)
270
295
  old_sub = template_str.lower()
271
- self.substitutions = [sub for sub in self.substitutions if sub[0] != old_sub]
296
+ self._subs_dict.pop(old_sub, None)
297
+ self._compiled_patterns.pop(old_sub, None)
272
298
 
273
299
  def add_substitution(self, varname: str, repl_str: Any) -> None:
274
300
  self.check_var_name(varname)
275
301
  varname = varname.lower()
276
- self.remove_substitution(varname)
277
- self.substitutions.append((varname, repl_str))
302
+ self._subs_dict[varname] = repl_str
303
+ self._compiled_patterns[varname] = self._compile_patterns_for(varname)
278
304
 
279
305
  def append_substitution(self, varname: str, repl_str: str) -> None:
280
306
  self.check_var_name(varname)
281
307
  varname = varname.lower()
282
- oldsub = [x for x in self.substitutions if x[0] == varname]
283
- if len(oldsub) == 0:
284
- self.add_substitution(varname, repl_str)
308
+ if varname in self._subs_dict:
309
+ self.add_substitution(varname, f"{self._subs_dict[varname]}\n{repl_str}")
285
310
  else:
286
- self.add_substitution(varname, f"{oldsub[0][1]}\n{repl_str}")
311
+ self.add_substitution(varname, repl_str)
287
312
 
288
- def varvalue(self, varname: str) -> Optional[str]:
313
+ def varvalue(self, varname: str) -> str | None:
289
314
  self.check_var_name(varname)
290
- vname = varname.lower()
291
- for vardef in self.substitutions:
292
- if vardef[0] == vname:
293
- return vardef[1]
294
- return None
315
+ return self._subs_dict.get(varname.lower())
295
316
 
296
317
  def increment_by(self, varname: str, numeric_increment: Any) -> None:
297
318
  self.check_var_name(varname)
@@ -312,44 +333,39 @@ class SubVarSet:
312
333
 
313
334
  def sub_exists(self, template_str: str) -> bool:
314
335
  self.check_var_name(template_str)
315
- test_str = template_str.lower()
316
- return test_str in [s[0] for s in self.substitutions]
336
+ return template_str.lower() in self._subs_dict
317
337
 
318
- def merge(self, other_subvars: Optional[SubVarSet]) -> SubVarSet:
338
+ def merge(self, other_subvars: SubVarSet | None) -> SubVarSet:
319
339
  # Return a new SubVarSet with this object's variables merged with other_subvars.
320
340
  if other_subvars is not None:
321
341
  newsubs = SubVarSet()
322
- newsubs.substitutions = self.substitutions
342
+ newsubs._subs_dict = dict(self._subs_dict)
343
+ newsubs._compiled_patterns = dict(self._compiled_patterns)
323
344
  newsubs.prefix_list = list(set(self.prefix_list + other_subvars.prefix_list))
324
345
  newsubs.compile_var_rx()
325
- for vardef in other_subvars.substitutions:
326
- newsubs.add_substitution(vardef[0], vardef[1])
346
+ for varname, value in other_subvars._subs_dict.items():
347
+ newsubs.add_substitution(varname, value)
327
348
  return newsubs
328
349
  return self
329
350
 
330
351
  def substitute(self, command_str: str) -> tuple:
331
352
  # Replace any substitution variables in the command string.
332
- match_found = False
333
353
  if isinstance(command_str, str):
334
- for match, sub in self.substitutions:
354
+ for varname, sub in self._subs_dict.items():
335
355
  if sub is None:
336
356
  sub = ""
337
357
  sub = str(sub)
338
- if match[0] == "$":
339
- match = "\\" + match
340
358
  if os.name != "posix":
341
359
  sub = sub.replace("\\", "\\\\")
342
- pat = f"!!{match}!!"
343
- patq = f"!'!{match}!'!"
344
- patdq = f'!"!{match}!"!'
345
- if re.search(pat, command_str, re.I):
346
- return re.sub(pat, sub, command_str, flags=re.I), True
347
- if re.search(patq, command_str, re.I):
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):
348
364
  sub = sub.replace("'", "''")
349
- return re.sub(patq, sub, command_str, flags=re.I), True
350
- if re.search(patdq, command_str, re.I):
365
+ return patq_rx.sub(sub, command_str), True
366
+ if patdq_rx.search(command_str):
351
367
  sub = '"' + sub + '"'
352
- return re.sub(patdq, sub, command_str, flags=re.I), True
368
+ return patdq_rx.sub(sub, command_str), True
353
369
  return command_str, False
354
370
 
355
371
  def substitute_all(self, any_text: str) -> tuple:
@@ -399,12 +415,11 @@ class MetaCommand:
399
415
  self,
400
416
  rx: Any,
401
417
  exec_func: Any,
402
- description: Optional[str] = None,
418
+ description: str | None = None,
403
419
  run_in_batch: bool = False,
404
420
  run_when_false: bool = False,
405
421
  set_error_flag: bool = True,
406
422
  ) -> None:
407
- self.next_node = None
408
423
  self.rx = rx
409
424
  self.exec_fn = exec_func
410
425
  self.description = description
@@ -432,7 +447,7 @@ class MetaCommand:
432
447
  raise
433
448
  except ErrInfo as errinf:
434
449
  er = errinf
435
- except:
450
+ except Exception:
436
451
  er = ErrInfo("cmd", command_text=cmd_str, exception_msg=exception_desc())
437
452
  if er:
438
453
  if _state.status.halt_on_metacommand_err:
@@ -451,63 +466,61 @@ class MetaCommand:
451
466
 
452
467
 
453
468
  class MetaCommandList:
454
- # The head node for a linked list of MetaCommand objects.
469
+ """Ordered list of :class:`MetaCommand` entries.
470
+
471
+ 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.
475
+ """
476
+
455
477
  def __init__(self) -> None:
456
- self.next_node = None
478
+ self._commands: list[MetaCommand] = []
457
479
 
458
480
  def __iter__(self) -> Any:
459
- n1 = self.next_node
460
- while n1 is not None:
461
- yield n1
462
- n1 = n1.next_node
463
-
464
- def insert_node(self, new_node: MetaCommand) -> None:
465
- new_node.next_node = self.next_node
466
- self.next_node = new_node
481
+ return iter(self._commands)
467
482
 
468
483
  def add(
469
484
  self,
470
485
  matching_regexes: Any,
471
486
  exec_func: Any,
472
- description: Optional[str] = None,
487
+ description: str | None = None,
473
488
  run_in_batch: bool = False,
474
489
  run_when_false: bool = False,
475
490
  set_error_flag: bool = True,
476
491
  ) -> None:
477
492
  if type(matching_regexes) in (tuple, list):
478
- self.regexes = [re.compile(rx, re.I) for rx in tuple(matching_regexes)]
493
+ regexes = [re.compile(rx, re.I) for rx in tuple(matching_regexes)]
479
494
  else:
480
- self.regexes = [re.compile(matching_regexes, re.I)]
481
- for rx in self.regexes:
482
- self.insert_node(MetaCommand(rx, exec_func, description, run_in_batch, run_when_false, set_error_flag))
495
+ 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),
501
+ )
483
502
 
484
503
  def eval(self, cmd_str: str) -> tuple:
485
- # Evaluates the given metacommand string.
486
- n1 = self
487
- node_no = 0
488
- while n1 is not None:
489
- n2 = n1.next_node
490
- if n2 is not None:
491
- node_no += 1
492
- if _state.if_stack.all_true() or n2.run_when_false:
493
- success, value = n2.run(cmd_str)
494
- if success:
495
- # Move n2 to the head of the list.
496
- n1.next_node = n2.next_node
497
- n2.next_node = self.next_node
498
- self.next_node = n2
499
- return True, value
500
- n1 = n2
504
+ """Evaluate *cmd_str* against the registered metacommands.
505
+
506
+ Returns ``(True, return_value)`` if a matching command was found and
507
+ run, ``(False, None)`` if no command matched.
508
+ """
509
+ for cmd in self._commands:
510
+ if _state.if_stack.all_true() or cmd.run_when_false:
511
+ success, value = cmd.run(cmd_str)
512
+ if success:
513
+ return True, value
501
514
  return False, None
502
515
 
503
- def get_match(self, cmd: str) -> Optional[tuple]:
504
- # Tries to match the command to any MetaCommand.
505
- n1 = self.next_node
506
- while n1 is not None:
507
- m = n1.rx.match(cmd.strip())
516
+ def get_match(self, cmd: str) -> tuple | None:
517
+ """Return ``(MetaCommand, re.Match)`` for the first entry matching *cmd*,
518
+ or ``None`` if no entry matches.
519
+ """
520
+ for node in self._commands:
521
+ m = node.rx.match(cmd.strip())
508
522
  if m is not None:
509
- return (n1, m)
510
- n1 = n1.next_node
523
+ return (node, m)
511
524
  return None
512
525
 
513
526
 
@@ -524,7 +537,7 @@ class SqlStmt:
524
537
  def __repr__(self) -> str:
525
538
  return f"SqlStmt({self.statement})"
526
539
 
527
- def run(self, localvars: Optional[SubVarSet] = None, commit: bool = True) -> None:
540
+ def run(self, localvars: SubVarSet | None = None, commit: bool = True) -> None:
528
541
  # Run the SQL statement on the current database.
529
542
  if _state.if_stack.all_true():
530
543
  e = None
@@ -543,7 +556,7 @@ class SqlStmt:
543
556
  e = errinfo
544
557
  except SystemExit:
545
558
  raise
546
- except:
559
+ except Exception:
547
560
  e = ErrInfo(type="exception", exception_msg=exception_desc())
548
561
  if e:
549
562
  _state.subvars.add_substitution("$LAST_ERROR", cmd)
@@ -567,7 +580,7 @@ class MetacommandStmt:
567
580
  def __repr__(self) -> str:
568
581
  return f"MetacommandStmt({self.statement})"
569
582
 
570
- def run(self, localvars: Optional[SubVarSet] = None, commit: bool = False) -> Any:
583
+ def run(self, localvars: SubVarSet | None = None, commit: bool = False) -> Any:
571
584
  # Tries all metacommands in the dispatch table until one runs.
572
585
  errmsg = "Unknown metacommand"
573
586
  cmd = substitute_vars(self.statement, localvars)
@@ -582,7 +595,7 @@ class MetacommandStmt:
582
595
  e = errinfo
583
596
  except SystemExit:
584
597
  raise
585
- except:
598
+ except Exception:
586
599
  e = ErrInfo(type="exception", exception_msg=exception_desc())
587
600
  if e:
588
601
  _state.status.metacommand_error = True
@@ -637,9 +650,9 @@ class CommandList:
637
650
  # A list of ScriptCmd objects including execution state.
638
651
  def __init__(
639
652
  self,
640
- cmdlist: List[ScriptCmd],
653
+ cmdlist: list[ScriptCmd],
641
654
  listname: str,
642
- paramnames: Optional[List[str]] = None,
655
+ paramnames: list[str] | None = None,
643
656
  ) -> None:
644
657
  if cmdlist is None:
645
658
  raise ErrInfo("error", other_msg="Initiating a command list without any commands.")
@@ -647,9 +660,9 @@ class CommandList:
647
660
  self.cmdlist = cmdlist
648
661
  self.cmdptr = 0
649
662
  self.paramnames = paramnames
650
- self.paramvals: Optional[SubVarSet] = None
663
+ self.paramvals: SubVarSet | None = None
651
664
  self.localvars = LocalSubVarSet()
652
- self.init_if_level: Optional[int] = None
665
+ self.init_if_level: int | None = None
653
666
 
654
667
  def add(self, script_command: ScriptCmd) -> None:
655
668
  self.cmdlist.append(script_command)
@@ -658,13 +671,13 @@ class CommandList:
658
671
  self.paramvals = paramvals
659
672
  if self.paramnames is not None:
660
673
  passed_paramnames = [p[0][1:] if p[0][0] == "#" else p[0][1:] for p in paramvals.substitutions]
661
- if not all([p in passed_paramnames for p in self.paramnames]):
674
+ if not all(p in passed_paramnames for p in self.paramnames):
662
675
  raise ErrInfo(
663
676
  "error",
664
677
  other_msg=f"Formal and actual parameter name mismatch in call to {self.listname}.",
665
678
  )
666
679
 
667
- def current_command(self) -> Optional[ScriptCmd]:
680
+ def current_command(self) -> ScriptCmd | None:
668
681
  if self.cmdptr > len(self.cmdlist) - 1:
669
682
  return None
670
683
  return self.cmdlist[self.cmdptr]
@@ -704,17 +717,14 @@ class CommandList:
704
717
  if cmditem.command_type == "sql" and _state.status.batch.in_batch():
705
718
  _state.status.batch.using_db(_state.dbs.current())
706
719
  _state.subvars.add_substitution("$CURRENT_TIME", datetime.datetime.now().strftime("%Y-%m-%d %H:%M"))
707
- if sys.version_info < (3, 12):
708
- utcnow = datetime.datetime.utcnow()
709
- else:
710
- utcnow = datetime.datetime.now(tz=datetime.timezone.utc)
720
+ utcnow = datetime.datetime.now(tz=datetime.timezone.utc)
711
721
  _state.subvars.add_substitution("$CURRENT_TIME_UTC", utcnow.strftime("%Y-%m-%d %H:%M"))
712
722
  _state.subvars.add_substitution("$CURRENT_SCRIPT", cmditem.source)
713
723
  _state.subvars.add_substitution(
714
724
  "$CURRENT_SCRIPT_PATH",
715
- os.path.dirname(os.path.abspath(cmditem.source)) + os.sep,
725
+ str(Path(cmditem.source).resolve().parent) + os.sep,
716
726
  )
717
- _state.subvars.add_substitution("$CURRENT_SCRIPT_NAME", os.path.basename(cmditem.source))
727
+ _state.subvars.add_substitution("$CURRENT_SCRIPT_NAME", Path(cmditem.source).name)
718
728
  _state.subvars.add_substitution("$CURRENT_SCRIPT_LINE", str(cmditem.line_no))
719
729
  _state.subvars.add_substitution("$SCRIPT_LINE", str(cmditem.line_no))
720
730
  cmditem.command.run(self.localvars.merge(self.paramvals), not _state.status.batch.in_batch())
@@ -743,12 +753,12 @@ class CommandListWhileLoop(CommandList):
743
753
  # Subclass of CommandList that loops WHILE a condition is met.
744
754
  def __init__(
745
755
  self,
746
- cmdlist: List[ScriptCmd],
756
+ cmdlist: list[ScriptCmd],
747
757
  listname: str,
748
- paramnames: Optional[List[str]],
758
+ paramnames: list[str] | None,
749
759
  loopcondition: str,
750
760
  ) -> None:
751
- super(CommandListWhileLoop, self).__init__(cmdlist, listname, paramnames)
761
+ super().__init__(cmdlist, listname, paramnames)
752
762
  self.loopcondition = loopcondition
753
763
 
754
764
  def run_next(self) -> None:
@@ -769,12 +779,12 @@ class CommandListUntilLoop(CommandList):
769
779
  # Subclass of CommandList that loops UNTIL a condition is met.
770
780
  def __init__(
771
781
  self,
772
- cmdlist: List[ScriptCmd],
782
+ cmdlist: list[ScriptCmd],
773
783
  listname: str,
774
- paramnames: Optional[List[str]],
784
+ paramnames: list[str] | None,
775
785
  loopcondition: str,
776
786
  ) -> None:
777
- super(CommandListUntilLoop, self).__init__(cmdlist, listname, paramnames)
787
+ super().__init__(cmdlist, listname, paramnames)
778
788
  self.loopcondition = loopcondition
779
789
 
780
790
  def run_next(self) -> None:
@@ -799,20 +809,20 @@ class CommandListUntilLoop(CommandList):
799
809
  class ScriptFile(EncodedFile):
800
810
  # A file reader that returns lines and records the line number.
801
811
  def __init__(self, scriptfname: str, file_encoding: str) -> None:
802
- super(ScriptFile, self).__init__(scriptfname, file_encoding)
812
+ super().__init__(scriptfname, file_encoding)
803
813
  self.lno = 0
804
814
  self.f = self.open("r")
805
815
 
806
816
  def __repr__(self) -> str:
807
- return f"ScriptFile({super(ScriptFile, self).filename!r}, {super(ScriptFile, self).encoding!r})"
817
+ return f"ScriptFile({super().filename!r}, {super().encoding!r})"
808
818
 
809
819
  def __iter__(self) -> Any:
810
820
  return self
811
821
 
812
822
  def __next__(self) -> str:
813
- l = next(self.f)
823
+ line = next(self.f)
814
824
  self.lno += 1
815
- return l
825
+ return line
816
826
 
817
827
 
818
828
  # ---------------------------------------------------------------------------
@@ -829,11 +839,11 @@ class ScriptExecSpec:
829
839
 
830
840
  def __init__(self, **kwargs: Any) -> None:
831
841
  self.script_id = kwargs["script_id"].lower()
832
- if self.script_id not in _state.savedscripts.keys():
842
+ if self.script_id not in _state.savedscripts:
833
843
  raise ErrInfo("cmd", other_msg=f"There is no SCRIPT named {self.script_id}.")
834
844
  self.arg_exp = kwargs["argexp"]
835
845
  self.looptype = kwargs["looptype"].upper() if "looptype" in kwargs and kwargs["looptype"] is not None else None
836
- self.loopcond = kwargs["loopcond"] if "loopcond" in kwargs else None
846
+ self.loopcond = kwargs.get("loopcond")
837
847
 
838
848
  def execute(self) -> None:
839
849
  # Copy the saved script to avoid erasing saved script commands during execution.
@@ -883,18 +893,19 @@ def set_system_vars() -> None:
883
893
  )
884
894
  _state.subvars.add_substitution("$CONSOLE_WAIT_WHEN_DONE_STATE", "ON" if _state.conf.gui_wait_on_exit else "OFF")
885
895
  _state.subvars.add_substitution("$CURRENT_TIME", datetime.datetime.now().strftime("%Y-%m-%d %H:%M"))
886
- _state.subvars.add_substitution("$CURRENT_DIR", os.path.abspath(os.path.curdir))
887
- _state.subvars.add_substitution("$CURRENT_PATH", os.path.abspath(os.path.curdir) + os.sep)
896
+ _state.subvars.add_substitution("$CURRENT_DIR", str(Path(".").resolve()))
897
+ _state.subvars.add_substitution("$CURRENT_PATH", str(Path(".").resolve()) + os.sep)
888
898
  _state.subvars.add_substitution("$CURRENT_ALIAS", _state.dbs.current_alias())
889
- _state.subvars.add_substitution("$AUTOCOMMIT_STATE", "ON" if _state.dbs.current().autocommit else "OFF")
899
+ db = _state.dbs.current()
900
+ _state.subvars.add_substitution("$AUTOCOMMIT_STATE", "ON" if db.autocommit else "OFF")
890
901
  _state.subvars.add_substitution("$TIMER", str(datetime.timedelta(seconds=_state.timer.elapsed())))
891
- _state.subvars.add_substitution("$DB_USER", _state.dbs.current().user if _state.dbs.current().user else "")
902
+ _state.subvars.add_substitution("$DB_USER", db.user if db.user else "")
892
903
  _state.subvars.add_substitution(
893
904
  "$DB_SERVER",
894
- _state.dbs.current().server_name if _state.dbs.current().server_name else "",
905
+ db.server_name if db.server_name else "",
895
906
  )
896
- _state.subvars.add_substitution("$DB_NAME", _state.dbs.current().db_name)
897
- _state.subvars.add_substitution("$DB_NEED_PWD", "TRUE" if _state.dbs.current().need_passwd else "FALSE")
907
+ _state.subvars.add_substitution("$DB_NAME", db.db_name)
908
+ _state.subvars.add_substitution("$DB_NEED_PWD", "TRUE" if db.need_passwd else "FALSE")
898
909
  import random
899
910
 
900
911
  _state.subvars.add_substitution("$RANDOM", str(random.random()))
@@ -904,7 +915,7 @@ def set_system_vars() -> None:
904
915
  _state.subvars.add_substitution("$VERSION3", str(_state.tertiary_vno))
905
916
 
906
917
 
907
- def substitute_vars(command_str: str, localvars: Optional[SubVarSet] = None) -> str:
918
+ def substitute_vars(command_str: str, localvars: SubVarSet | None = None) -> str:
908
919
  # Substitutes global variables, global counters, and local variables.
909
920
  if localvars is not None:
910
921
  subs = _state.subvars.merge(localvars)
@@ -927,7 +938,6 @@ def substitute_vars(command_str: str, localvars: Optional[SubVarSet] = None) ->
927
938
 
928
939
 
929
940
  def runscripts() -> None:
930
- from execsql.metacommands import DISPATCH_TABLE # deferred — avoids circular import
931
941
 
932
942
  # Repeatedly run the next statement from the script at the top of the
933
943
  # command list stack until there are no more statements.
@@ -942,7 +952,7 @@ def runscripts() -> None:
942
952
  raise
943
953
  except ErrInfo:
944
954
  raise
945
- except:
955
+ except Exception:
946
956
  raise ErrInfo(type="exception", exception_msg=exception_desc())
947
957
  _state.cmds_run += 1
948
958
 
@@ -958,10 +968,9 @@ def current_script_line() -> tuple:
958
968
  return ("", 0)
959
969
 
960
970
 
961
- def read_sqlfile(sql_file_name: str) -> None:
962
- # Read lines from the given script file, create a list of ScriptCmd objects,
963
- # and append the list to the top of the stack of script commands.
964
- from execsql.utils.errors import file_size_date, write_warning
971
+ def _parse_script_lines(lines_iter, source_name: str) -> list:
972
+ # Parse an iterable of lines into a list of ScriptCmd objects.
973
+ from execsql.utils.errors import write_warning
965
974
 
966
975
  beginscript = re.compile(
967
976
  r"^\s*--\s*!x!\s*(?:BEGIN|CREATE)\s+SCRIPT\s+(?P<scriptname>\w+)(?:(?P<paramexpr>\s*\S+.*))?$",
@@ -974,17 +983,12 @@ def read_sqlfile(sql_file_name: str) -> None:
974
983
  cmtline = re.compile(r"^\s*--")
975
984
  in_block_cmt = False
976
985
  in_block_sql = False
977
- sz, dt = file_size_date(sql_file_name)
978
- _state.exec_log.log_status_info(f"Reading script file {sql_file_name} (size: {sz}; date: {dt})")
979
- scriptfilename = os.path.basename(sql_file_name)
980
- scriptfile_obj = ScriptFile(sql_file_name, _state.conf.script_encoding).open("r")
981
- sqllist: List[ScriptCmd] = []
986
+ sqllist: list[ScriptCmd] = []
982
987
  sqlline = 0
983
- subscript_stack: List[CommandList] = []
984
- file_lineno = 0
988
+ subscript_stack: list[CommandList] = []
985
989
  currcmd = ""
986
- for line in scriptfile_obj:
987
- file_lineno += 1
990
+ scriptname = ""
991
+ for file_lineno, line in enumerate(lines_iter, 1):
988
992
  # Remove trailing whitespace but not leading whitespace.
989
993
  line = line.rstrip()
990
994
  is_comment_line = False
@@ -1013,7 +1017,7 @@ def read_sqlfile(sql_file_name: str) -> None:
1013
1017
  if endsql.match(line):
1014
1018
  in_block_sql = False
1015
1019
  if len(currcmd) > 0:
1016
- cmd = ScriptCmd(sql_file_name, sqlline, "sql", SqlStmt(currcmd))
1020
+ cmd = ScriptCmd(source_name, sqlline, "sql", SqlStmt(currcmd))
1017
1021
  if len(subscript_stack) == 0:
1018
1022
  sqllist.append(cmd)
1019
1023
  else:
@@ -1022,7 +1026,7 @@ def read_sqlfile(sql_file_name: str) -> None:
1022
1026
  else:
1023
1027
  if len(currcmd) > 0:
1024
1028
  write_warning(
1025
- f"Incomplete SQL statement starting on line {sqlline} at metacommand on line {file_lineno} of {sql_file_name}.",
1029
+ f"Incomplete SQL statement starting on line {sqlline} at metacommand on line {file_lineno} of {source_name}.",
1026
1030
  )
1027
1031
  begs = beginscript.match(line)
1028
1032
  if not begs:
@@ -1042,7 +1046,7 @@ def read_sqlfile(sql_file_name: str) -> None:
1042
1046
  raise ErrInfo(
1043
1047
  type="cmd",
1044
1048
  command_text=line,
1045
- other_msg=f"Invalid BEGIN SCRIPT metacommand on line {file_lineno} of file {sql_file_name}.",
1049
+ other_msg=f"Invalid BEGIN SCRIPT metacommand on line {file_lineno} of file {source_name}.",
1046
1050
  )
1047
1051
  else:
1048
1052
  param_rx = re.compile(r"\w+", re.I)
@@ -1057,26 +1061,26 @@ def read_sqlfile(sql_file_name: str) -> None:
1057
1061
  raise ErrInfo(
1058
1062
  type="cmd",
1059
1063
  command_text=line,
1060
- other_msg=f"Unmatched END SCRIPT metacommand on line {file_lineno} of file {sql_file_name}.",
1064
+ other_msg=f"Unmatched END SCRIPT metacommand on line {file_lineno} of file {source_name}.",
1061
1065
  )
1062
1066
  if len(currcmd) > 0:
1063
1067
  raise ErrInfo(
1064
1068
  type="cmd",
1065
1069
  command_text=line,
1066
- other_msg=f"Incomplete SQL statement\n ({currcmd})\nat END SCRIPT metacommand on line {file_lineno} of file {sql_file_name}.",
1070
+ other_msg=f"Incomplete SQL statement\n ({currcmd})\nat END SCRIPT metacommand on line {file_lineno} of file {source_name}.",
1067
1071
  )
1068
1072
  if endscriptname is not None and endscriptname != scriptname:
1069
1073
  raise ErrInfo(
1070
1074
  type="cmd",
1071
1075
  command_text=line,
1072
- other_msg=f"Mismatched script name in the END SCRIPT metacommand on line {file_lineno} of file {sql_file_name}.",
1076
+ other_msg=f"Mismatched script name in the END SCRIPT metacommand on line {file_lineno} of file {source_name}.",
1073
1077
  )
1074
1078
  sub_script = subscript_stack.pop()
1075
1079
  _state.savedscripts[sub_script.listname] = sub_script
1076
1080
  else:
1077
1081
  # This is a non-IMMEDIATE metacommand.
1078
1082
  cmd = ScriptCmd(
1079
- sql_file_name,
1083
+ source_name,
1080
1084
  file_lineno,
1081
1085
  "cmd",
1082
1086
  MetacommandStmt(metacommand_match.group("cmd").strip()),
@@ -1087,7 +1091,7 @@ def read_sqlfile(sql_file_name: str) -> None:
1087
1091
  subscript_stack[-1].add(cmd)
1088
1092
  else:
1089
1093
  # This line is not a comment and not a metacommand; part of a SQL statement.
1090
- cmd_end = True if line[-1] == ";" else False
1094
+ cmd_end = line[-1] == ";"
1091
1095
  if line[-1] == "\\":
1092
1096
  line = line[:-1].strip()
1093
1097
  if currcmd == "":
@@ -1096,19 +1100,41 @@ def read_sqlfile(sql_file_name: str) -> None:
1096
1100
  else:
1097
1101
  currcmd = f"{currcmd} \n{line}"
1098
1102
  if cmd_end and not in_block_sql:
1099
- cmd = ScriptCmd(sql_file_name, sqlline, "sql", SqlStmt(currcmd.strip()))
1103
+ cmd = ScriptCmd(source_name, sqlline, "sql", SqlStmt(currcmd.strip()))
1100
1104
  if len(subscript_stack) == 0:
1101
1105
  sqllist.append(cmd)
1102
1106
  else:
1103
1107
  subscript_stack[-1].add(cmd)
1104
1108
  currcmd = ""
1105
1109
  if len(subscript_stack) > 0:
1106
- raise ErrInfo(type="error", other_msg=f"Unmatched BEGIN SCRIPT metacommand at end of file {sql_file_name}.")
1110
+ raise ErrInfo(type="error", other_msg=f"Unmatched BEGIN SCRIPT metacommand at end of file {source_name}.")
1107
1111
  if len(currcmd) > 0:
1108
1112
  raise ErrInfo(
1109
1113
  type="error",
1110
- other_msg=f"Incomplete SQL statement starting on line {sqlline} at end of file {sql_file_name}.",
1114
+ other_msg=(
1115
+ f"Incomplete SQL statement starting on line {sqlline} at end of file {source_name}."
1116
+ + (" Metacommands must be prefixed with '-- !x!'." if source_name == "<inline>" else "")
1117
+ ),
1111
1118
  )
1112
- if len(sqllist) > 0:
1113
- # The file might be all comments or just a BEGIN/END SCRIPT metacommand.
1114
- _state.commandliststack.append(CommandList(sqllist, scriptfilename))
1119
+ return sqllist
1120
+
1121
+
1122
+ def read_sqlfile(sql_file_name: str) -> None:
1123
+ # Read lines from the given script file, create a list of ScriptCmd objects,
1124
+ # and append the list to the top of the stack of script commands.
1125
+ from execsql.utils.errors import file_size_date
1126
+
1127
+ sz, dt = file_size_date(sql_file_name)
1128
+ _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)
1131
+ if sqllist:
1132
+ _state.commandliststack.append(CommandList(sqllist, Path(sql_file_name).name))
1133
+
1134
+
1135
+ def read_sqlstring(content: str, source_name: str = "<inline>") -> None:
1136
+ """Parse an inline script string and push it onto the command stack."""
1137
+ _state.exec_log.log_status_info(f"Reading inline script ({source_name})")
1138
+ sqllist = _parse_script_lines(content.splitlines(), source_name)
1139
+ if sqllist:
1140
+ _state.commandliststack.append(CommandList(sqllist, source_name))