execsql2 2.23.1__py3-none-any.whl → 2.25.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 (92) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +19 -586
  3. execsql/cli/application.py +309 -0
  4. execsql/cli/commands/__init__.py +13 -0
  5. execsql/cli/commands/config.py +205 -0
  6. execsql/cli/commands/format.py +100 -0
  7. execsql/cli/commands/init.py +135 -0
  8. execsql/cli/commands/lint.py +225 -0
  9. execsql/cli/commands/listing.py +81 -0
  10. execsql/cli/commands/run.py +459 -0
  11. execsql/cli/dispatch.py +66 -0
  12. execsql/cli/dsn.py +11 -4
  13. execsql/cli/help.py +175 -24
  14. execsql/cli/lint.py +780 -100
  15. execsql/cli/options.py +108 -0
  16. execsql/cli/run.py +96 -32
  17. execsql/config.py +256 -173
  18. execsql/config_validation.py +282 -0
  19. execsql/data/execsql.conf.template +38 -5
  20. execsql/data/script_header.sql +23 -0
  21. execsql/db/access.py +29 -8
  22. execsql/db/base.py +40 -8
  23. execsql/db/dsn.py +13 -4
  24. execsql/db/duckdb.py +3 -1
  25. execsql/db/factory.py +13 -4
  26. execsql/db/firebird.py +21 -7
  27. execsql/db/mysql.py +37 -16
  28. execsql/db/oracle.py +49 -16
  29. execsql/db/postgres.py +30 -6
  30. execsql/db/sqlite.py +7 -9
  31. execsql/db/sqlserver.py +25 -8
  32. execsql/debug/repl.py +51 -193
  33. execsql/exceptions.py +9 -0
  34. execsql/exporters/delimited.py +87 -28
  35. execsql/exporters/xlsx.py +24 -9
  36. execsql/exporters/zip.py +1 -2
  37. execsql/format.py +115 -76
  38. execsql/gui/desktop.py +1 -3
  39. execsql/gui/tui.py +2 -2
  40. execsql/importers/base.py +35 -1
  41. execsql/importers/csv.py +3 -1
  42. execsql/interactive.py +232 -0
  43. execsql/manifest.py +200 -0
  44. execsql/metacommands/conditions.py +7 -8
  45. execsql/metacommands/control.py +2 -3
  46. execsql/metacommands/data.py +4 -1
  47. execsql/metacommands/debug.py +2 -4
  48. execsql/metacommands/effects.py +124 -0
  49. execsql/metacommands/io_fileops.py +62 -37
  50. execsql/metacommands/io_import.py +0 -1
  51. execsql/metacommands/prompt.py +2 -0
  52. execsql/metacommands/upsert.py +5 -2
  53. execsql/models.py +3 -2
  54. execsql/parser.py +14 -5
  55. execsql/plugins.py +24 -0
  56. execsql/script/__init__.py +0 -9
  57. execsql/script/ast.py +1 -1
  58. execsql/script/engine.py +46 -104
  59. execsql/script/executor.py +162 -39
  60. execsql/script/parser.py +167 -118
  61. execsql/state.py +34 -11
  62. execsql/state.pyi +11 -0
  63. execsql/types.py +22 -13
  64. execsql/utils/auth.py +169 -24
  65. execsql/utils/color.py +18 -0
  66. execsql/utils/datetime.py +55 -17
  67. execsql/utils/errors.py +82 -10
  68. execsql/utils/fileio.py +404 -117
  69. execsql/utils/gui.py +3 -0
  70. execsql/utils/mail.py +37 -12
  71. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
  72. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
  73. execsql2-2.25.0.dist-info/RECORD +140 -0
  74. execsql2-2.25.0.dist-info/entry_points.txt +2 -0
  75. execsql2-2.23.1.dist-info/RECORD +0 -124
  76. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  77. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  78. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  79. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  80. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  81. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  82. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  83. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  84. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  85. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  86. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  87. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  88. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  89. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  90. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  91. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  92. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
execsql/models.py CHANGED
@@ -268,7 +268,6 @@ class DataTable:
268
268
 
269
269
  self.inputrows = 0 # Total number of rows in the row source.
270
270
  self.datarows = 0 # Number of non-empty rows (with data values).
271
- self.shortrows = 0 # Number of rows without as many data values as column names.
272
271
  self.cols: list = [] # List of Column objects.
