execsql2 2.15.8__py3-none-any.whl → 2.16.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 (66) hide show
  1. execsql/__init__.py +8 -3
  2. execsql/api.py +580 -0
  3. execsql/cli/__init__.py +123 -0
  4. execsql/cli/lint_ast.py +439 -0
  5. execsql/cli/run.py +113 -102
  6. execsql/config.py +29 -4
  7. execsql/db/access.py +1 -0
  8. execsql/db/base.py +4 -1
  9. execsql/db/dsn.py +3 -2
  10. execsql/db/duckdb.py +1 -1
  11. execsql/db/factory.py +3 -0
  12. execsql/db/firebird.py +2 -1
  13. execsql/db/mysql.py +2 -1
  14. execsql/db/oracle.py +2 -1
  15. execsql/db/postgres.py +2 -1
  16. execsql/db/sqlite.py +1 -1
  17. execsql/db/sqlserver.py +3 -2
  18. execsql/debug/repl.py +27 -10
  19. execsql/exporters/base.py +6 -4
  20. execsql/exporters/delimited.py +11 -3
  21. execsql/exporters/pretty.py +9 -12
  22. execsql/gui/tui.py +59 -2
  23. execsql/metacommands/__init__.py +3 -0
  24. execsql/metacommands/conditions.py +20 -2
  25. execsql/metacommands/connect.py +1 -1
  26. execsql/metacommands/control.py +8 -14
  27. execsql/metacommands/debug.py +6 -4
  28. execsql/metacommands/io_export.py +117 -315
  29. execsql/metacommands/io_fileops.py +7 -13
  30. execsql/metacommands/io_write.py +1 -1
  31. execsql/metacommands/script_ext.py +8 -5
  32. execsql/metacommands/upsert.py +40 -0
  33. execsql/models.py +8 -12
  34. execsql/plugins.py +414 -0
  35. execsql/script/__init__.py +36 -12
  36. execsql/script/ast.py +562 -0
  37. execsql/script/engine.py +59 -368
  38. execsql/script/executor.py +833 -0
  39. execsql/script/parser.py +663 -0
  40. execsql/script/variables.py +11 -0
  41. execsql/state.py +55 -2
  42. execsql/utils/crypto.py +14 -10
  43. execsql/utils/errors.py +31 -8
  44. execsql/utils/gui.py +139 -17
  45. execsql/utils/mail.py +15 -12
  46. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/METADATA +59 -1
  47. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/RECORD +66 -60
  48. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/README.md +0 -0
  49. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  50. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  51. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/execsql.conf +0 -0
  52. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  53. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  54. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  55. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  56. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  57. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  58. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  59. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/script_template.sql +0 -0
  60. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  61. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  62. {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  63. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/WHEEL +0 -0
  64. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/entry_points.txt +0 -0
  65. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/licenses/LICENSE.txt +0 -0
  66. {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/licenses/NOTICE +0 -0
execsql/exporters/base.py CHANGED
@@ -146,8 +146,10 @@ class WriteSpec:
146
146
  subvars = _state.subvars
147
147
  if self.repeatable or not self.written:
148
148
  self.written = True
149
- msg = _state.commandliststack[-1].localvars.substitute_all(self.msg)
150
- msg = subvars.substitute_all(msg)
149
+ msg = self.msg
150
+ if _state.commandliststack:
151
+ msg, _ = _state.commandliststack[-1].localvars.substitute_all(msg)
152
+ msg, _ = subvars.substitute_all(msg)
151
153
  if self.outfile:
152
154
  from execsql.utils.fileio import EncodedFile
153
155
 
@@ -159,13 +161,13 @@ class WriteSpec:
159
161
  fh.close()
160
162
  if (not self.outfile) or self.tee:
161
163
  try:
162
- _state.output.write(msg.encode(conf.output_encoding))
164
+ _state.output.write(msg)
163
165
  except ConsoleUIError as e:
164
166
  _state.output.reset()
165
167
  _state.exec_log.log_status_info(
166
168
  f"Console UI write failed (message {{{e.value}}}); output reset to stdout.",
167
169
  )
168
- _state.output.write(msg.encode(conf.output_encoding))
170
+ _state.output.write(msg)
169
171
  if conf.tee_write_log:
170
172
  _state.exec_log.log_user_msg(msg)
171
173
  return None
@@ -31,6 +31,8 @@ from execsql.utils.strings import clean_words, dedup_words, fold_words
31
31
 
32
32
  __all__ = ["LineDelimiter", "CsvFile", "CsvWriter", "DelimitedWriter", "write_delimited_file"]
33
33
 
34
+ _SPACE_DELIM_RX = re.compile(r" +")
35
+
34
36
 
35
37
  class LineDelimiter:
36
38
  """Encapsulates delimiter, quote character, and escape rules for a single line format."""
@@ -211,7 +213,7 @@ class CsvFile(EncodedFile):
211
213
  # to a single delimiter, split on the space(s), and consider the delimiter
212
214
  # count to be one fewer than the items returned.
213
215
  if delim == " ":
214
- self.delim_counts[delim] = max(0, len(re.split(r" +", self.text)) - 1)
216
+ self.delim_counts[delim] = max(0, len(_SPACE_DELIM_RX.split(self.text)) - 1)
215
217
  else:
216
218
  self.delim_counts[delim] = self.text.count(delim)
217
219
 
@@ -798,10 +800,11 @@ def write_delimited_file(
798
800
  if not (filefmt.lower() == "plain" or (append and zipfile is None)):
799
801
  datarow = line_delimiter.delimited(column_headers)
800
802
  ofile.write(datarow)
803
+ buf: list[str] = []
804
+ flush_every = getattr(_state.conf, "export_row_buffer", 1000) if _state.conf else 1000
801
805
  for rec in rowsource:
802
806
  try:
803
- datarow = line_delimiter.delimited(rec)
804
- ofile.write(datarow)
807
+ buf.append(line_delimiter.delimited(rec))
805
808
  except ErrInfo:
806
809
  raise
807
810
  except Exception as e:
@@ -810,5 +813,10 @@ def write_delimited_file(
810
813
  exception_msg=exception_desc(),
811
814
  other_msg=f"Can't write output to file {fdesc}.",
812
815
  ) from e
816
+ if len(buf) >= flush_every:
817
+ ofile.write("".join(buf))
818
+ buf.clear()
819
+ if buf:
820
+ ofile.write("".join(buf))
813
821
  finally:
814
822
  ofile.close()
@@ -56,15 +56,6 @@ def prettyprint_rowset(
56
56
  rcols = range(len(colhdrs))
57
57
  rrows = range(len(rows))
58
58
  colwidths = [max(0, len(colhdrs[j]), *(len(as_ucode(rows[i][j])) for i in rrows)) for j in rcols]
59
- names = " " + " | ".join([colhdrs[j].ljust(colwidths[j]) for j in rcols])
60
- sep = "|".join(["-" * (colwidths[j] + 2) for j in rcols])
61
- rows = [names, sep] + [
62
- " "
63
- + " | ".join(
64
- [as_ucode(rows[i][j]).ljust(colwidths[j]) for j in rcols],
65
- )
66
- for i in rrows
67
- ]
68
59
  if output_dest == "stdout":
69
60
  ofile = _state.output
70
61
  margin = " "
@@ -83,9 +74,15 @@ def prettyprint_rowset(
83
74
  try:
84
75
  if desc is not None:
85
76
  ofile.write(f"{desc}\n")
86
- for row in rows:
87
- ln = f"{margin}{row}\n"
88
- ofile.write(ln)
77
+ names = " " + " | ".join([colhdrs[j].ljust(colwidths[j]) for j in rcols])
78
+ sep = "|".join(["-" * (colwidths[j] + 2) for j in rcols])
79
+ ofile.write(f"{margin}{names}\n")
80
+ ofile.write(f"{margin}{sep}\n")
81
+ for i in rrows:
82
+ line = " " + " | ".join(
83
+ [as_ucode(rows[i][j]).ljust(colwidths[j]) for j in rcols],
84
+ )
85
+ ofile.write(f"{margin}{line}\n")
89
86
  finally:
90
87
  if output_dest != "stdout":
91
88
  ofile.close()
execsql/gui/tui.py CHANGED
@@ -244,7 +244,31 @@ class MsgScreen(_BaseDialog):
244
244
 
245
245
 
246
246
  class PauseScreen(_BaseDialog):
247
- """Pause dialog with optional countdown and Continue/Cancel buttons."""
247
+ """Pause dialog with optional countdown progress bar and Continue/Cancel buttons."""
248
+
249
+ DEFAULT_CSS = (
250
+ _BaseDialog.DEFAULT_CSS
251
+ + """
252
+ #countdown-container {
253
+ height: auto;
254
+ margin: 1 0;
255
+ }
256
+ #countdown-container ProgressBar {
257
+ width: 1fr;
258
+ }
259
+ #countdown-container Bar {
260
+ width: 1fr;
261
+ &> .bar--bar {
262
+ background: $primary 30%;
263
+ }
264
+ }
265
+ #countdown-container .countdown-label {
266
+ text-align: center;
267
+ color: $text-muted;
268
+ width: 1fr;
269
+ }
270
+ """
271
+ )
248
272
 
249
273
  BINDINGS = [
250
274
  *_BaseDialog.BINDINGS,
@@ -254,17 +278,50 @@ class PauseScreen(_BaseDialog):
254
278
  def compose(self) -> ComposeResult:
255
279
  title = self.args.get("title", "Pause")
256
280
  message = self.args.get("message", "")
281
+ countdown = self.args.get("countdown")
257
282
  with Container(id="dialog"):
258
283
  yield Label(title, id="title")
259
284
  yield Static(message, id="message")
285
+ if countdown is not None:
286
+ with Vertical(id="countdown-container"):
287
+ yield ProgressBar(
288
+ total=float(countdown),
289
+ show_percentage=False,
290
+ show_eta=False,
291
+ id="countdown-bar",
292
+ )
293
+ yield Static("", classes="countdown-label")
260
294
  with Horizontal(id="buttons"):
261
295
  yield Button("Cancel", id="btn_cancel_exit", variant="warning")
262
296
  yield Button("Continue", id="btn_continue", variant="primary")
263
297
 
264
298
  def on_mount(self) -> None:
299
+ import time
300
+
265
301
  countdown = self.args.get("countdown")
266
302
  if countdown is not None:
267
- self.set_timer(float(countdown), self._auto_continue)
303
+ self._countdown_total = float(countdown)
304
+ self._countdown_start = time.time()
305
+ self._tick_interval = self.set_interval(0.2, self._tick)
306
+
307
+ def _tick(self) -> None:
308
+ import time
309
+
310
+ elapsed = time.time() - self._countdown_start
311
+ remaining = max(0.0, self._countdown_total - elapsed)
312
+ progress = min(self._countdown_total, elapsed)
313
+ bar = self.query_one("#countdown-bar", ProgressBar)
314
+ bar.update(progress=progress)
315
+ label = self.query_one(".countdown-label", Static)
316
+ if remaining >= 60:
317
+ mins = int(remaining) // 60
318
+ secs = int(remaining) % 60
319
+ label.update(f"{mins}m {secs:02d}s remaining")
320
+ else:
321
+ label.update(f"{remaining:.0f}s remaining")
322
+ if remaining <= 0:
323
+ self._tick_interval.stop()
324
+ self._auto_continue()
268
325
 
269
326
  def _auto_continue(self) -> None:
270
327
  self._result = {"quit": False}
@@ -497,3 +497,6 @@ DATABASE_TYPES = [
497
497
  from execsql.metacommands.dispatch import build_dispatch_table
498
498
 
499
499
  DISPATCH_TABLE = build_dispatch_table()
500
+
501
+ # Plugin discovery is deferred to state.initialize() so it does not run
502
+ # at import time. See execsql.plugins.discover_metacommand_plugins().
@@ -30,6 +30,24 @@ from execsql.utils.gui import gui_console_isrunning
30
30
  from execsql.utils.strings import unquoted
31
31
 
32
32
 
33
+ def _quote_table_name(name: str) -> str:
34
+ """Quote a potentially schema-qualified table name for safe SQL interpolation.
35
+
36
+ Splits on ``.`` and quotes each component with standard SQL double-quoting
37
+ (embedded double-quotes are escaped to ``""``).
38
+
39
+ Examples::
40
+
41
+ >>> _quote_table_name("books")
42
+ '"books"'
43
+ >>> _quote_table_name("staging.books")
44
+ '"staging"."books"'
45
+ >>> _quote_table_name('my"table')
46
+ '"my""table"'
47
+ """
48
+ return ".".join('"' + part.replace('"', '""') + '"' for part in name.split("."))
49
+
50
+
33
51
  def xf_contains(**kwargs: Any) -> bool:
34
52
  s1 = kwargs["string1"]
35
53
  s2 = kwargs["string2"]
@@ -59,7 +77,7 @@ def xf_endswith(**kwargs: Any) -> bool:
59
77
 
60
78
  def xf_hasrows(**kwargs: Any) -> bool:
61
79
  queryname = kwargs["queryname"]
62
- sql = f"select count(*) from {queryname};"
80
+ sql = f"select count(*) from {_quote_table_name(queryname)};"
63
81
  try:
64
82
  hdrs, rec = _state.dbs.current().select_data(sql)
65
83
  except ErrInfo:
@@ -84,7 +102,7 @@ def _row_count(queryname: str, sql_context: str, metacommandline: str) -> int:
84
102
  Raises:
85
103
  ErrInfo: If the query fails or the result is not numeric.
86
104
  """
87
- sql = f"select count(*) from {queryname};"
105
+ sql = f"select count(*) from {_quote_table_name(queryname)};"
88
106
  try:
89
107
  _hdrs, rec = _state.dbs.current().select_data(sql)
90
108
  except ErrInfo:
@@ -1,5 +1,4 @@
1
1
  from __future__ import annotations
2
- from execsql.db.access import AccessDatabase
3
2
 
4
3
  """
5
4
  Database connection metacommand handlers for execsql.
@@ -24,6 +23,7 @@ from pathlib import Path
24
23
  from typing import Any
25
24
 
26
25
  import execsql.state as _state
26
+ from execsql.db.access import AccessDatabase # noqa: F401 — used in x_connect_access; module-level for test patchability
27
27
  from execsql.db.dsn import DsnDatabase
28
28
  from execsql.db.duckdb import DuckDBDatabase
29
29
  from execsql.db.firebird import FirebirdDatabase
@@ -24,8 +24,6 @@ from typing import Any
24
24
  import execsql.state as _state
25
25
  from execsql.script import (
26
26
  CommandList,
27
- CommandListUntilLoop,
28
- CommandListWhileLoop,
29
27
  MetacommandStmt,
30
28
  ScriptCmd,
31
29
  current_script_line,
@@ -122,18 +120,14 @@ def x_if_end(**kwargs: Any) -> None:
122
120
 
123
121
 
124
122
  def x_loop(**kwargs: Any) -> None:
125
- _state.compiling_loop = True
126
- looptype = kwargs["looptype"].upper()
127
- loopcond = kwargs["loopcond"]
128
- listname = "loop" + str(len(_state.loopcommandstack) + 1)
129
- if looptype == "WHILE":
130
- _state.loopcommandstack.append(
131
- CommandListWhileLoop([], listname, paramnames=None, loopcondition=loopcond),
132
- )
133
- else:
134
- _state.loopcommandstack.append(
135
- CommandListUntilLoop([], listname, paramnames=None, loopcondition=loopcond),
136
- )
123
+ # LOOP is now handled natively by the AST executor (_execute_loop).
124
+ # This handler exists only for dispatch table registration compatibility.
125
+ from execsql.exceptions import ErrInfo
126
+
127
+ raise ErrInfo(
128
+ type="cmd",
129
+ other_msg="LOOP should be handled by the AST executor, not the dispatch table.",
130
+ )
137
131
 
138
132
 
139
133
  def x_halt(**kwargs: Any) -> None:
@@ -73,8 +73,9 @@ def x_debug_write_odbc_drivers(**kwargs: Any) -> None:
73
73
  def x_debug_log_subvars(**kwargs: Any) -> None:
74
74
  local = kwargs["local"]
75
75
  user = kwargs["user"]
76
- for s in _state.commandliststack[-1].localvars.substitutions:
77
- _state.exec_log.log_status_info(f"Substitution [{s[0]}] = [{s[1]}]")
76
+ if _state.commandliststack:
77
+ for s in _state.commandliststack[-1].localvars.substitutions:
78
+ _state.exec_log.log_status_info(f"Substitution [{s[0]}] = [{s[1]}]")
78
79
  if local is None:
79
80
  for s in _state.subvars.substitutions:
80
81
  if user is None or s[0][0].isalnum() or s[0][0] == "_":
@@ -142,8 +143,9 @@ def x_debug_write_subvars(**kwargs: Any) -> None:
142
143
  else:
143
144
  filewriter_write(output_dest, txt)
144
145
 
145
- for s in _state.commandliststack[-1].localvars.substitutions:
146
- write(f"Substitution [{s[0]}] = [{s[1]}]\n")
146
+ if _state.commandliststack:
147
+ for s in _state.commandliststack[-1].localvars.substitutions:
148
+ write(f"Substitution [{s[0]}] = [{s[1]}]\n")
147
149
  if local is None:
148
150
  for s in _state.subvars.substitutions:
149
151
  if user is None or s[0][0].isalnum() or s[0][0] == "_":