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.
- execsql/__init__.py +8 -3
- execsql/api.py +580 -0
- execsql/cli/__init__.py +123 -0
- execsql/cli/lint_ast.py +439 -0
- execsql/cli/run.py +113 -102
- execsql/config.py +29 -4
- execsql/db/access.py +1 -0
- execsql/db/base.py +4 -1
- execsql/db/dsn.py +3 -2
- execsql/db/duckdb.py +1 -1
- execsql/db/factory.py +3 -0
- execsql/db/firebird.py +2 -1
- execsql/db/mysql.py +2 -1
- execsql/db/oracle.py +2 -1
- execsql/db/postgres.py +2 -1
- execsql/db/sqlite.py +1 -1
- execsql/db/sqlserver.py +3 -2
- execsql/debug/repl.py +27 -10
- execsql/exporters/base.py +6 -4
- execsql/exporters/delimited.py +11 -3
- execsql/exporters/pretty.py +9 -12
- execsql/gui/tui.py +59 -2
- execsql/metacommands/__init__.py +3 -0
- execsql/metacommands/conditions.py +20 -2
- execsql/metacommands/connect.py +1 -1
- execsql/metacommands/control.py +8 -14
- execsql/metacommands/debug.py +6 -4
- execsql/metacommands/io_export.py +117 -315
- execsql/metacommands/io_fileops.py +7 -13
- execsql/metacommands/io_write.py +1 -1
- execsql/metacommands/script_ext.py +8 -5
- execsql/metacommands/upsert.py +40 -0
- execsql/models.py +8 -12
- execsql/plugins.py +414 -0
- execsql/script/__init__.py +36 -12
- execsql/script/ast.py +562 -0
- execsql/script/engine.py +59 -368
- execsql/script/executor.py +833 -0
- execsql/script/parser.py +663 -0
- execsql/script/variables.py +11 -0
- execsql/state.py +55 -2
- execsql/utils/crypto.py +14 -10
- execsql/utils/errors.py +31 -8
- execsql/utils/gui.py +139 -17
- execsql/utils/mail.py +15 -12
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/METADATA +59 -1
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/RECORD +66 -60
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/WHEEL +0 -0
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/licenses/LICENSE.txt +0 -0
- {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 =
|
|
150
|
-
|
|
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
|
|
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
|
|
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
|
execsql/exporters/delimited.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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()
|
execsql/exporters/pretty.py
CHANGED
|
@@ -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
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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}
|
execsql/metacommands/__init__.py
CHANGED
|
@@ -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:
|
execsql/metacommands/connect.py
CHANGED
|
@@ -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
|
execsql/metacommands/control.py
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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:
|
execsql/metacommands/debug.py
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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] == "_":
|