273
272
  for n in column_names:
274
273
  self.cols.append(Column(n, infer_strings=infer_strings))
@@ -281,7 +280,6 @@ class DataTable:
281
280
  self.datarows += 1
282
281
  chkcols = len(self.cols)
283
282
  if dataitems < chkcols:
284
- self.shortrows += 1
285
283
  chkcols = len(datarow)
286
284
  else:
287
285
  if dataitems > chkcols:
@@ -307,6 +305,9 @@ class DataTable:
307
305
  raise DataTableError(errmsg)
308
306
  for i in range(chkcols):
309
307
  self.cols[i].eval_types(datarow[i])
308
+ # Trailing values left off a short row are imported as NULL.
309
+ for i in range(chkcols, len(self.cols)):
310
+ self.cols[i].eval_types(None)
310
311
  for col in self.cols:
311
312
  col.column_type()
312
313
 
execsql/parser.py CHANGED
@@ -215,10 +215,17 @@ class CondParser(CondTokens):
215
215
  """Recursive-descent parser for boolean conditional expressions."""
216
216
 
217
217
  # Takes a conditional expression string.
218
- def __init__(self, condexpr: str) -> None:
219
- """Initialise the parser with the conditional expression string."""
218
+ def __init__(self, condexpr: str, conditionallist: Any = None) -> None:
219
+ """Initialise the parser with the conditional expression string.
220
+
221
+ *conditionallist* is the table of conditional tests to match against;
222
+ ``None`` means the current run's ``conditionallist``. Passing one lets
223
+ a caller with no run in progress, such as ``execsql lint``, parse
224
+ (without evaluating) a condition.
225
+ """
220
226
  self.condexpr = condexpr
221
227
  self.cond_expr = SourceString(condexpr)
228
+ self.conditionallist = conditionallist
222
229
 
223
230
  def match_not(self) -> int | None:
224
231
  """Match a NOT operator and return its token type, or None."""
@@ -251,10 +258,12 @@ class CondParser(CondTokens):
251
258
  m1 = self.factor()
252
259
  return CondAstNode(self.NOT, m1, None)
253
260
  # Find the matching metacommand -- get a tuple consisting of (metacommand, groupdict)
254
- # conditionallist is a module-level global in the main execsql module
255
- import execsql.state as _state
261
+ conditionallist = self.conditionallist
262
+ if conditionallist is None:
263
+ import execsql.state as _state
256
264
 
257
- matched_metacommand = self.cond_expr.match_metacommand(_state.conditionallist)
265
+ conditionallist = _state.conditionallist
266
+ matched_metacommand = self.cond_expr.match_metacommand(conditionallist)
258
267
  if matched_metacommand is not None:
259
268
  matched_metacommand[1]["metacommandline"] = self.condexpr
260
269
  return CondAstNode(self.CONDITIONAL, matched_metacommand, None)
execsql/plugins.py CHANGED
@@ -61,6 +61,8 @@ adds entries::
61
61
  from __future__ import annotations
62
62
 
63
63
  import logging
64
+ import threading
65
+ import weakref
64
66
  from collections.abc import Callable
65
67
  from importlib.metadata import entry_points
66
68
  from typing import Any
