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.
- execsql/api.py +29 -4
- execsql/cli/__init__.py +1 -1
- execsql/cli/run.py +37 -17
- execsql/config.py +40 -22
- execsql/data/execsql.conf.template +6 -0
- execsql/db/access.py +6 -5
- execsql/db/base.py +23 -5
- execsql/db/dsn.py +2 -0
- execsql/db/duckdb.py +1 -1
- execsql/db/mysql.py +6 -8
- execsql/db/sqlite.py +1 -1
- execsql/exceptions.py +14 -11
- execsql/exporters/base.py +1 -1
- execsql/exporters/delimited.py +22 -16
- execsql/exporters/duckdb.py +6 -1
- execsql/exporters/feather.py +1 -1
- execsql/exporters/json.py +5 -3
- execsql/exporters/latex.py +2 -2
- execsql/exporters/markdown.py +1 -0
- execsql/exporters/ods.py +7 -3
- execsql/exporters/raw.py +8 -8
- execsql/exporters/sqlite.py +11 -1
- execsql/exporters/templates.py +2 -1
- execsql/exporters/xls.py +10 -10
- execsql/exporters/xml.py +1 -0
- execsql/exporters/yaml.py +1 -0
- execsql/exporters/zip.py +9 -7
- execsql/format.py +6 -6
- execsql/gui/console.py +3 -3
- execsql/gui/desktop.py +28 -10
- execsql/gui/tui.py +8 -6
- execsql/importers/base.py +14 -8
- execsql/importers/csv.py +8 -3
- execsql/importers/xls.py +1 -0
- execsql/metacommands/conditions.py +14 -14
- execsql/metacommands/connect.py +2 -2
- execsql/metacommands/control.py +1 -1
- execsql/metacommands/data.py +2 -1
- execsql/metacommands/io_export.py +4 -0
- execsql/metacommands/io_fileops.py +85 -39
- execsql/metacommands/io_import.py +1 -0
- execsql/metacommands/io_write.py +1 -1
- execsql/metacommands/prompt.py +24 -22
- execsql/metacommands/system.py +14 -1
- execsql/models.py +56 -17
- execsql/parser.py +8 -8
- execsql/plugins.py +1 -1
- execsql/script/engine.py +4 -4
- execsql/script/executor.py +69 -28
- execsql/script/parser.py +13 -12
- execsql/script/variables.py +4 -2
- execsql/state.pyi +122 -0
- execsql/types.py +31 -26
- execsql/utils/auth.py +6 -2
- execsql/utils/datetime.py +3 -3
- execsql/utils/errors.py +7 -5
- execsql/utils/fileio.py +80 -32
- execsql/utils/gui.py +15 -11
- execsql/utils/mail.py +9 -2
- execsql/utils/numeric.py +1 -1
- execsql/utils/regex.py +9 -9
- {execsql2-2.21.2.dist-info → execsql2-2.22.1.dist-info}/METADATA +114 -132
- execsql2-2.22.1.dist-info/RECORD +123 -0
- execsql2-2.21.2.dist-info/RECORD +0 -122
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.21.2.data → execsql2-2.22.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.21.2.dist-info → execsql2-2.22.1.dist-info}/WHEEL +0 -0
- {execsql2-2.21.2.dist-info → execsql2-2.22.1.dist-info}/entry_points.txt +0 -0
- {execsql2-2.21.2.dist-info → execsql2-2.22.1.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.21.2.dist-info → execsql2-2.22.1.dist-info}/licenses/NOTICE +0 -0
execsql/api.py
CHANGED
|
@@ -35,7 +35,7 @@ import io
|
|
|
35
35
|
import os
|
|
36
36
|
import time
|
|
37
37
|
from pathlib import Path
|
|
38
|
-
from typing import Any
|
|
38
|
+
from typing import Any, cast
|
|
39
39
|
|
|
40
40
|
from execsql.cli.dsn import _parse_connection_string
|
|
41
41
|
from execsql.config import ConfigData, StatObj, WriteHooks
|
|
@@ -170,6 +170,7 @@ class _LibraryConfig:
|
|
|
170
170
|
self.log_datavars = False
|
|
171
171
|
self.show_progress = False
|
|
172
172
|
self.max_log_size_mb = 0
|
|
173
|
+
self.system_cmd_timeout = 0.0
|
|
173
174
|
|
|
174
175
|
# Data handling
|
|
175
176
|
self.boolean_int = True
|
|
@@ -334,6 +335,8 @@ def run(
|
|
|
334
335
|
halt_on_error: bool = True,
|
|
335
336
|
new_db: bool = False,
|
|
336
337
|
allow_system_cmd: bool = True,
|
|
338
|
+
allow_rm_file: bool = True,
|
|
339
|
+
allow_serve: bool = True,
|
|
337
340
|
) -> ScriptResult:
|
|
338
341
|
"""Execute a SQL script and return the result.
|
|
339
342
|
|
|
@@ -359,6 +362,13 @@ def run(
|
|
|
359
362
|
disabled and will raise an error if encountered. Matches the
|
|
360
363
|
``--no-system-cmd`` CLI flag and the ``allow_system_cmd``
|
|
361
364
|
config option.
|
|
365
|
+
allow_rm_file: If ``False``, the RM_FILE metacommand is disabled
|
|
366
|
+
and will raise an error if encountered. Matches the
|
|
367
|
+
``--no-rm-file`` CLI flag and the ``allow_rm_file`` config
|
|
368
|
+
option.
|
|
369
|
+
allow_serve: If ``False``, the SERVE metacommand is disabled and
|
|
370
|
+
will raise an error if encountered. Matches the ``--no-serve``
|
|
371
|
+
CLI flag and the ``allow_serve`` config option.
|
|
362
372
|
|
|
363
373
|
Returns:
|
|
364
374
|
A :class:`ScriptResult` with execution outcome, timing, errors,
|
|
@@ -391,6 +401,7 @@ def run(
|
|
|
391
401
|
if script is not None:
|
|
392
402
|
tree = parse_script(str(script), encoding=encoding)
|
|
393
403
|
else:
|
|
404
|
+
assert sql is not None
|
|
394
405
|
tree = parse_string(sql, source_name="<inline>")
|
|
395
406
|
except ErrInfo as exc:
|
|
396
407
|
return ScriptResult(
|
|
@@ -408,6 +419,7 @@ def run(
|
|
|
408
419
|
|
|
409
420
|
# Configuration
|
|
410
421
|
conf_overrides: dict[str, Any] = {"script_encoding": encoding}
|
|
422
|
+
conf: Any
|
|
411
423
|
if config_file is not None:
|
|
412
424
|
# Load a real ConfigData with the explicit config file
|
|
413
425
|
from execsql.script.variables import SubVarSet
|
|
@@ -457,6 +469,8 @@ def run(
|
|
|
457
469
|
ctx.status = StatObj()
|
|
458
470
|
ctx.status.halt_on_err = halt_on_error
|
|
459
471
|
conf.allow_system_cmd = allow_system_cmd
|
|
472
|
+
conf.allow_rm_file = allow_rm_file
|
|
473
|
+
conf.allow_serve = allow_serve
|
|
460
474
|
ctx.conf = conf
|
|
461
475
|
|
|
462
476
|
# Capture output to a buffer (suppress stdout/stderr)
|
|
@@ -465,7 +479,7 @@ def run(
|
|
|
465
479
|
ctx.output = WriteHooks(stdout_buf.write, stderr_buf.write)
|
|
466
480
|
|
|
467
481
|
# No log file for library use
|
|
468
|
-
ctx.exec_log = _NoOpLogger()
|
|
482
|
+
ctx.exec_log = cast(Any, _NoOpLogger())
|
|
469
483
|
|
|
470
484
|
with active_context(ctx):
|
|
471
485
|
# Initialize singletons (CounterVars, Timer, DatabasePool, BatchLevels, etc.)
|
|
@@ -482,6 +496,8 @@ def run(
|
|
|
482
496
|
else:
|
|
483
497
|
db = connection
|
|
484
498
|
|
|
499
|
+
assert db is not None
|
|
500
|
+
assert ctx.dbs is not None
|
|
485
501
|
ctx.dbs.add("initial", db)
|
|
486
502
|
ctx.subvars.add_substitution("$CURRENT_DBMS", db.type.dbms_id)
|
|
487
503
|
ctx.subvars.add_substitution("$CURRENT_DATABASE", db.name())
|
|
@@ -512,6 +528,14 @@ def run(
|
|
|
512
528
|
except Exception as exc:
|
|
513
529
|
errors.append(ScriptError(message=str(exc), source="<runtime>"))
|
|
514
530
|
|
|
531
|
+
# Non-halting errors (halt_on_error=False, METACOMMAND_ERROR_HALT OFF)
|
|
532
|
+
# are recorded in status.error_history as execution continues; a
|
|
533
|
+
# halting error never reaches the history, so there is no overlap
|
|
534
|
+
# with the exception paths above.
|
|
535
|
+
if ctx.status is not None:
|
|
536
|
+
for err_source, err_line, err_cmd, err_msg in ctx.status.error_history:
|
|
537
|
+
errors.append(ScriptError(message=err_msg, source=err_source, line=err_line, sql=err_cmd))
|
|
538
|
+
|
|
515
539
|
elapsed = time.perf_counter() - t0
|
|
516
540
|
|
|
517
541
|
# ------------------------------------------------------------------
|
|
@@ -530,6 +554,7 @@ def run(
|
|
|
530
554
|
# Close connection if we own it
|
|
531
555
|
if owns_connection:
|
|
532
556
|
try:
|
|
557
|
+
assert ctx.dbs is not None
|
|
533
558
|
ctx.dbs.closeall()
|
|
534
559
|
except Exception:
|
|
535
560
|
pass
|
|
@@ -572,7 +597,7 @@ def _last_source(ctx: RuntimeContext) -> str:
|
|
|
572
597
|
"""Get the source file from the last executed command."""
|
|
573
598
|
lc = ctx.last_command
|
|
574
599
|
if lc is not None and hasattr(lc, "source"):
|
|
575
|
-
return lc.source
|
|
600
|
+
return cast(str, lc.source)
|
|
576
601
|
return "<unknown>"
|
|
577
602
|
|
|
578
603
|
|
|
@@ -580,7 +605,7 @@ def _last_line(ctx: RuntimeContext) -> int | None:
|
|
|
580
605
|
"""Get the line number from the last executed command."""
|
|
581
606
|
lc = ctx.last_command
|
|
582
607
|
if lc is not None and hasattr(lc, "line_no"):
|
|
583
|
-
return lc.line_no
|
|
608
|
+
return cast(int | None, lc.line_no)
|
|
584
609
|
return None
|
|
585
610
|
|
|
586
611
|
|
execsql/cli/__init__.py
CHANGED
|
@@ -292,7 +292,7 @@ def main(
|
|
|
292
292
|
"-a",
|
|
293
293
|
"--assign-arg",
|
|
294
294
|
metavar="VALUE",
|
|
295
|
-
help="Define the replacement string for a substitution variable [cyan]
|
|
295
|
+
help="Define the replacement string for a substitution variable [cyan]$ARG_x[/cyan].",
|
|
296
296
|
),
|
|
297
297
|
user_logfile: bool = typer.Option(
|
|
298
298
|
False,
|
execsql/cli/run.py
CHANGED
|
@@ -7,15 +7,15 @@ and drives the main execution loop. Separated from argument parsing
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
import atexit
|
|
11
|
-
from typing import Any
|
|
12
10
|
import datetime
|
|
11
|
+
import atexit
|
|
13
12
|
import getpass
|
|
14
13
|
import os
|
|
15
14
|
import platform
|
|
16
15
|
import sys
|
|
17
16
|
import traceback
|
|
18
17
|
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
19
|
|
|
20
20
|
from execsql import __version__
|
|
21
21
|
from execsql.cli.dsn import _parse_connection_string
|
|
@@ -26,6 +26,9 @@ from execsql.script import SubVarSet, current_script_line, substitute_vars
|
|
|
26
26
|
from execsql.utils.fileio import FileWriter, Logger, filewriter_end
|
|
27
27
|
from execsql.utils.gui import gui_connect, gui_console_isrunning, gui_console_off, gui_console_on, gui_console_wait_user
|
|
28
28
|
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from execsql.script.ast import Script
|
|
31
|
+
|
|
29
32
|
__all__ = ["_connect_initial_db", "_ping_db", "_print_dry_run", "_print_profile", "_run"]
|
|
30
33
|
|
|
31
34
|
# Lint helper — imported lazily inside _run() to keep start-up cost low, but
|
|
@@ -38,7 +41,7 @@ from execsql.cli.lint import _print_lint_results # noqa: F401 — re-export (us
|
|
|
38
41
|
# ---------------------------------------------------------------------------
|
|
39
42
|
|
|
40
43
|
|
|
41
|
-
def _print_dry_run(tree:
|
|
44
|
+
def _print_dry_run(tree: Script | None) -> None:
|
|
42
45
|
"""Print the parsed AST for --dry-run mode.
|
|
43
46
|
|
|
44
47
|
Walks the AST tree and prints each SQL statement and metacommand with
|
|
@@ -304,7 +307,7 @@ def _load_script(
|
|
|
304
307
|
command: str | None,
|
|
305
308
|
script_name: str | None,
|
|
306
309
|
encoding: str,
|
|
307
|
-
) ->
|
|
310
|
+
) -> Script | None:
|
|
308
311
|
"""Parse the SQL script (or inline command) into an AST.
|
|
309
312
|
|
|
310
313
|
Returns the AST tree, or ``None`` if no script is provided.
|
|
@@ -316,6 +319,8 @@ def _load_script(
|
|
|
316
319
|
command.replace("\\n", "\n").replace("\\t", "\t"),
|
|
317
320
|
"<inline>",
|
|
318
321
|
)
|
|
322
|
+
if script_name is None:
|
|
323
|
+
return None
|
|
319
324
|
return parse_script(script_name, encoding=encoding)
|
|
320
325
|
|
|
321
326
|
|
|
@@ -515,7 +520,7 @@ def _setup_logging(
|
|
|
515
520
|
}
|
|
516
521
|
logger = Logger(
|
|
517
522
|
script_name or "<inline>",
|
|
518
|
-
conf.db,
|
|
523
|
+
conf.db or "",
|
|
519
524
|
conf.server,
|
|
520
525
|
opts_dict,
|
|
521
526
|
conf.user_logfile,
|
|
@@ -537,6 +542,7 @@ def _setup_logging(
|
|
|
537
542
|
for n, repl in enumerate(sub_vars):
|
|
538
543
|
var = f"$ARG_{n + 1}"
|
|
539
544
|
subvars.add_substitution(var, repl)
|
|
545
|
+
logger.add_redaction_value(repl)
|
|
540
546
|
# `-a` values are positional and opaque (no name to denylist
|
|
541
547
|
# against). High-entropy secrets (sk-live-*, AKIA*, ghp_*,
|
|
542
548
|
# JWTs) pass any substring/value heuristic, so log the
|
|
@@ -898,8 +904,9 @@ def _execute_script_textual_console(tree: Any, conf: ConfigData) -> None:
|
|
|
898
904
|
dialog_queue = _ConsoleDialogQueue()
|
|
899
905
|
_state.gui_manager_queue = dialog_queue
|
|
900
906
|
|
|
907
|
+
ctx = _state.get_context()
|
|
901
908
|
app = ConsoleApp(
|
|
902
|
-
script_runner=lambda: execute(tree),
|
|
909
|
+
script_runner=lambda: execute(tree, ctx=ctx),
|
|
903
910
|
dialog_queue=dialog_queue,
|
|
904
911
|
wait_on_exit=conf.gui_wait_on_exit,
|
|
905
912
|
)
|
|
@@ -945,6 +952,16 @@ def _execute_script_textual_console(tree: Any, conf: ConfigData) -> None:
|
|
|
945
952
|
# ---------------------------------------------------------------------------
|
|
946
953
|
|
|
947
954
|
|
|
955
|
+
def _required_connection_value(value: str | None, description: str) -> str:
|
|
956
|
+
"""Return a required connection value or stop with the standard fatal path."""
|
|
957
|
+
if value is None:
|
|
958
|
+
from execsql.utils.errors import fatal_error
|
|
959
|
+
|
|
960
|
+
fatal_error(f"Configured database requires {description}, but none is provided.")
|
|
961
|
+
raise SystemExit(1)
|
|
962
|
+
return value
|
|
963
|
+
|
|
964
|
+
|
|
948
965
|
def _connect_initial_db(conf: ConfigData):
|
|
949
966
|
"""Create and return the initial database object based on conf.db_type."""
|
|
950
967
|
from execsql.db.factory import (
|
|
@@ -964,6 +981,7 @@ def _connect_initial_db(conf: ConfigData):
|
|
|
964
981
|
from execsql.utils.errors import fatal_error
|
|
965
982
|
|
|
966
983
|
fatal_error("Configured to run with MS-Access, but no Access file name is provided.")
|
|
984
|
+
raise SystemExit(1)
|
|
967
985
|
return db_Access(
|
|
968
986
|
conf.db_file,
|
|
969
987
|
pw_needed=conf.passwd_prompt and conf.access_username is not None,
|
|
@@ -972,8 +990,8 @@ def _connect_initial_db(conf: ConfigData):
|
|
|
972
990
|
)
|
|
973
991
|
elif conf.db_type == "p":
|
|
974
992
|
return db_Postgres(
|
|
975
|
-
conf.server,
|
|
976
|
-
conf.db,
|
|
993
|
+
_required_connection_value(conf.server, "a server name"),
|
|
994
|
+
_required_connection_value(conf.db, "a database name"),
|
|
977
995
|
user=conf.username,
|
|
978
996
|
pw_needed=conf.passwd_prompt,
|
|
979
997
|
port=conf.port,
|
|
@@ -983,8 +1001,8 @@ def _connect_initial_db(conf: ConfigData):
|
|
|
983
1001
|
)
|
|
984
1002
|
elif conf.db_type == "s":
|
|
985
1003
|
return db_SqlServer(
|
|
986
|
-
conf.server,
|
|
987
|
-
conf.db,
|
|
1004
|
+
_required_connection_value(conf.server, "a server name"),
|
|
1005
|
+
_required_connection_value(conf.db, "a database name"),
|
|
988
1006
|
user=conf.username,
|
|
989
1007
|
pw_needed=conf.passwd_prompt,
|
|
990
1008
|
port=conf.port,
|
|
@@ -996,11 +1014,12 @@ def _connect_initial_db(conf: ConfigData):
|
|
|
996
1014
|
from execsql.utils.errors import fatal_error
|
|
997
1015
|
|
|
998
1016
|
fatal_error("Configured to run with SQLite, but no SQLite file name is provided.")
|
|
1017
|
+
raise SystemExit(1)
|
|
999
1018
|
return db_SQLite(conf.db_file, new_db=conf.new_db, encoding=conf.db_encoding)
|
|
1000
1019
|
elif conf.db_type == "m":
|
|
1001
1020
|
return db_MySQL(
|
|
1002
|
-
conf.server,
|
|
1003
|
-
conf.db,
|
|
1021
|
+
_required_connection_value(conf.server, "a server name"),
|
|
1022
|
+
_required_connection_value(conf.db, "a database name"),
|
|
1004
1023
|
user=conf.username,
|
|
1005
1024
|
pw_needed=conf.passwd_prompt,
|
|
1006
1025
|
port=conf.port,
|
|
@@ -1012,11 +1031,12 @@ def _connect_initial_db(conf: ConfigData):
|
|
|
1012
1031
|
from execsql.utils.errors import fatal_error
|
|
1013
1032
|
|
|
1014
1033
|
fatal_error("Configured to run with DuckDB, but no DuckDB file name is provided.")
|
|
1034
|
+
raise SystemExit(1)
|
|
1015
1035
|
return db_DuckDB(conf.db_file, new_db=conf.new_db, encoding=conf.db_encoding)
|
|
1016
1036
|
elif conf.db_type == "o":
|
|
1017
1037
|
return db_Oracle(
|
|
1018
|
-
conf.server,
|
|
1019
|
-
conf.db,
|
|
1038
|
+
_required_connection_value(conf.server, "a server name"),
|
|
1039
|
+
_required_connection_value(conf.db, "a database name"),
|
|
1020
1040
|
user=conf.username,
|
|
1021
1041
|
pw_needed=conf.passwd_prompt,
|
|
1022
1042
|
port=conf.port,
|
|
@@ -1025,8 +1045,8 @@ def _connect_initial_db(conf: ConfigData):
|
|
|
1025
1045
|
)
|
|
1026
1046
|
elif conf.db_type == "f":
|
|
1027
1047
|
return db_Firebird(
|
|
1028
|
-
conf.server,
|
|
1029
|
-
conf.db,
|
|
1048
|
+
_required_connection_value(conf.server, "a server name"),
|
|
1049
|
+
_required_connection_value(conf.db, "a database name"),
|
|
1030
1050
|
user=conf.username,
|
|
1031
1051
|
pw_needed=conf.passwd_prompt,
|
|
1032
1052
|
port=conf.port,
|
|
@@ -1035,7 +1055,7 @@ def _connect_initial_db(conf: ConfigData):
|
|
|
1035
1055
|
)
|
|
1036
1056
|
elif conf.db_type == "d":
|
|
1037
1057
|
return db_Dsn(
|
|
1038
|
-
conf.db,
|
|
1058
|
+
_required_connection_value(conf.db, "a DSN name"),
|
|
1039
1059
|
user=conf.username,
|
|
1040
1060
|
pw_needed=conf.passwd_prompt,
|
|
1041
1061
|
encoding=conf.db_encoding,
|
execsql/config.py
CHANGED
|
@@ -17,8 +17,10 @@ Provides three classes:
|
|
|
17
17
|
|
|
18
18
|
import os
|
|
19
19
|
import sys
|
|
20
|
+
from collections.abc import Callable
|
|
20
21
|
from configparser import ConfigParser
|
|
21
22
|
from pathlib import Path
|
|
23
|
+
from typing import Protocol
|
|
22
24
|
|
|
23
25
|
from execsql.exceptions import ConfigError
|
|
24
26
|
from execsql.utils.crypto import Encrypt
|
|
@@ -30,6 +32,12 @@ __all__ = [
|
|
|
30
32
|
]
|
|
31
33
|
|
|
32
34
|
|
|
35
|
+
class _VariablePool(Protocol):
|
|
36
|
+
def substitute(self, strval: str) -> tuple[str, int]: ...
|
|
37
|
+
def var_name_ok(self, varname: str) -> bool: ...
|
|
38
|
+
def add_substitution(self, varname: str, strval: object) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
33
41
|
class StatObj:
|
|
34
42
|
"""Lightweight container for runtime status flags used by the metacommand engine.
|
|
35
43
|
|
|
@@ -48,6 +56,9 @@ class StatObj:
|
|
|
48
56
|
self.metacommand_error = False
|
|
49
57
|
self.cancel_halt = True
|
|
50
58
|
self.dialog_canceled = False
|
|
59
|
+
# Cumulative record of non-halting errors: (source, line_no, command, message).
|
|
60
|
+
# Unlike sql_error/metacommand_error, this is never reset between statements.
|
|
61
|
+
self.error_history: list[tuple[str, int | None, str, str]] = []
|
|
51
62
|
# BatchLevels is defined in the main module; defer import to avoid circular deps
|
|
52
63
|
from execsql.script import BatchLevels
|
|
53
64
|
|
|
@@ -315,6 +326,7 @@ class ConfigData:
|
|
|
315
326
|
self._get_bool(cp, self._CONFIG_SECTION, "log_sql", "log_sql")
|
|
316
327
|
self._get_int(cp, self._CONFIG_SECTION, "max_log_size_mb", "max_log_size_mb")
|
|
317
328
|
self._get_bool(cp, self._CONFIG_SECTION, "allow_system_cmd", "allow_system_cmd")
|
|
329
|
+
self._get_float(cp, self._CONFIG_SECTION, "system_cmd_timeout", "system_cmd_timeout", min_val=0.0)
|
|
318
330
|
self._get_bool(cp, self._CONFIG_SECTION, "allow_rm_file", "allow_rm_file")
|
|
319
331
|
self._get_bool(cp, self._CONFIG_SECTION, "allow_serve", "allow_serve")
|
|
320
332
|
self._get_str(cp, self._CONFIG_SECTION, "include_root", "include_root")
|
|
@@ -344,7 +356,7 @@ class ConfigData:
|
|
|
344
356
|
def __init__(
|
|
345
357
|
self,
|
|
346
358
|
script_path: str,
|
|
347
|
-
variable_pool:
|
|
359
|
+
variable_pool: _VariablePool,
|
|
348
360
|
*,
|
|
349
361
|
config_file: str | None = None,
|
|
350
362
|
) -> None:
|
|
@@ -361,22 +373,23 @@ class ConfigData:
|
|
|
361
373
|
precedence over system, user, script, and working-directory
|
|
362
374
|
config files.
|
|
363
375
|
"""
|
|
364
|
-
self.db_type = "l"
|
|
365
|
-
self.server = None
|
|
366
|
-
self.port = None
|
|
367
|
-
self.db = None
|
|
368
|
-
self.username = None
|
|
369
|
-
self.access_username = None
|
|
376
|
+
self.db_type: str | None = "l"
|
|
377
|
+
self.server: str | None = None
|
|
378
|
+
self.port: int | None = None
|
|
379
|
+
self.db: str | None = None
|
|
380
|
+
self.username: str | None = None
|
|
381
|
+
self.access_username: str | None = None
|
|
382
|
+
self.db_password: str | None = None
|
|
370
383
|
self.passwd_prompt = True
|
|
371
384
|
self.use_keyring = True
|
|
372
|
-
self.db_file = None
|
|
385
|
+
self.db_file: str | None = None
|
|
373
386
|
self.new_db = False
|
|
374
387
|
self.user_logfile = False
|
|
375
|
-
self.db_encoding = None
|
|
388
|
+
self.db_encoding: str | None = None
|
|
376
389
|
self.script_encoding = "utf8"
|
|
377
390
|
self.output_encoding = "utf8"
|
|
378
391
|
self.import_encoding = "utf8"
|
|
379
|
-
self.enc_err_disposition = None
|
|
392
|
+
self.enc_err_disposition: str | None = None
|
|
380
393
|
self.import_common_cols_only = False
|
|
381
394
|
self.max_int = 2147483647
|
|
382
395
|
self.boolean_int = True
|
|
@@ -396,17 +409,17 @@ class ConfigData:
|
|
|
396
409
|
self.scan_lines = 100
|
|
397
410
|
self.hdf5_text_len = 1000
|
|
398
411
|
self.write_warnings = False
|
|
399
|
-
self.write_prefix = None
|
|
400
|
-
self.write_suffix = None
|
|
401
|
-
self.gui_level = 0
|
|
412
|
+
self.write_prefix: str | None = None
|
|
413
|
+
self.write_suffix: str | None = None
|
|
414
|
+
self.gui_level: int = 0
|
|
402
415
|
self.gui_framework = "tkinter"
|
|
403
416
|
self.gui_wait_on_exit = False
|
|
404
417
|
self.gui_wait_on_error_halt = False
|
|
405
418
|
self.gui_console_height = 25
|
|
406
419
|
self.gui_console_width = 100
|
|
407
420
|
self.import_buffer = 32 * 1024
|
|
408
|
-
self.css_file = None
|
|
409
|
-
self.css_styles = None
|
|
421
|
+
self.css_file: str | None = None
|
|
422
|
+
self.css_styles: str | None = None
|
|
410
423
|
self.make_export_dirs = False
|
|
411
424
|
self.outfile_open_timeout = 600
|
|
412
425
|
self.quote_all_text = False
|
|
@@ -414,7 +427,7 @@ class ConfigData:
|
|
|
414
427
|
self.import_progress_interval = 0
|
|
415
428
|
self.show_progress = False
|
|
416
429
|
self.export_row_buffer = 1000
|
|
417
|
-
self.template_processor = None
|
|
430
|
+
self.template_processor: str | None = None
|
|
418
431
|
self.tee_write_log = False
|
|
419
432
|
self.log_datavars = True
|
|
420
433
|
self.log_sql = False
|
|
@@ -432,6 +445,7 @@ class ConfigData:
|
|
|
432
445
|
self.export_output_dir: str | None = None
|
|
433
446
|
self.dao_flush_delay_secs = 5.0
|
|
434
447
|
self.allow_system_cmd = True
|
|
448
|
+
self.system_cmd_timeout = 0.0
|
|
435
449
|
# B05: path-containment controls. ``*_root`` keys, when set, force
|
|
436
450
|
# the corresponding handler to confine resolved paths under the
|
|
437
451
|
# named root and reject anything that escapes via ``..``,
|
|
@@ -618,9 +632,9 @@ class WriteHooks:
|
|
|
618
632
|
|
|
619
633
|
def __init__(
|
|
620
634
|
self,
|
|
621
|
-
standard_output_func: object = None,
|
|
622
|
-
error_output_func: object = None,
|
|
623
|
-
status_output_func: object = None,
|
|
635
|
+
standard_output_func: Callable[[str], object] | None = None,
|
|
636
|
+
error_output_func: Callable[[str], object] | None = None,
|
|
637
|
+
status_output_func: Callable[[str], object] | None = None,
|
|
624
638
|
) -> None:
|
|
625
639
|
"""Store optional hook callables; ``None`` means use the default stream.
|
|
626
640
|
|
|
@@ -648,16 +662,20 @@ class WriteHooks:
|
|
|
648
662
|
self.write_func = None
|
|
649
663
|
self.err_func = None
|
|
650
664
|
|
|
651
|
-
def redir_stdout(self, standard_output_func: object) -> None:
|
|
665
|
+
def redir_stdout(self, standard_output_func: Callable[[str], object]) -> None:
|
|
652
666
|
"""Replace the standard-output hook with the given callable."""
|
|
653
667
|
self.write_func = standard_output_func
|
|
654
668
|
|
|
655
|
-
def redir_stderr(self, error_output_func: object, tee: bool = True) -> None:
|
|
669
|
+
def redir_stderr(self, error_output_func: Callable[[str], object], tee: bool = True) -> None:
|
|
656
670
|
"""Replace the error-output hook and optionally keep tee-to-stderr behaviour."""
|
|
657
671
|
self.err_func = error_output_func
|
|
658
672
|
self.tee_stderr = tee
|
|
659
673
|
|
|
660
|
-
def redir(
|
|
674
|
+
def redir(
|
|
675
|
+
self,
|
|
676
|
+
standard_output_func: Callable[[str], object],
|
|
677
|
+
error_output_func: Callable[[str], object],
|
|
678
|
+
) -> None:
|
|
661
679
|
"""Redirect both stdout and stderr hooks in one call."""
|
|
662
680
|
self.redir_stdout(standard_output_func)
|
|
663
681
|
self.redir_stderr(error_output_func)
|
|
@@ -279,6 +279,12 @@
|
|
|
279
279
|
# Values: Yes or No. Default: Yes.
|
|
280
280
|
#allow_system_cmd=Yes
|
|
281
281
|
|
|
282
|
+
# Maximum seconds a foreground SYSTEM_CMD may run before execsql stops it.
|
|
283
|
+
# Set to 0 for no timeout. SYSTEM_CMD ... CONTINUE is detached and is not
|
|
284
|
+
# timeout-managed by this setting.
|
|
285
|
+
# Value: A non-negative number of seconds. Default: 0.
|
|
286
|
+
#system_cmd_timeout=0
|
|
287
|
+
|
|
282
288
|
# Whether to allow the RM_FILE metacommand (which deletes a file).
|
|
283
289
|
# Set to No to prevent scripts from deleting files.
|
|
284
290
|
# Also controllable via --no-rm-file CLI flag.
|
execsql/db/access.py
CHANGED
|
@@ -12,7 +12,7 @@ import datetime
|
|
|
12
12
|
import re
|
|
13
13
|
import time
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import Any
|
|
15
|
+
from typing import Any, cast
|
|
16
16
|
|
|
17
17
|
from execsql.db.base import Database
|
|
18
18
|
from execsql.exceptions import ErrInfo
|
|
@@ -69,7 +69,7 @@ class AccessDatabase(Database):
|
|
|
69
69
|
# Encoding is only applicable to Jet < 4.0: non-accdb databases.
|
|
70
70
|
self.encoding = encoding or "windows-1252"
|
|
71
71
|
self.encode_commands = True
|
|
72
|
-
self.dao_conn = None
|
|
72
|
+
self.dao_conn: Any = None
|
|
73
73
|
self.conn = None # ODBC connection
|
|
74
74
|
self.paramstr = "?"
|
|
75
75
|
self.dt_cast = dict(self.dt_cast) # Copy the lazy-initialized default before overriding.
|
|
@@ -253,6 +253,7 @@ class AccessDatabase(Database):
|
|
|
253
253
|
else:
|
|
254
254
|
self.dao_flush_check()
|
|
255
255
|
with self._cursor() as curs:
|
|
256
|
+
encoded_sql: Any
|
|
256
257
|
if self.jet4:
|
|
257
258
|
encoded_sql = str(sql)
|
|
258
259
|
else:
|
|
@@ -398,7 +399,7 @@ class AccessDatabase(Database):
|
|
|
398
399
|
tablename = self.type.quoted(tablename)
|
|
399
400
|
self.execute(f"drop table {tablename};")
|
|
400
401
|
|
|
401
|
-
def as_datetime(self, val: Any) -> datetime.datetime | None:
|
|
402
|
+
def as_datetime(self, val: Any) -> datetime.datetime | datetime.date | datetime.time | None:
|
|
402
403
|
"""Convert a value to a datetime object suitable for Access."""
|
|
403
404
|
from execsql.types import DT_Timestamp, DT_Date, DT_Time, DataTypeError
|
|
404
405
|
|
|
@@ -414,7 +415,7 @@ class AccessDatabase(Database):
|
|
|
414
415
|
v = DT_Date().from_data(val)
|
|
415
416
|
except DataTypeError:
|
|
416
417
|
# If this generates an exception, let it go up to get caught.
|
|
417
|
-
v = DT_Time().from_data(val)
|
|
418
|
+
v = cast(datetime.time, DT_Time().from_data(val))
|
|
418
419
|
n = datetime.datetime.now()
|
|
419
420
|
v = datetime.datetime(
|
|
420
421
|
n.year,
|
|
@@ -427,7 +428,7 @@ class AccessDatabase(Database):
|
|
|
427
428
|
)
|
|
428
429
|
except Exception:
|
|
429
430
|
raise
|
|
430
|
-
return v
|
|
431
|
+
return cast(datetime.datetime, v)
|
|
431
432
|
|
|
432
433
|
def int_or_bool(self, val: Any) -> int | None:
|
|
433
434
|
"""Convert a value to an integer, recognizing Access boolean values."""
|
execsql/db/base.py
CHANGED
|
@@ -67,6 +67,19 @@ class Database(ABC):
|
|
|
67
67
|
``_state.dbs.current()`` rather than constructing adapters directly.
|
|
68
68
|
"""
|
|
69
69
|
|
|
70
|
+
type: Any
|
|
71
|
+
server_name: Any
|
|
72
|
+
db_name: Any
|
|
73
|
+
user: Any
|
|
74
|
+
need_passwd: bool | None
|
|
75
|
+
password: str | None
|
|
76
|
+
port: int | None
|
|
77
|
+
encoding: Any
|
|
78
|
+
encode_commands: bool
|
|
79
|
+
paramstr: str
|
|
80
|
+
conn: Any
|
|
81
|
+
autocommit: bool
|
|
82
|
+
|
|
70
83
|
_dt_cast: dict[type, Callable] | None = None
|
|
71
84
|
|
|
72
85
|
@property
|
|
@@ -90,7 +103,7 @@ class Database(ABC):
|
|
|
90
103
|
encoding: str | None = None,
|
|
91
104
|
) -> None:
|
|
92
105
|
"""Initialize common connection attributes for a database backend."""
|
|
93
|
-
self.type = None
|
|
106
|
+
self.type: Any = None
|
|
94
107
|
self.server_name = server_name
|
|
95
108
|
self.db_name = db_name
|
|
96
109
|
self.user = user_name
|
|
@@ -697,12 +710,14 @@ class Database(ABC):
|
|
|
697
710
|
)
|
|
698
711
|
import_cols = csv_cols
|
|
699
712
|
|
|
713
|
+
tablespec_cache: Any = None
|
|
714
|
+
|
|
700
715
|
def get_ts() -> Any:
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
716
|
+
nonlocal tablespec_cache
|
|
717
|
+
if not tablespec_cache:
|
|
718
|
+
tablespec_cache = csv_file_obj.data_table_def()
|
|
719
|
+
return tablespec_cache
|
|
704
720
|
|
|
705
|
-
get_ts.tablespec = None
|
|
706
721
|
f = csv_file_obj.reader()
|
|
707
722
|
next(f)
|
|
708
723
|
self.populate_table(schema_name, table_name, f, import_cols, get_ts)
|
|
@@ -777,14 +792,17 @@ class DatabasePool:
|
|
|
777
792
|
|
|
778
793
|
def current(self) -> Database:
|
|
779
794
|
"""Return the currently active ``Database`` object."""
|
|
795
|
+
assert self.current_db is not None
|
|
780
796
|
return self.pool[self.current_db]
|
|
781
797
|
|
|
782
798
|
def current_alias(self) -> str:
|
|
783
799
|
"""Return the alias string for the currently active database."""
|
|
800
|
+
assert self.current_db is not None
|
|
784
801
|
return self.current_db
|
|
785
802
|
|
|
786
803
|
def initial(self) -> Database:
|
|
787
804
|
"""Return the first ``Database`` that was added to the pool."""
|
|
805
|
+
assert self.initial_db is not None
|
|
788
806
|
return self.pool[self.initial_db]
|
|
789
807
|
|
|
790
808
|
def aliased_as(self, db_alias: str) -> Database:
|
execsql/db/dsn.py
CHANGED
|
@@ -86,6 +86,8 @@ class DsnDatabase(Database):
|
|
|
86
86
|
# supplied credentials cannot inject additional attributes.
|
|
87
87
|
parts = [f"DSN={_odbc_quote(self.db_name)}"]
|
|
88
88
|
if self.need_passwd:
|
|
89
|
+
assert self.user is not None
|
|
90
|
+
assert self.password is not None
|
|
89
91
|
parts.append(f"UID={_odbc_quote(self.user)}")
|
|
90
92
|
parts.append(f"PWD={_odbc_quote(self.password)}")
|
|
91
93
|
connstr = ";".join(parts) + ";"
|
execsql/db/duckdb.py
CHANGED
|
@@ -73,7 +73,7 @@ class DuckDBDatabase(Database):
|
|
|
73
73
|
self.rollback()
|
|
74
74
|
raise
|
|
75
75
|
|
|
76
|
-
def view_exists(self, view_name: str) -> bool:
|
|
76
|
+
def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
|
|
77
77
|
"""Return True if the named view exists in the DuckDB database."""
|
|
78
78
|
# DuckDB information_schema has no 'views' table; views are listed in 'tables'
|
|
79
79
|
return self.table_exists(view_name)
|
execsql/db/mysql.py
CHANGED
|
@@ -8,7 +8,7 @@ servers via ``pymysql``. Corresponds to ``-t m`` on the CLI.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import re
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any, cast
|
|
12
12
|
|
|
13
13
|
from execsql.db.base import Database
|
|
14
14
|
from execsql.exceptions import ErrInfo
|
|
@@ -105,7 +105,7 @@ class MySQLDatabase(Database):
|
|
|
105
105
|
"""
|
|
106
106
|
cached = getattr(self, "_cached_lctn", None)
|
|
107
107
|
if cached is not None:
|
|
108
|
-
return cached
|
|
108
|
+
return cast(int, cached)
|
|
109
109
|
try:
|
|
110
110
|
_, rows = self.select_data("SELECT @@lower_case_table_names;")
|
|
111
111
|
value = int(rows[0][0]) if rows else 0
|
|
@@ -114,10 +114,8 @@ class MySQLDatabase(Database):
|
|
|
114
114
|
self._cached_lctn = value
|
|
115
115
|
return value
|
|
116
116
|
|
|
117
|
-
def _fold_identifier(self, name: str
|
|
117
|
+
def _fold_identifier(self, name: str) -> str:
|
|
118
118
|
"""Lowercase *name* when the server is case-insensitive (LCTN 1/2)."""
|
|
119
|
-
if name is None:
|
|
120
|
-
return None
|
|
121
119
|
return name.lower() if self._lower_case_table_names() in (1, 2) else name
|
|
122
120
|
|
|
123
121
|
# NB: schema_exists is overridden below to return False unconditionally
|
|
@@ -127,7 +125,7 @@ class MySQLDatabase(Database):
|
|
|
127
125
|
def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
|
|
128
126
|
return super().table_exists(
|
|
129
127
|
self._fold_identifier(table_name),
|
|
130
|
-
self._fold_identifier(schema_name),
|
|
128
|
+
self._fold_identifier(schema_name) if schema_name is not None else None,
|
|
131
129
|
)
|
|
132
130
|
|
|
133
131
|
def column_exists(
|
|
@@ -139,13 +137,13 @@ class MySQLDatabase(Database):
|
|
|
139
137
|
return super().column_exists(
|
|
140
138
|
self._fold_identifier(table_name),
|
|
141
139
|
self._fold_identifier(column_name),
|
|
142
|
-
self._fold_identifier(schema_name),
|
|
140
|
+
self._fold_identifier(schema_name) if schema_name is not None else None,
|
|
143
141
|
)
|
|
144
142
|
|
|
145
143
|
def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
|
|
146
144
|
return super().view_exists(
|
|
147
145
|
self._fold_identifier(view_name),
|
|
148
|
-
self._fold_identifier(schema_name),
|
|
146
|
+
self._fold_identifier(schema_name) if schema_name is not None else None,
|
|
149
147
|
)
|
|
150
148
|
|
|
151
149
|
def open_db(self) -> None:
|
execsql/db/sqlite.py
CHANGED
|
@@ -130,7 +130,7 @@ class SQLiteDatabase(Database):
|
|
|
130
130
|
) from e
|
|
131
131
|
return [d[0] for d in curs.description]
|
|
132
132
|
|
|
133
|
-
def view_exists(self, view_name: str) -> bool:
|
|
133
|
+
def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
|
|
134
134
|
"""Return True if the named view exists in the SQLite database."""
|
|
135
135
|
# Match case-insensitively — see table_exists().
|
|
136
136
|
with self._cursor() as curs:
|