execsql2 2.24.0__py3-none-any.whl → 2.25.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 (72) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +2 -0
  3. execsql/cli/commands/lint.py +2 -10
  4. execsql/cli/commands/run.py +35 -27
  5. execsql/cli/dsn.py +11 -4
  6. execsql/cli/lint.py +351 -48
  7. execsql/cli/options.py +1 -1
  8. execsql/cli/run.py +56 -19
  9. execsql/config.py +5 -19
  10. execsql/data/execsql.conf.template +14 -4
  11. execsql/db/access.py +29 -8
  12. execsql/db/base.py +40 -8
  13. execsql/db/dsn.py +13 -4
  14. execsql/db/duckdb.py +3 -1
  15. execsql/db/factory.py +11 -2
  16. execsql/db/firebird.py +21 -7
  17. execsql/db/mysql.py +37 -16
  18. execsql/db/oracle.py +49 -16
  19. execsql/db/postgres.py +30 -6
  20. execsql/db/sqlite.py +7 -9
  21. execsql/db/sqlserver.py +25 -8
  22. execsql/exporters/delimited.py +87 -28
  23. execsql/exporters/xlsx.py +24 -9
  24. execsql/exporters/zip.py +1 -2
  25. execsql/format.py +29 -2
  26. execsql/gui/desktop.py +1 -3
  27. execsql/gui/tui.py +2 -2
  28. execsql/importers/base.py +35 -1
  29. execsql/importers/csv.py +3 -1
  30. execsql/manifest.py +33 -22
  31. execsql/metacommands/conditions.py +6 -7
  32. execsql/metacommands/data.py +4 -1
  33. execsql/metacommands/debug.py +2 -4
  34. execsql/metacommands/io_fileops.py +62 -37
  35. execsql/metacommands/io_import.py +0 -1
  36. execsql/metacommands/prompt.py +2 -0
  37. execsql/metacommands/upsert.py +5 -2
  38. execsql/models.py +3 -2
  39. execsql/parser.py +14 -5
  40. execsql/plugins.py +24 -0
  41. execsql/script/engine.py +40 -11
  42. execsql/script/executor.py +9 -5
  43. execsql/script/parser.py +167 -118
  44. execsql/state.py +23 -9
  45. execsql/state.pyi +8 -0
  46. execsql/types.py +22 -13
  47. execsql/utils/auth.py +169 -24
  48. execsql/utils/datetime.py +55 -17
  49. execsql/utils/errors.py +71 -9
  50. execsql/utils/fileio.py +404 -117
  51. execsql/utils/gui.py +3 -0
  52. execsql/utils/mail.py +37 -12
  53. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/METADATA +18 -12
  54. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/RECORD +72 -72
  55. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/README.md +0 -0
  56. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  57. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  58. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  59. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_compare.sql +0 -0
  60. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
  61. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
  62. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
  63. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  64. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  65. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/script_template.sql +0 -0
  66. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
  67. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  68. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  69. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/WHEEL +0 -0
  70. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/entry_points.txt +0 -0
  71. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/LICENSE.txt +0 -0
  72. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/NOTICE +0 -0
@@ -4,7 +4,7 @@ Integrates pg-upsert (https://pg-upsert.readthedocs.io/) as an optional
4
4
  dependency, providing QA-checked, FK-dependency-ordered upserts from a
5
5
  staging schema to a base schema on PostgreSQL.
6
6
 
7
- Requires: ``pip install execsql2[upsert]``
7
+ Requires the ``upsert`` extra (``execsql2[upsert]``).
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -213,7 +213,10 @@ def _require_pg_upsert() -> None:
213
213
  except ImportError as exc:
214
214
  raise ErrInfo(
215
215
  "exception",
216
- other_msg=("PG_UPSERT requires the pg-upsert package. Install it with: pip install execsql2[upsert]"),
216
+ other_msg=(
217
+ 'PG_UPSERT requires the pg-upsert package. Install the upsert extra: uv tool install "execsql2[upsert]"'
218
+ ' (or pip install "execsql2[upsert]").'
219
+ ),
217
220
  ) from exc
218
221
 
219
222
 
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
 
execsql/script/engine.py CHANGED
@@ -50,12 +50,16 @@ __all__ = [
50
50
  class MetaCommand:
51
51
  """A single entry in the metacommand dispatch table.
52
52
 
53
- Holds a compiled regex, a handler function, and execution-control flags.
54
- Call :meth:`run` with a raw command string to attempt a match and invoke
55
- 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.
56
61
  """
57
62
 
58
- # A compiled metacommand that can be run if it matches a metacommand command string.
59
63
  def __init__(
60
64
  self,
61
65
  rx: Any,
@@ -64,6 +68,8 @@ class MetaCommand:
64
68
  set_error_flag: bool = True,
65
69
  category: str | None = None,
66
70
  ) -> None:
71
+ self._pattern: str
72
+ self._rx: Any = None
67
73
  self.rx = rx
68
74
  self.exec_fn = exec_func
69
75
  self.description = description
@@ -71,8 +77,32 @@ class MetaCommand:
71
77
  self.category = category
72
78
  self.hitcount = 0
73
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
+
74
104
  def __repr__(self) -> str:
75
- 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})"
76
106
 
