execsql2 2.21.2__py3-none-any.whl → 2.22.1__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 (82) hide show
  1. execsql/api.py +29 -4
  2. execsql/cli/__init__.py +1 -1
  3. execsql/cli/run.py +37 -17
  4. execsql/config.py +40 -22
  5. execsql/data/execsql.conf.template +6 -0
  6. execsql/db/access.py +6 -5
  7. execsql/db/base.py +23 -5
  8. execsql/db/dsn.py +2 -0
  9. execsql/db/duckdb.py +1 -1
  10. execsql/db/mysql.py +6 -8
  11. execsql/db/sqlite.py +1 -1
  12. execsql/exceptions.py +14 -11
  13. execsql/exporters/base.py +1 -1
  14. execsql/exporters/delimited.py +22 -16
  15. execsql/exporters/duckdb.py +6 -1
  16. execsql/exporters/feather.py +1 -1
  17. execsql/exporters/json.py +5 -3
  18. execsql/exporters/latex.py +2 -2
  19. execsql/exporters/markdown.py +1 -0
  20. execsql/exporters/ods.py +7 -3
  21. execsql/exporters/raw.py +8 -8
  22. execsql/exporters/sqlite.py +11 -1
  23. execsql/exporters/templates.py +2 -1
  24. execsql/exporters/xls.py +10 -10
  25. execsql/exporters/xml.py +1 -0
  26. execsql/exporters/yaml.py +1 -0
  27. execsql/exporters/zip.py +9 -7
  28. execsql/format.py +6 -6
  29. execsql/gui/console.py +3 -3
  30. execsql/gui/desktop.py +28 -10
  31. execsql/gui/tui.py +8 -6
  32. execsql/importers/base.py +14 -8
  33. execsql/importers/csv.py +8 -3
  34. execsql/importers/xls.py +1 -0
  35. execsql/metacommands/conditions.py +14 -14
  36. execsql/metacommands/connect.py +2 -2
  37. execsql/metacommands/control.py +1 -1
  38. execsql/metacommands/data.py +2 -1
  39. execsql/metacommands/io_export.py +4 -0
  40. execsql/metacommands/io_fileops.py +85 -39
  41. execsql/metacommands/io_import.py +1 -0
  42. execsql/metacommands/io_write.py +1 -1
  43. execsql/metacommands/prompt.py +24 -22
  44. execsql/metacommands/system.py +14 -1
  45. execsql/models.py +56 -17
  46. execsql/parser.py +8 -8
  47. execsql/plugins.py +1 -1
  48. execsql/script/engine.py +4 -4
  49. execsql/script/executor.py +69 -28
  50. execsql/script/parser.py +13 -12
  51. execsql/script/variables.py +4 -2
  52. execsql/state.pyi +122 -0
  53. execsql/types.py +31 -26
  54. execsql/utils/auth.py +6 -2
  55. execsql/utils/datetime.py +3 -3
  56. execsql/utils/errors.py +7 -5
  57. execsql/utils/fileio.py +80 -32
  58. execsql/utils/gui.py +15 -11
  59. execsql/utils/mail.py +9 -2
  60. execsql/utils/numeric.py +1 -1
  61. execsql/utils/regex.py +9 -9
  62. {execsql2-2.21.2.dist-info → execsql2-2.22.1.dist-info}/METADATA +114 -132
  63. execsql2-2.22.1.dist-info/RECORD +123 -0
  64. execsql2-2.21.2.dist-info/RECORD +0 -122
  65. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/README.md +0 -0
  66. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  67. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  68. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  69. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/md_compare.sql +0 -0
  70. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
  71. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
  72. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
  73. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  74. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  75. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/script_template.sql +0 -0
  76. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
  77. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  78. {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  79. {execsql2-2.21.2.dist-info → execsql2-2.22.1.dist-info}/WHEEL +0 -0
  80. {execsql2-2.21.2.dist-info → execsql2-2.22.1.dist-info}/entry_points.txt +0 -0
  81. {execsql2-2.21.2.dist-info → execsql2-2.22.1.dist-info}/licenses/LICENSE.txt +0 -0
  82. {execsql2-2.21.2.dist-info → execsql2-2.22.1.dist-info}/licenses/NOTICE +0 -0
@@ -81,7 +81,7 @@ def x_prompt(**kwargs: Any) -> None:
81
81
  cmd = f"select * from {sq_name};"
82
82
  colnames, rows = db.select_data(cmd)
83
83
  enable_gui()
84
- return_queue = _queue.Queue()
84
+ return_queue: _queue.Queue[Any] = _queue.Queue()
85
85
  gui_args = {
86
86
  "title": table,
87
87
  "message": message,
@@ -117,7 +117,7 @@ def x_prompt_enter(**kwargs: Any) -> None:
117
117
  else:
118
118
  hdrs, rows = None, None
119
119
  enable_gui()
120
- return_queue = _queue.Queue()
120
+ return_queue: _queue.Queue[Any] = _queue.Queue()
121
121
  gui_args = {
122
122
  "title": "Enter a value",
123
123
  "message": message,
@@ -289,7 +289,7 @@ def x_prompt_entryform(**kwargs: Any) -> None:
289
289
  sq_name = db.schema_qualified_table_name(display_schema, display_table)
290
290
  colnames, rows = db.select_data(f"select * from {sq_name};")
291
291
  enable_gui()
292
- return_queue = _queue.Queue()
292
+ return_queue: _queue.Queue[Any] = _queue.Queue()
293
293
  gui_args = {
294
294
  "title": "Entry",
295
295
  "message": message,
@@ -304,18 +304,20 @@ def x_prompt_entryform(**kwargs: Any) -> None:
304
304
  entries = user_response["return_value"]
305
305
  script, line_no = current_script_line()
306
306
  if btn:
307
- for e in entries:
308
- if e.value:
309
- value = str(e.value)
307
+ assert entries is not None
308
+ assert subvar is not None
309
+ for entry in entries:
310
+ if entry.value:
311
+ value = str(entry.value)
310
312
  subvarset = _state.subvars if subvar[0] != "~" else _state.current_localvars()
311
- subvarset.add_substitution(e.name, value)
313
+ subvarset.add_substitution(entry.name, value)
312
314
  _state.exec_log.log_status_info(
313
- f"Substitution variable {e.name} set to {{{value}}} on line {line_no} of {script}",
315
+ f"Substitution variable {entry.name} set to {{{value}}} on line {line_no} of {script}",
314
316
  )
315
317
  else:
316
- if _state.subvars.sub_exists(e.name):
318
+ if _state.subvars.sub_exists(entry.name):
317
319
  _state.exec_log.log_status_info(
318
- f"Substitution variable {e.name} removed on line {line_no} of {script}",
320
+ f"Substitution variable {entry.name} removed on line {line_no} of {script}",
319
321
  )
320
322
  else:
321
323
  if _state.status.cancel_halt:
@@ -340,7 +342,7 @@ def x_prompt_pause(**kwargs: Any) -> None:
340
342
  if timeunit and timeunit.lower() == "minutes":
341
343
  maxtime_secs = maxtime_secs * 60
342
344
  enable_gui()
343
- return_queue = _queue.Queue()
345
+ return_queue: _queue.Queue[Any] = _queue.Queue()
344
346
  gui_args = {
345
347
  "title": f"Script {current_script_line()[0]}",
346
348
  "message": msg,
@@ -401,7 +403,7 @@ def prompt_compare(button_list: list, **kwargs: Any) -> Any:
401
403
  other_msg=f"Specified primary key columns do not exist in PROMPT COMPARE metacommand on line {line_no} of {script}.",
402
404
  )
403
405
  enable_gui()
404
- return_queue = _queue.Queue()
406
+ return_queue: _queue.Queue[Any] = _queue.Queue()
405
407
  gui_args = {
406
408
  "title": "Compare data",
407
409
  "message": msg,
@@ -458,7 +460,7 @@ def x_prompt_ask(**kwargs: Any) -> None:
458
460
  else:
459
461
  colnames, rows = None, None
460
462
  enable_gui()
461
- return_queue = _queue.Queue()
463
+ return_queue: _queue.Queue[Any] = _queue.Queue()
462
464
  gui_args = {
463
465
  "title": script,
464
466
  "message": kwargs["question"],
@@ -499,7 +501,7 @@ def x_prompt_map(**kwargs: Any) -> None:
499
501
  cmd = f"select * from {sq_name};"
500
502
  colnames, rows = db.select_data(cmd)
501
503
  enable_gui()
502
- return_queue = _queue.Queue()
504
+ return_queue: _queue.Queue[Any] = _queue.Queue()
503
505
  gui_args = {
504
506
  "title": table,
505
507
  "message": message,
@@ -590,7 +592,7 @@ def x_prompt_action(**kwargs: Any) -> None:
590
592
  sq_name = db.schema_qualified_table_name(display_schema, display_table)
591
593
  colnames, rows = db.select_data(f"select * from {sq_name};")
592
594
  enable_gui()
593
- return_queue = _queue.Queue()
595
+ return_queue: _queue.Queue[Any] = _queue.Queue()
594
596
  gui_args = {
595
597
  "title": "Actions",
596
598
  "message": message,
@@ -624,7 +626,7 @@ def x_prompt_savefile(**kwargs: Any) -> None:
624
626
  script, lno = current_script_line()
625
627
  working_dir = startdir if startdir is not None else str(Path(script).resolve().parent)
626
628
  enable_gui()
627
- return_queue = _queue.Queue()
629
+ return_queue: _queue.Queue[Any] = _queue.Queue()
628
630
  gui_args = {"working_dir": working_dir, "script": script}
629
631
  _state.gui_manager_queue.put(GuiSpec(GUI_SAVEFILE, gui_args, return_queue))
630
632
  user_response = return_queue.get(block=True)
@@ -697,7 +699,7 @@ def x_prompt_openfile(**kwargs: Any) -> None:
697
699
  script, lno = current_script_line()
698
700
  working_dir = startdir if startdir is not None else str(Path(script).resolve().parent)
699
701
  enable_gui()
700
- return_queue = _queue.Queue()
702
+ return_queue: _queue.Queue[Any] = _queue.Queue()
701
703
  gui_args = {"working_dir": working_dir, "script": script}
702
704
  _state.gui_manager_queue.put(GuiSpec(GUI_OPENFILE, gui_args, return_queue))
703
705
  user_response = return_queue.get(block=True)
@@ -756,7 +758,7 @@ def x_prompt_directory(**kwargs: Any) -> None:
756
758
  script, lno = current_script_line()
757
759
  working_dir = startdir if startdir is not None else str(Path(script).resolve().parent)
758
760
  enable_gui()
759
- return_queue = _queue.Queue()
761
+ return_queue: _queue.Queue[Any] = _queue.Queue()
760
762
  gui_args = {"working_dir": working_dir, "script": script}
761
763
  _state.gui_manager_queue.put(GuiSpec(GUI_DIRECTORY, gui_args, return_queue))
762
764
  user_response = return_queue.get(block=True)
@@ -818,7 +820,7 @@ def prompt_select_rows(button_list: list, **kwargs: Any) -> Any:
818
820
  if len(missing_hdrs) > 0:
819
821
  raise ErrInfo("error", other_msg=f"Columns [{', '.join(missing_hdrs)}] are missing from {sq_name2}.")
820
822
  enable_gui()
821
- return_queue = _queue.Queue()
823
+ return_queue: _queue.Queue[Any] = _queue.Queue()
822
824
  gui_args = {
823
825
  "title": "Select rows",
824
826
  "message": msg,
@@ -867,7 +869,7 @@ def x_ask(**kwargs: Any) -> None:
867
869
  subvar = kwargs["match"]
868
870
  script, lno = current_script_line()
869
871
  if _state.gui_console:
870
- return_queue = _queue.Queue()
872
+ return_queue: _queue.Queue[Any] = _queue.Queue()
871
873
  gui_args = {
872
874
  "title": script,
873
875
  "message": kwargs["question"],
@@ -919,7 +921,7 @@ def x_pause(**kwargs: Any) -> None:
919
921
  maxtime_secs = maxtime_secs * 60
920
922
  use_gui = False
921
923
  if _state.gui_manager_thread:
922
- return_queue = _queue.Queue()
924
+ return_queue: _queue.Queue[Any] = _queue.Queue()
923
925
  _state.gui_manager_queue.put(GuiSpec(QUERY_CONSOLE, {}, return_queue))
924
926
  user_response = return_queue.get(block=True)
925
927
  use_gui = user_response["console_running"]
@@ -953,7 +955,7 @@ def x_msg(**kwargs: Any) -> None:
953
955
  message = kwargs["message"]
954
956
  current_script_line()
955
957
  enable_gui()
956
- return_queue = _queue.Queue()
958
+ return_queue: _queue.Queue[Any] = _queue.Queue()
957
959
  gui_args = {"title": "Message", "message": message}
958
960
  _state.gui_manager_queue.put(GuiSpec(GUI_MSG, gui_args, return_queue))
959
961
  return_queue.get(block=True)
@@ -49,7 +49,20 @@ def x_system_cmd(**kwargs: Any) -> None:
49
49
  filewriter_close_all_after_write()
50
50
  cmdlist = shlex.split(syscmd, posix=(os.name == "posix"))
51
51
  if cont is None:
52
- result = subprocess.run(cmdlist)
52
+ timeout = getattr(_state.conf, "system_cmd_timeout", 0.0)
53
+ try:
54
+ if timeout and timeout > 0:
55
+ result = subprocess.run(cmdlist, timeout=timeout)
56
+ else:
57
+ result = subprocess.run(cmdlist)
58
+ except subprocess.TimeoutExpired as e:
59
+ _state.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", "124")
60
+ raise ErrInfo(
61
+ type="cmd",
62
+ command_text=kwargs.get("metacommandline", "SYSTEM_CMD"),
63
+ other_msg=f"SYSTEM_CMD timed out after {timeout:g} seconds.",
64
+ exception_msg=exception_desc(),
65
+ ) from e
53
66
  _state.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", str(result.returncode))
54
67
  else:
55
68
  proc = subprocess.Popen(cmdlist)
execsql/models.py CHANGED
@@ -47,6 +47,8 @@ __all__ = [
47
47
  "to_json_type",
48
48
  ]
49
49
 
50
+ ColumnType = tuple[str, type[DataType], int | None, bool, int | None, int | None]
51
+
50
52
 
51
53
  class Column:
52
54
  """Compile data-type match statistics for a single column of imported data."""
@@ -66,9 +68,11 @@ class Column:
66
68
  self.count = 0
67
69
  self.maxlen = 0
68
70
  self.varlen = False
69
- self.maxprecision = None
70
- self.scale = None
71
+ self.maxprecision: int | None = None
72
+ self.scale: int | None = None
71
73
  self.varscale = False
74
+ self.maxscale: int | None = None
75
+ self.max_int_digits: int | None = None
72
76
 
73
77
  def __repr__(self) -> str:
74
78
  return (
@@ -97,21 +101,35 @@ class Column:
97
101
  if vlen > self.maxlen:
98
102
  self.maxlen = vlen
99
103
  if self.dt.precision is not None and self.dt.scale is not None:
104
+ int_digits = self.dt.precision - self.dt.scale
100
105
  if self.maxprecision is None:
101
106
  self.maxprecision = self.dt.precision
102
- else:
103
- self.maxprecision = max(self.dt.precision, self.maxprecision)
104
- if self.scale is None:
105
107
  self.scale = self.dt.scale
108
+ self.maxscale = self.dt.scale
109
+ self.max_int_digits = int_digits
106
110
  else:
111
+ assert self.maxscale is not None
112
+ assert self.max_int_digits is not None
113
+ self.maxprecision = max(self.dt.precision, self.maxprecision)
107
114
  if self.dt.scale != self.scale:
108
115
  self.varscale = True
109
- self.failed = True
116
+ self.maxscale = max(self.dt.scale, self.maxscale)
117
+ self.max_int_digits = max(int_digits, self.max_int_digits)
110
118
  else:
111
119
  self.failed = True
112
120
 
113
- def __init__(self, colname: str) -> None:
114
- """Create a column characteriser for the named column."""
121
+ def __init__(self, colname: str, infer_strings: bool = True) -> None:
122
+ """Create a column characteriser for the named column.
123
+
124
+ Args:
125
+ colname: The name of the column to characterise.
126
+ infer_strings: When ``False``, string values are only tested
127
+ against string-typed accumulators (Character, Varchar, Text),
128
+ skipping numeric/date/boolean checks. Set to ``False`` when
129
+ the row source already contains typed values (e.g. exported
130
+ from a DBMS cursor) so that a Python ``str`` that happens to
131
+ look like a date is not misclassified.
132
+ """
115
133
  from execsql.exceptions import ErrInfo
116
134
  import execsql.state as _state
117
135
 
@@ -126,6 +144,7 @@ class Column:
126
144
  self.nullrows = 0
127
145
  # The list of accumulators must be in order from most specific to least specific data type.
128
146
  conf = _state.conf
147
+ self.accums: tuple[Column.Accum, ...]
129
148
  if conf.only_strings:
130
149
  self.accums = (
131
150
  self.Accum(DT_Character()),
@@ -148,10 +167,12 @@ class Column:
148
167
  self.Accum(DT_Text()),
149
168
  self.Accum(DT_Binary()),
150
169
  )
170
+ self.infer_strings = infer_strings
171
+ self.str_accums = tuple(ac for ac in self.accums if isinstance(ac.dt, (DT_Character, DT_Varchar, DT_Text)))
151
172
  self.dt_eval = False
152
173
  # self.dt is a tuple of: 0: column name; 1: data type class; 2: max length or None;
153
174
  # 3: bool indicating any null values; 4: precision or None; 5: scale or None.
154
- self.dt = (None, None, None, None, None, None)
175
+ self.dt: ColumnType | None = None
155
176
 
156
177
  def __repr__(self) -> str:
157
178
  return f"Column({self.name!r})"
@@ -174,15 +195,20 @@ class Column:
174
195
  ):
175
196
  self.nullrows += 1
176
197
  return
177
- for dt in self.accums:
178
- dt.check(column_value)
198
+ if not self.infer_strings and isinstance(column_value, str):
199
+ for dt in self.str_accums:
200
+ dt.check(column_value)
201
+ else:
202
+ for dt in self.accums:
203
+ dt.check(column_value)
179
204
 
180
- def column_type(self) -> tuple:
205
+ def column_type(self) -> ColumnType:
181
206
  """Return the inferred type of this column as a 6-tuple."""
182
207
  # Return the type of this column as a tuple of:
183
208
  # column name, data type class, max length or None, bool for null values,
184
209
  # precision or None, scale or None.
185
210
  if self.dt_eval:
211
+ assert self.dt is not None
186
212
  return self.dt
187
213
  sel_type = None # Will be set to an Accum instance.
188
214
  if self.nullrows == self.rowcount:
@@ -213,8 +239,12 @@ class Column:
213
239
  sel_type.dt.__class__,
214
240
  None if not sel_type.dt.lenspec else sel_type.maxlen,
215
241
  self.nullrows > 0,
216
- sel_type.maxprecision,
217
- sel_type.scale,
242
+ (
243
+ sel_type.max_int_digits + sel_type.maxscale
244
+ if sel_type.max_int_digits is not None and sel_type.maxscale is not None
245
+ else sel_type.maxprecision
246
+ ),
247
+ sel_type.maxscale if sel_type.maxscale is not None else sel_type.scale,
218
248
  )
219
249
  self.dt_eval = True
220
250
  return self.dt
@@ -223,8 +253,17 @@ class Column:
223
253
  class DataTable:
224
254
  """Scan a row source and infer column types for CREATE TABLE generation."""
225
255
 
226
- def __init__(self, column_names: list[str], rowsource: Any) -> None:
227
- """Scan all rows from the source and infer a column type for each column."""
256
+ def __init__(self, column_names: list[str], rowsource: Any, infer_strings: bool = True) -> None:
257
+ """Scan all rows from the source and infer a column type for each column.
258
+
259
+ Args:
260
+ column_names: Ordered list of column names for the table.
261
+ rowsource: Iterable of row tuples (or lists) to scan.
262
+ infer_strings: Passed through to each :class:`Column`. Set to
263
+ ``False`` when the row source originates from a DBMS cursor so
264
+ that Python ``str`` values that resemble dates or numbers are
265
+ not misclassified.
266
+ """
228
267
  import execsql.state as _state
229
268
 
230
269
  self.inputrows = 0 # Total number of rows in the row source.
@@ -232,7 +271,7 @@ class DataTable:
232
271
  self.shortrows = 0 # Number of rows without as many data values as column names.
233
272
  self.cols: list = [] # List of Column objects.
234
273
  for n in column_names:
235
- self.cols.append(Column(n))
274
+ self.cols.append(Column(n, infer_strings=infer_strings))
236
275
  conf = _state.conf
237
276
  # Read and evaluate columns in the rowsource until done (or until an error).
238
277
  for datarow in rowsource:
execsql/parser.py CHANGED
@@ -23,7 +23,7 @@ Classes:
23
23
  """
24
24
 
25
25
  import re
26
- from typing import Any
26
+ from typing import Any, cast
27
27
 
28
28
  from execsql.exceptions import CondParserError, NumericParserError
29
29
 
@@ -154,18 +154,18 @@ class CondAstNode(CondTokens):
154
154
  if self.type == self.CONDITIONAL:
155
155
  exec_fn = self.left[0].exec_fn
156
156
  cmdargs = self.left[1]
157
- return exec_fn(**cmdargs)
157
+ return bool(exec_fn(**cmdargs))
158
158
  if self.type == self.NOT:
159
159
  return not self.left.eval()
160
160
  lcond = self.left.eval()
161
161
  if self.type == self.AND:
162
162
  if not lcond:
163
163
  return False
164
- return self.right.eval()
164
+ return cast(bool, self.right.eval())
165
165
  if self.type == self.OR:
166
166
  if lcond:
167
167
  return True
168
- return self.right.eval()
168
+ return cast(bool, self.right.eval())
169
169
  raise CondParserError(f"Unknown conditional node type: {self.type}")
170
170
 
171
171
 
@@ -254,10 +254,10 @@ class CondParser(CondTokens):
254
254
  # conditionallist is a module-level global in the main execsql module
255
255
  import execsql.state as _state
256
256
 
257
- m1 = self.cond_expr.match_metacommand(_state.conditionallist)
258
- if m1 is not None:
259
- m1[1]["metacommandline"] = self.condexpr
260
- return CondAstNode(self.CONDITIONAL, m1, None)
257
+ matched_metacommand = self.cond_expr.match_metacommand(_state.conditionallist)
258
+ if matched_metacommand is not None:
259
+ matched_metacommand[1]["metacommandline"] = self.condexpr
260
+ return CondAstNode(self.CONDITIONAL, matched_metacommand, None)
261
261
  else:
262
262
  if self.cond_expr.match_str("(") is not None:
263
263
  m1 = self.expression()
execsql/plugins.py CHANGED
@@ -298,7 +298,7 @@ def _load_entry_points(group: str) -> list[tuple[str, Any]]:
298
298
  Errors during loading are logged and skipped — a broken plugin should
299
299
  not prevent execsql from starting.
300
300
  """
301
- results = []
301
+ results: list[tuple[str, Any]] = []
302
302
  try:
303
303
  eps = entry_points(group=group)
304
304
  except Exception:
execsql/script/engine.py CHANGED
@@ -28,7 +28,7 @@ import os
28
28
  import re
29
29
  import uuid
30
30
  from pathlib import Path
31
- from typing import Any
31
+ from typing import Any, cast
32
32
 
33
33
  import execsql.state as _state
34
34
  from execsql.exceptions import ErrInfo
@@ -315,11 +315,11 @@ class ScriptCmd:
315
315
  def __repr__(self) -> str:
316
316
  return f"ScriptCmd({self.source!r}, {self.line_no!r}, {self.command_type!r}, {repr(self.command)!r})"
317
317
 
318
- def current_script_line(self) -> tuple:
318
+ def current_script_line(self) -> tuple[str, int]:
319
319
  return (self.source, self.line_no)
320
320
 
321
321
  def commandline(self) -> str:
322
- return self.command.statement if self.command_type == "sql" else "-- !x! " + self.command.statement
322
+ return cast(str, self.command.statement if self.command_type == "sql" else "-- !x! " + self.command.statement)
323
323
 
324
324
 
325
325
  # ---------------------------------------------------------------------------
@@ -505,7 +505,7 @@ def substitute_vars(command_str: str, localvars: SubVarSet | None = None, ctx: A
505
505
  return cmdstr
506
506
 
507
507
 
508
- def current_script_line() -> tuple:
508
+ def current_script_line() -> tuple[str, int]:
509
509
  """Return ``(source_name, line_number)`` for the command currently executing.
510
510
 
511
511
  Reads from ``_state.last_command``, which the AST executor updates on
@@ -47,7 +47,7 @@ import os
47
47
  import re
48
48
  import time as _time
49
49
  from pathlib import Path
50
- from typing import Any
50
+ from typing import Any, cast
51
51
 
52
52
  from execsql.exceptions import ErrInfo
53
53
  from execsql.script.ast import (
@@ -98,7 +98,17 @@ def _stack_localvars(ctx: RuntimeContext) -> SubVarSet | None:
98
98
  localvars = scope.localvars
99
99
  if localvars is None:
100
100
  return None
101
- return localvars.merge(scope.paramvals)
101
+ return cast(SubVarSet, localvars.merge(scope.paramvals))
102
+
103
+
104
+ def _assert_command_runtime(ctx: RuntimeContext) -> None:
105
+ """Assert runtime singletons required by command execution are initialised."""
106
+ assert ctx.conf is not None
107
+ assert ctx.dbs is not None
108
+ assert ctx.metacommandlist is not None
109
+ assert ctx.output is not None
110
+ assert ctx.status is not None
111
+ assert ctx.subvars is not None
102
112
 
103
113
 
104
114
  def _push_frame(
@@ -192,6 +202,7 @@ def _eval_condition(
192
202
 
193
203
  def _set_command_vars(ctx: RuntimeContext, source: str, line_no: int) -> None:
194
204
  """Set per-command system variables (current script, line, time)."""
205
+ assert ctx.subvars is not None
195
206
  now = datetime.datetime.now()
196
207
  ctx.subvars.add_substitution("$CURRENT_TIME", now.strftime("%Y-%m-%d %H:%M"))
197
208
  ctx.subvars.add_substitution("$CURRENT_DATE", now.strftime("%Y-%m-%d"))
@@ -219,9 +230,21 @@ def _exec_sql(
219
230
  commit: bool = True,
220
231
  ) -> None:
221
232
  """Execute a SQL statement against the current database."""
222
- ctx.status.sql_error = False
223
- if ctx.status.batch.in_batch():
224
- ctx.status.batch.using_db(ctx.dbs.current())
233
+ _assert_command_runtime(ctx)
234
+ conf = ctx.conf
235
+ dbs = ctx.dbs
236
+ output = ctx.output
237
+ status = ctx.status
238
+ subvars = ctx.subvars
239
+ assert conf is not None
240
+ assert dbs is not None
241
+ assert output is not None
242
+ assert status is not None
243
+ assert subvars is not None
244
+
245
+ status.sql_error = False
246
+ if status.batch.in_batch():
247
+ status.batch.using_db(dbs.current())
225
248
  # Build localvars from the command-list stack frame so that ~ and # vars
226
249
  # written by x_sub/get_subvarset are visible. The stack frame is the
227
250
  # canonical source; the `localvars` parameter is kept for backward compat
@@ -229,13 +252,13 @@ def _exec_sql(
229
252
  effective_locals = _stack_localvars(ctx) or localvars
230
253
  cmd = substitute_vars(text, effective_locals, ctx=ctx)
231
254
  if _VARLIKE.search(cmd):
232
- ctx.output.write(
255
+ output.write(
233
256
  f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
234
257
  )
235
258
  e = None
236
259
  try:
237
- db = ctx.dbs.current()
238
- if ctx.conf.log_sql and ctx.exec_log:
260
+ db = dbs.current()
261
+ if conf.log_sql and ctx.exec_log:
239
262
  ctx.exec_log.log_sql_query(cmd, db.name(), line_no)
240
263
  db.execute(cmd)
241
264
  if commit:
@@ -248,15 +271,16 @@ def _exec_sql(
248
271
  e = ErrInfo(type="exception", exception_msg=exception_desc())
249
272
  if e:
250
273
  stamp_errinfo(e)
251
- ctx.subvars.add_substitution("$LAST_ERROR", cmd)
252
- ctx.subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
253
- ctx.status.sql_error = True
274
+ subvars.add_substitution("$LAST_ERROR", cmd)
275
+ subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
276
+ status.sql_error = True
254
277
  if ctx.exec_log is not None:
255
278
  ctx.exec_log.log_status_info(f"SQL error: {e.errmsg()}")
256
- if ctx.status.halt_on_err:
279
+ if status.halt_on_err:
257
280
  exit_now(1, e)
281
+ status.error_history.append((source, line_no, cmd, e.errmsg()))
258
282
  return
259
- ctx.subvars.add_substitution("$LAST_SQL", cmd)
283
+ subvars.add_substitution("$LAST_SQL", cmd)
260
284
 
261
285
 
262
286
  # ---------------------------------------------------------------------------
@@ -277,13 +301,23 @@ def _exec_metacommand(
277
301
  increments, ``$RANDOM``, ``$UUID``) are evaluated exactly once per
278
302
  metacommand reference.
279
303
  """
304
+ _assert_command_runtime(ctx)
305
+ metacommandlist = ctx.metacommandlist
306
+ output = ctx.output
307
+ status = ctx.status
308
+ subvars = ctx.subvars
309
+ assert metacommandlist is not None
310
+ assert output is not None
311
+ assert status is not None
312
+ assert subvars is not None
313
+
280
314
  if _VARLIKE.search(cmd):
281
- ctx.output.write(
315
+ output.write(
282
316
  f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
283
317
  )
284
318
  e = None
285
319
  try:
286
- applies, result = ctx.metacommandlist.eval(cmd)
320
+ applies, result = metacommandlist.eval(cmd)
287
321
  if applies:
288
322
  return result
289
323
  except ErrInfo as errinfo:
@@ -294,16 +328,17 @@ def _exec_metacommand(
294
328
  e = ErrInfo(type="exception", exception_msg=exception_desc())
295
329
  if e:
296
330
  stamp_errinfo(e)
297
- ctx.status.metacommand_error = True
298
- ctx.subvars.add_substitution("$LAST_ERROR", cmd)
299
- ctx.subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
331
+ status.metacommand_error = True
332
+ subvars.add_substitution("$LAST_ERROR", cmd)
333
+ subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
300
334
  if ctx.exec_log is not None:
301
335
  ctx.exec_log.log_status_info(f"Metacommand error: {e.errmsg()}")
302
- if ctx.status.halt_on_metacommand_err:
336
+ if status.halt_on_metacommand_err:
303
337
  raise e
338
+ status.error_history.append((source, line_no, cmd, e.errmsg()))
304
339
  return None
305
340
  # No handler matched — truly unknown metacommand
306
- ctx.status.metacommand_error = True
341
+ status.metacommand_error = True
307
342
  raise ErrInfo(type="cmd", command_text=cmd, other_msg="Unknown metacommand")
308
343
 
309
344
 
@@ -345,7 +380,9 @@ def _execute_nodes(
345
380
  elapsed = _time.perf_counter() - t0
346
381
  cmd_type = _node_cmd_type(node)
347
382
  cmd_text = _node_cmd_text(node)[:100]
348
- ctx.profile_data.append(
383
+ profile_data = ctx.profile_data
384
+ assert profile_data is not None
385
+ profile_data.append(
349
386
  (node.span.file, node.span.start_line, cmd_type, elapsed, cmd_text),
350
387
  )
351
388
 
@@ -360,6 +397,7 @@ def _execute_node(
360
397
  in_loop: bool = False,
361
398
  ) -> None:
362
399
  """Execute a single AST node."""
400
+ assert ctx.status is not None
363
401
  if isinstance(node, SqlStatement):
364
402
  text = node.text
365
403
  if in_loop:
@@ -528,7 +566,9 @@ def _execute_batch(
528
566
  """Execute a BEGIN BATCH / END BATCH block."""
529
567
  from execsql.state import ExecFrame
530
568
 
531
- ctx.status.batch.new_batch()
569
+ status = ctx.status
570
+ assert status is not None
571
+ status.batch.new_batch()
532
572
  _push_block_frame(
533
573
  ctx,
534
574
  ExecFrame(kind="batch", label="", source=node.span.file, line=node.span.start_line),
@@ -537,8 +577,8 @@ def _execute_batch(
537
577
  _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=in_loop)
538
578
  finally:
539
579
  ctx.ast_exec_stack.pop()
540
- if ctx.status.batch.in_batch():
541
- ctx.status.batch.end_batch()
580
+ if status.batch.in_batch():
581
+ status.batch.end_batch()
542
582
 
543
583
 
544
584
  def _pre_register_scripts(ctx: RuntimeContext, nodes: list[Node]) -> None:
@@ -716,7 +756,8 @@ def _execute_script_native(
716
756
 
717
757
  # Handle WHILE/UNTIL loops
718
758
  # Convert deferred vars once — node.loop_condition is immutable after parsing
719
- if node.loop_type is not None:
759
+ condition = ""
760
+ if node.loop_type is not None and node.loop_condition is not None:
720
761
  condition = _convert_deferred_vars(node.loop_condition)
721
762
 
722
763
  if node.loop_type == "WHILE":
@@ -818,7 +859,7 @@ def _execute_include_native(
818
859
  ctx.exec_log.log_status_info(f"Reading script file {target} (size: {sz}; date: {dt})")
819
860
 
820
861
  # Parse with AST parser
821
- encoding = ctx.conf.script_encoding if ctx.conf else "utf-8"
862
+ encoding = ctx.conf.script_encoding if ctx.conf is not None else "utf-8"
822
863
  included_tree = parse_script(target, encoding=encoding)
823
864
 
824
865
  # Pre-register SCRIPT blocks in the included file so forward references work.
@@ -879,11 +920,11 @@ class _FakeScriptCmd:
879
920
  else:
880
921
  self.command = type("_cmd", (), {"statement": "", "commandline": lambda self: ""})()
881
922
 
882
- def current_script_line(self) -> tuple:
923
+ def current_script_line(self) -> tuple[str, int]:
883
924
  return (self.source, self.line_no)
884
925
 
885
926
  def commandline(self) -> str:
886
- return self.command.commandline()
927
+ return cast(str, self.command.commandline())
887
928
 
888
929
 
889
930
  # ---------------------------------------------------------------------------