@@ -71,6 +73,7 @@ __all__ = [
71
73
  "ImporterEntry",
72
74
  "ImporterRegistry",
73
75
  "discover_metacommand_plugins",
76
+ "register_metacommand_plugins_once",
74
77
  "discover_exporter_plugins",
75
78
  "discover_importer_plugins",
76
79
  "discover_all_plugins",
@@ -344,6 +347,27 @@ def discover_metacommand_plugins(mcl: Any) -> int:
344
347
  return loaded
345
348
 
346
349
 
350
+ _registered_tables: weakref.WeakSet[Any] = weakref.WeakSet()
351
+ _registration_lock = threading.Lock()
352
+
353
+
354
+ def register_metacommand_plugins_once(mcl: Any) -> int:
355
+ """Register metacommand plugins into *mcl* unless that was already done.
356
+
357
+ Every run in a process shares one dispatch table, so registering on each
358
+ run would prepend another copy of every plugin command each time, while
359
+ other threads may be dispatching from the table.
360
+
361
+ Returns:
362
+ Number of plugins loaded by this call (0 if *mcl* already had them).
363
+ """
364
+ with _registration_lock:
365
+ if mcl in _registered_tables:
366
+ return 0
367
+ _registered_tables.add(mcl)
368
+ return discover_metacommand_plugins(mcl)
369
+
370
+
347
371
  def discover_exporter_plugins(registry: ExporterRegistry | None = None) -> int:
348
372
  """Discover and register exporter plugins.
349
373
 
@@ -23,9 +23,6 @@ Key classes:
23
23
  handler function + flags).
24
24
  - :class:`MetaCommandList` — ordered list of :class:`MetaCommand` entries
25
25
  with a keyword index for fast dispatch.
26
- - :class:`SqlStmt` / :class:`MetacommandStmt` / :class:`ScriptCmd` —
27
- statement wrappers carried in the AST and used by ``ctx.last_command``
28
- for source-location tracking.
29
26
  - :class:`ScriptExecSpec` — specification for deferred script execution
30
27
  (used by ``ON ERROR_HALT`` / ``ON CANCEL_HALT EXECUTE SCRIPT``).
31
28
 
@@ -46,10 +43,7 @@ from execsql.script.control import BatchLevels
46
43
  from execsql.script.engine import (
47
44
  MetaCommand,
48
45
  MetaCommandList,
49
- MetacommandStmt,
50
- ScriptCmd,
51
46
  ScriptExecSpec,
52
- SqlStmt,
53
47
  current_script_line,
54
48
  set_dynamic_system_vars,
55
49
  set_static_system_vars,
@@ -84,9 +78,6 @@ __all__ = [
84
78
  "ScriptArgSubVarSet",
85
79
  "MetaCommand",
86
80
  "MetaCommandList",
87
- "SqlStmt",
88
- "MetacommandStmt",
89
- "ScriptCmd",
90
81
  "ScriptExecSpec",
91
82
  "set_dynamic_system_vars",
92
83
  "set_static_system_vars",
execsql/script/ast.py CHANGED
@@ -6,7 +6,7 @@ Defines the node types that make up the execsql AST.
6
6
 
7
7
  Design principles:
8
8
  - Every node carries a :class:`SourceSpan` so that error messages,
9
- ``--lint``, and the formatter can report precise source locations.
9
+ ``execsql lint``, and the formatter can report precise source locations.
10
10
  - Block structures (IF, LOOP, BATCH, SCRIPT) are represented as nodes
11
11
  whose ``body`` (and optional ``else_body``, ``elseif_clauses``) contain
12
12
  child nodes, forming the tree structure.
execsql/script/engine.py CHANGED
@@ -11,9 +11,6 @@ live in :mod:`execsql.script.parser` and
11
11
  Classes:
12
12
  - :class:`MetaCommand` — one entry in the metacommand dispatch table.
13
13
  - :class:`MetaCommandList` — ordered list of :class:`MetaCommand` entries with a keyword index.
14
- - :class:`SqlStmt` — wraps a single SQL string for execution.
15
- - :class:`MetacommandStmt` — wraps a metacommand line for dispatch.
16
- - :class:`ScriptCmd` — pairs a statement with its source-file location.
17
14
  - :class:`ScriptExecSpec` — specification for deferred script execution.
18
15
 
19
16
  Functions:
@@ -28,7 +25,7 @@ import os
28
25
  import re
29
26
  import uuid
30
27
  from pathlib import Path
31
- from typing import Any, cast
28
+ from typing import Any
32
29
 
33
30
  import execsql.state as _state
34
31
  from execsql.exceptions import ErrInfo
@@ -38,9 +35,6 @@ from execsql.utils.errors import exception_desc
38
35
  __all__ = [
39
36
  "MetaCommand",
40
37
  "MetaCommandList",
41
- "SqlStmt",
42
- "MetacommandStmt",
43
- "ScriptCmd",
44
38
  "ScriptExecSpec",
45
39
  "set_system_vars",
46
40
  "substitute_vars",
@@ -56,12 +50,16 @@ __all__ = [
56
50
  class MetaCommand:
57
51
  """A single entry in the metacommand dispatch table.
58
52
 
59
- Holds a compiled regex, a handler function, and execution-control flags.
60
- Call :meth:`run` with a raw command string to attempt a match and invoke
61
- the handler.
53
+ Holds a regex, a handler function, and execution-control flags. Call
54
+ :meth:`run` with a raw command string to attempt a match and invoke the
55
+ handler.
56
+
57
+ *rx* is a compiled pattern, or a pattern string that is compiled
58
+ (case-insensitively) the first time :attr:`rx` is read. The dispatch
59
+ table registers some 1,700 patterns and a script uses a few dozen, so
60
+ compiling them all up front cost most of a second on every run.
62
61
  """
63
62
 
64
- # A compiled metacommand that can be run if it matches a metacommand command string.
65
63
  def __init__(
66
64
  self,
67
65
  rx: Any,
@@ -70,6 +68,8 @@ class MetaCommand:
70
68
  set_error_flag: bool = True,
71
69
  category: str | None = None,
72
70
  ) -> None:
71
+ self._pattern: str
72
+ self._rx: Any = None
73
73
  self.rx = rx
74
74
  self.exec_fn = exec_func
75
75
  self.description = description
@@ -77,8 +77,32 @@ class MetaCommand:
77
77
  self.category = category
78
78
  self.hitcount = 0
79
79
 
80
+ @property
81
+ def rx(self) -> Any:
82
+ """The compiled pattern, compiled on first use."""
83
+ if self._rx is None:
84
+ self._rx = re.compile(self._pattern, re.I)
85
+ return self._rx
86
+
87
+ @rx.setter
88
+ def rx(self, value: Any) -> None:
89
+ if isinstance(value, str):
90
+ self._pattern, self._rx = value, None
91
+ else:
92
+ self._pattern, self._rx = value.pattern, value
93
+
94
+ @property
95
+ def pattern(self) -> str:
96
+ """The pattern string, without compiling it."""
97
+ return self._pattern
98
+
99
+ @property
100
+ def compiled(self) -> bool:
101
+ """Whether :attr:`rx` has been compiled yet."""
102
+ return self._rx is not None
103
+
80
104
  def __repr__(self) -> str:
81
- return f"MetaCommand({self.rx.pattern!r}, {self.exec_fn!r}, {self.description!r})"
105
+ return f"MetaCommand({self.pattern!r}, {self.exec_fn!r}, {self.description!r})"
82
106
 
83
107
  def run(self, cmd_str: str) -> tuple:
84
108
  """Match *cmd_str* against this entry's regex and, if it matches, invoke the handler.
@@ -97,8 +121,9 @@ class MetaCommand:
97
121
  raise
98
122
  except ErrInfo as errinf:
99
123
  er = errinf
100
- except Exception:
124
+ except Exception as exc:
101
125
  er = ErrInfo("cmd", command_text=cmd_str, exception_msg=exception_desc())
126
+ er.__cause__ = exc
102
127
  if er:
103
128
  if _state.status.halt_on_metacommand_err:
104
129
  from execsql.utils.errors import exit_now
@@ -170,18 +195,17 @@ class MetaCommandList:
170
195
  """Register one or more regex patterns as a new :class:`MetaCommand` entry.
171
196
 
172
197
  *matching_regexes* may be a single pattern string or a list/tuple of
173
- patterns; each compiles into a separate :class:`MetaCommand` prepended to
174
- the dispatch list so that later registrations take priority.
198
+ patterns; each becomes a separate :class:`MetaCommand` prepended to
199
+ the dispatch list so that later registrations take priority. The
200
+ patterns are compiled when first matched, not here.
175
201
  """
176
202
  if isinstance(matching_regexes, (tuple, list)):
177
203
  raw_patterns = list(matching_regexes)
178
- regexes = [re.compile(rx, re.I) for rx in raw_patterns]
179
204
  else:
180
205
  raw_patterns = [matching_regexes]
181
- regexes = [re.compile(matching_regexes, re.I)]
182
- for rx, raw in zip(regexes, raw_patterns):
206
+ for raw in raw_patterns:
183
207
  mc = MetaCommand(
184
- rx,
208
+ raw,
185
209
  exec_func,
186
210
  description,
187
211
  set_error_flag,
@@ -205,7 +229,7 @@ class MetaCommandList:
205
229
  def keywords_by_category(self) -> dict[str, list[str]]:
206
230
  """Return ``{category: [keyword, ...]}`` from entries that have both.
207
231
 
208
- Used by ``--dump-keywords`` to introspect the dispatch table.
232
+ Used by ``execsql list keywords`` to introspect the dispatch table.
209
233
  """
210
234
  result: dict[str, list[str]] = {}
211
235
  for mc in self._commands:
@@ -239,89 +263,6 @@ class MetaCommandList:
239
263
  return None
240
264
 
241
265
 
242
- # ---------------------------------------------------------------------------
243
- # SqlStmt / MetacommandStmt
244
- # ---------------------------------------------------------------------------
245
-
246
-
247
- class SqlStmt:
248
- """A single SQL statement ready to be executed against the active database.
249
-
250
- Data class only — the legacy ``.run()`` method was removed when the AST
251
- executor became the sole engine. SQL execution now goes through
252
- :func:`execsql.script.executor._exec_sql`.
253
- """
254
-
255
- def __init__(self, sql_statement: str) -> None:
256
- self.statement = re.sub(r"\s*;(\s*;\s*)+$", ";", sql_statement)
257
-
258
- def __repr__(self) -> str:
259
- return f"SqlStmt({self.statement})"
260
-
261
- def commandline(self) -> str:
262
- """Return the raw SQL statement text."""
263
- return self.statement
264
-
265
-
266
- class MetacommandStmt:
267
- """A single execsql metacommand line.
268
-
269
- Data class only — the legacy ``.run()`` method was removed when the AST
270
- executor became the sole engine. Metacommand dispatch now goes through
271
- :func:`execsql.script.executor._exec_metacommand`.
272
- """
273
-
274
- def __init__(self, metacommand_statement: str) -> None:
275
- self.statement = metacommand_statement
276
-
277
- def __repr__(self) -> str:
278
- return f"MetacommandStmt({self.statement})"
279
-
280
- def commandline(self) -> str:
281
- """Return the metacommand line in its canonical ``-- !x! ...`` form."""
282
- return "-- !x! " + self.statement
283
-
284
-
285
- # ---------------------------------------------------------------------------
286
- # ScriptCmd
287
- # ---------------------------------------------------------------------------
288
-
289
-
290
- class ScriptCmd:
291
- """A parsed script item: either a :class:`SqlStmt` or a :class:`MetacommandStmt`, with source location."""
292
-
293
- # A SQL script object that is either a SQL statement or a metacommand.
294
- def __init__(
295
- self,
296
- command_source_name: str,
297
- command_line_no: int,
298
- command_type: str,
299
- script_command: Any,
300
- ) -> None:
301
- self.source = command_source_name
302
- self.line_no = command_line_no
303
- self.command_type = command_type
304
- self.command = script_command
305
- # MIGRATION NOTE: differs from monolith (execsql.py) — source_dir and source_name are
306
- # resolved once at construction rather than on every statement execution. For absolute
307
- # paths (the common case) the result is identical. For relative paths the value is
308
- # anchored to the CWD at script-load time rather than at each statement's execution time;
309
- # the original per-statement resolve could yield inconsistent values across statements of
310
- # the same script if a CD metacommand ran between them.
311
- _p = Path(command_source_name)
312
- self.source_dir: str = str(_p.resolve().parent) + os.sep
313
- self.source_name: str = _p.name
314
-
315
- def __repr__(self) -> str:
316
- return f"ScriptCmd({self.source!r}, {self.line_no!r}, {self.command_type!r}, {repr(self.command)!r})"
317
-
318
- def current_script_line(self) -> tuple[str, int]:
319
- return (self.source, self.line_no)
320
-
321
- def commandline(self) -> str:
322
- return cast(str, self.command.statement if self.command_type == "sql" else "-- !x! " + self.command.statement)
323
-
324
-
325
266
  # ---------------------------------------------------------------------------
326
267
  # ScriptExecSpec
327
268
  # ---------------------------------------------------------------------------
@@ -509,7 +450,8 @@ def current_script_line() -> tuple[str, int]:
509
450
  """Return ``(source_name, line_number)`` for the command currently executing.
510
451
 
511
452
  Reads from ``_state.last_command``, which the AST executor updates on
512
- every statement via the ``_FakeScriptCmd`` shim. Returns ``("", 0)``
453
+ every statement via :class:`~execsql.script.executor.ExecutingStatement`.
454
+ Returns ``("", 0)``
513
455
  when nothing has executed yet (e.g. during early initialization errors).
514
456
  """
515
457
  last = getattr(_state, "last_command", None)