77
107
  def run(self, cmd_str: str) -> tuple:
78
108
  """Match *cmd_str* against this entry's regex and, if it matches, invoke the handler.
@@ -165,18 +195,17 @@ class MetaCommandList:
165
195
  """Register one or more regex patterns as a new :class:`MetaCommand` entry.
166
196
 
167
197
  *matching_regexes* may be a single pattern string or a list/tuple of
168
- patterns; each compiles into a separate :class:`MetaCommand` prepended to
169
- 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.
170
201
  """
171
202
  if isinstance(matching_regexes, (tuple, list)):
172
203
  raw_patterns = list(matching_regexes)
173
- regexes = [re.compile(rx, re.I) for rx in raw_patterns]
174
204
  else:
175
205
  raw_patterns = [matching_regexes]
176
- regexes = [re.compile(matching_regexes, re.I)]
177
- for rx, raw in zip(regexes, raw_patterns):
206
+ for raw in raw_patterns:
178
207
  mc = MetaCommand(
179
- rx,
208
+ raw,
180
209
  exec_func,
181
210
  description,
182
211
  set_error_flag,
@@ -69,7 +69,7 @@ from execsql import manifest as _manifest
69
69
  from execsql.script.engine import set_dynamic_system_vars, set_static_system_vars, substitute_vars
70
70
  from execsql.script.variables import SubVarSet
71
71
  from execsql.state import ExecFrame, RuntimeContext, active_context, get_context, xcmd_test
72
- from execsql.utils.errors import exception_desc, exit_now, stamp_errinfo
72
+ from execsql.utils.errors import exception_desc, exit_now, logged_error, stamp_errinfo
73
73
 
74
74
  __all__ = ["execute", "execute_input"]
75
75
 
@@ -254,8 +254,10 @@ def _exec_sql(
254
254
  effective_locals = _stack_localvars(ctx) or localvars
255
255
  cmd = substitute_vars(text, effective_locals, ctx=ctx)
256
256
  if _VARLIKE.search(cmd):
257
+ from execsql.utils.fileio import redact
258
+
257
259
  output.write(
258
- f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
260
+ f"Warning: There is a potential un-substituted variable in the command\n {redact(cmd)}\n",
259
261
  )
260
262
  e = None
261
263
  result = None
@@ -282,7 +284,7 @@ def _exec_sql(
282
284
  subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
283
285
  status.sql_error = True
284
286
  if ctx.exec_log is not None:
285
- ctx.exec_log.log_status_info(f"SQL error: {e.errmsg()}")
287
+ ctx.exec_log.log_status_info(f"SQL error: {logged_error(e)}")
286
288
  if status.halt_on_err:
287
289
  exit_now(1, e)
288
290
  status.error_history.append((source, line_no, cmd, e.errmsg()))
@@ -321,8 +323,10 @@ def _exec_metacommand(
321
323
  assert subvars is not None
322
324
 
323
325
  if _VARLIKE.search(cmd):
326
+ from execsql.utils.fileio import redact
327
+
324
328
  output.write(
325
- f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
329
+ f"Warning: There is a potential un-substituted variable in the command\n {redact(cmd)}\n",
326
330
  )
327
331
  e = None
328
332
  try:
@@ -344,7 +348,7 @@ def _exec_metacommand(
344
348
  subvars.add_substitution("$LAST_ERROR", cmd)
345
349
  subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
346
350
  if ctx.exec_log is not None:
347
- ctx.exec_log.log_status_info(f"Metacommand error: {e.errmsg()}")
351
+ ctx.exec_log.log_status_info(f"Metacommand error: {logged_error(e)}")
348
352
  if status.halt_on_metacommand_err:
349
353
  raise e
350
354
  status.error_history.append((source, line_no, cmd, e.errmsg()))