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/cli/run.py
CHANGED
|
@@ -12,6 +12,7 @@ from typing import Any
|
|
|
12
12
|
import datetime
|
|
13
13
|
import getpass
|
|
14
14
|
import os
|
|
15
|
+
import platform
|
|
15
16
|
import sys
|
|
16
17
|
import traceback
|
|
17
18
|
from pathlib import Path
|
|
@@ -21,7 +22,7 @@ from execsql.cli.dsn import _parse_connection_string
|
|
|
21
22
|
from execsql.cli.help import _console, _err_console
|
|
22
23
|
from execsql.config import ConfigData, StatObj
|
|
23
24
|
from execsql.exceptions import ConfigError, ErrInfo
|
|
24
|
-
from execsql.script import SubVarSet, current_script_line,
|
|
25
|
+
from execsql.script import SubVarSet, current_script_line, substitute_vars
|
|
25
26
|
from execsql.utils.fileio import FileWriter, Logger, filewriter_end
|
|
26
27
|
from execsql.utils.gui import gui_connect, gui_console_isrunning, gui_console_off, gui_console_on, gui_console_wait_user
|
|
27
28
|
|
|
@@ -29,7 +30,7 @@ __all__ = ["_connect_initial_db", "_ping_db", "_print_dry_run", "_print_profile"
|
|
|
29
30
|
|
|
30
31
|
# Lint helper — imported lazily inside _run() to keep start-up cost low, but
|
|
31
32
|
# re-exported here so that tests and callers can reach it via cli.run.
|
|
32
|
-
from execsql.cli.lint import
|
|
33
|
+
from execsql.cli.lint import _print_lint_results # noqa: F401 — re-export (used by cli/__init__.py)
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
# ---------------------------------------------------------------------------
|
|
@@ -37,31 +38,37 @@ from execsql.cli.lint import _lint_script, _print_lint_results # noqa: F401 —
|
|
|
37
38
|
# ---------------------------------------------------------------------------
|
|
38
39
|
|
|
39
40
|
|
|
40
|
-
def _print_dry_run(
|
|
41
|
-
"""Print the parsed
|
|
41
|
+
def _print_dry_run(tree: object) -> None:
|
|
42
|
+
"""Print the parsed AST for --dry-run mode.
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
execution time (e.g. ``$CURRENT_TIME``, ``$DB_NAME``, ``$TIMER``) will
|
|
47
|
-
appear unexpanded because ``set_system_vars()`` has not yet been called.
|
|
48
|
-
Local ``~``-prefixed script-scope variables are also not expanded (no script
|
|
49
|
-
execution context exists in dry-run mode).
|
|
44
|
+
Walks the AST tree and prints each SQL statement and metacommand with
|
|
45
|
+
source location. Variables already populated at parse time are expanded;
|
|
46
|
+
execution-time variables remain unexpanded.
|
|
50
47
|
"""
|
|
51
|
-
|
|
48
|
+
from execsql.script.ast import MetaCommandStatement, SqlStatement
|
|
49
|
+
|
|
50
|
+
if tree is None:
|
|
51
|
+
_console.print("[yellow]No commands found in script.[/yellow]")
|
|
52
|
+
return
|
|
53
|
+
nodes = list(tree.walk())
|
|
54
|
+
# Count only executable nodes (SQL and metacommands)
|
|
55
|
+
executable = [n for n in nodes if isinstance(n, (SqlStatement, MetaCommandStatement))]
|
|
56
|
+
if not executable:
|
|
52
57
|
_console.print("[yellow]No commands found in script.[/yellow]")
|
|
53
58
|
return
|
|
54
|
-
|
|
55
|
-
_console.print(f"[bold cyan]Dry Run[/bold cyan] — [dim]{n} command(s) parsed[/dim]")
|
|
59
|
+
_console.print(f"[bold cyan]Dry Run[/bold cyan] — [dim]{len(executable)} command(s) parsed[/dim]")
|
|
56
60
|
_console.print()
|
|
57
|
-
for i,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
for i, node in enumerate(executable, 1):
|
|
62
|
+
if isinstance(node, SqlStatement):
|
|
63
|
+
ctype = "SQL "
|
|
64
|
+
raw = node.text
|
|
65
|
+
else:
|
|
66
|
+
ctype = "METACMD"
|
|
67
|
+
raw = "-- !x! " + node.command
|
|
68
|
+
source_info = f"[dim]{node.span.file}:{node.span.start_line}[/dim]"
|
|
61
69
|
try:
|
|
62
70
|
expanded = substitute_vars(raw)
|
|
63
71
|
except Exception:
|
|
64
|
-
# Cycle detection or other expansion errors — fall back to raw text.
|
|
65
72
|
expanded = raw
|
|
66
73
|
_console.print(f" [dim]{i:>4}[/dim] [bold green]{ctype}[/bold green] {source_info} {expanded}")
|
|
67
74
|
|
|
@@ -219,6 +226,7 @@ def _run(
|
|
|
219
226
|
ping: bool = False,
|
|
220
227
|
lint: bool = False,
|
|
221
228
|
debug: bool = False,
|
|
229
|
+
config_file: str | None = None,
|
|
222
230
|
) -> None:
|
|
223
231
|
"""Initialise state, connect to the database, load the script, and run it.
|
|
224
232
|
|
|
@@ -245,11 +253,13 @@ def _run(
|
|
|
245
253
|
# ------------------------------------------------------------------
|
|
246
254
|
_state.subvars = SubVarSet()
|
|
247
255
|
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
|
|
256
|
+
# Environment variables are exposed as &-prefixed substitution variables.
|
|
257
|
+
# Variables whose names contain common secret-indicating substrings are
|
|
258
|
+
# excluded to reduce accidental credential leakage into scripts and logs.
|
|
259
|
+
_SENSITIVE_SUBSTRINGS = ("SECRET", "TOKEN", "PASSWORD", "PASSWD", "PRIVATE_KEY", "CREDENTIAL")
|
|
252
260
|
for k in os.environ:
|
|
261
|
+
if any(s in k.upper() for s in _SENSITIVE_SUBSTRINGS):
|
|
262
|
+
continue
|
|
253
263
|
try:
|
|
254
264
|
_state.subvars.add_substitution("&" + k, os.environ[k])
|
|
255
265
|
except Exception:
|
|
@@ -276,13 +286,14 @@ def _run(
|
|
|
276
286
|
elif osys.startswith("win"):
|
|
277
287
|
osys = "windows"
|
|
278
288
|
_state.subvars.add_substitution("$OS", osys)
|
|
289
|
+
_state.subvars.add_substitution("$HOSTNAME", platform.node())
|
|
279
290
|
_state.subvars.add_substitution("$PYTHON_EXECUTABLE", sys.executable)
|
|
280
291
|
|
|
281
292
|
# ------------------------------------------------------------------
|
|
282
293
|
# Read configuration file
|
|
283
294
|
# ------------------------------------------------------------------
|
|
284
295
|
script_path = str(Path(script_name).resolve().parent) if script_name else os.getcwd()
|
|
285
|
-
_state.conf = ConfigData(script_path, _state.subvars)
|
|
296
|
+
_state.conf = ConfigData(script_path, _state.subvars, config_file=config_file)
|
|
286
297
|
conf = _state.conf
|
|
287
298
|
|
|
288
299
|
# ------------------------------------------------------------------
|
|
@@ -485,28 +496,29 @@ def _run(
|
|
|
485
496
|
# ------------------------------------------------------------------
|
|
486
497
|
# Load the SQL script (skipped in --ping and --dry-run with no script)
|
|
487
498
|
# ------------------------------------------------------------------
|
|
499
|
+
_ast_tree = None
|
|
488
500
|
if not ping:
|
|
501
|
+
from execsql.script.parser import parse_script, parse_string
|
|
502
|
+
|
|
489
503
|
if command is not None:
|
|
490
|
-
|
|
504
|
+
_ast_tree = parse_string(
|
|
505
|
+
command.replace("\\n", "\n").replace("\\t", "\t"),
|
|
506
|
+
"<inline>",
|
|
507
|
+
)
|
|
491
508
|
else:
|
|
492
|
-
|
|
509
|
+
_ast_tree = parse_script(script_name, encoding=conf.script_encoding)
|
|
493
510
|
|
|
494
511
|
# ------------------------------------------------------------------
|
|
495
512
|
# Dry-run: print command list and exit without connecting to DB
|
|
496
513
|
# ------------------------------------------------------------------
|
|
497
514
|
if dry_run:
|
|
498
|
-
_print_dry_run(
|
|
515
|
+
_print_dry_run(_ast_tree)
|
|
499
516
|
raise SystemExit(0)
|
|
500
517
|
|
|
501
518
|
# ------------------------------------------------------------------
|
|
502
|
-
#
|
|
519
|
+
# NOTE: --lint is handled as an early exit in cli/__init__.py (AST
|
|
520
|
+
# linter) before _run() is called. No lint code path here.
|
|
503
521
|
# ------------------------------------------------------------------
|
|
504
|
-
if lint:
|
|
505
|
-
cmdlist = _state.commandliststack[-1] if _state.commandliststack else None
|
|
506
|
-
issues = _lint_script(cmdlist, script_name)
|
|
507
|
-
label = script_name or "<inline>"
|
|
508
|
-
exit_code = _print_lint_results(issues, label)
|
|
509
|
-
raise SystemExit(exit_code)
|
|
510
522
|
|
|
511
523
|
# ------------------------------------------------------------------
|
|
512
524
|
# Start GUI console if requested
|
|
@@ -555,7 +567,8 @@ def _run(
|
|
|
555
567
|
if debug:
|
|
556
568
|
_state.step_mode = True
|
|
557
569
|
|
|
558
|
-
|
|
570
|
+
if _ast_tree is not None:
|
|
571
|
+
_execute_script_ast(_ast_tree, conf, profile=profile, profile_limit=profile_limit)
|
|
559
572
|
|
|
560
573
|
|
|
561
574
|
# ---------------------------------------------------------------------------
|
|
@@ -563,84 +576,32 @@ def _run(
|
|
|
563
576
|
# ---------------------------------------------------------------------------
|
|
564
577
|
|
|
565
578
|
|
|
566
|
-
def
|
|
567
|
-
|
|
579
|
+
def _execute_script_ast(
|
|
580
|
+
tree: Any,
|
|
581
|
+
conf: ConfigData,
|
|
582
|
+
*,
|
|
583
|
+
profile: bool = False,
|
|
584
|
+
profile_limit: int = 20,
|
|
585
|
+
) -> None:
|
|
586
|
+
"""Execute a script using the AST executor."""
|
|
568
587
|
import execsql.state as _state
|
|
569
588
|
import execsql.utils.gui as _gui
|
|
570
|
-
from execsql.gui.tui import ConsoleApp, _ConsoleDialogQueue
|
|
571
|
-
|
|
572
|
-
dialog_queue = _ConsoleDialogQueue()
|
|
573
|
-
_state.gui_manager_queue = dialog_queue
|
|
574
|
-
|
|
575
|
-
app = ConsoleApp(
|
|
576
|
-
script_runner=runscripts,
|
|
577
|
-
dialog_queue=dialog_queue,
|
|
578
|
-
wait_on_exit=conf.gui_wait_on_exit,
|
|
579
|
-
)
|
|
580
|
-
_state.output.redir_stdout(app.write_console)
|
|
581
|
-
_state.output.redir_stderr(app.write_console)
|
|
582
|
-
_gui._active_backend._console_app = app
|
|
583
|
-
|
|
584
|
-
try:
|
|
585
|
-
app.run()
|
|
586
|
-
finally:
|
|
587
|
-
_state.output.reset()
|
|
588
|
-
_gui._active_backend._console_app = None
|
|
589
|
-
|
|
590
|
-
if app._script_exception is not None:
|
|
591
|
-
exc = app._script_exception
|
|
592
|
-
if isinstance(exc, SystemExit):
|
|
593
|
-
_state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
|
|
594
|
-
sys.exit(exc.code)
|
|
595
|
-
elif isinstance(exc, ConfigError):
|
|
596
|
-
raise exc
|
|
597
|
-
elif isinstance(exc, ErrInfo):
|
|
598
|
-
from execsql.utils.errors import exit_now
|
|
599
|
-
|
|
600
|
-
exit_now(1, exc)
|
|
601
|
-
else:
|
|
602
|
-
strace = traceback.extract_tb(exc.__traceback__)[-1:]
|
|
603
|
-
lno = strace[0][1] if strace else "?"
|
|
604
|
-
msg = f"{Path(sys.argv[0]).name}: Uncaught exception {type(exc)} ({exc}) on line {lno}"
|
|
605
|
-
script, slno = current_script_line()
|
|
606
|
-
if script is not None:
|
|
607
|
-
msg += f" in script {script}, line {slno}"
|
|
608
|
-
from execsql.utils.errors import exit_now
|
|
609
|
-
|
|
610
|
-
exit_now(1, ErrInfo("exception", exception_msg=msg))
|
|
611
|
-
|
|
612
|
-
_state.dbs.do_rollback = False
|
|
613
|
-
_state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
|
|
614
|
-
_state.exec_log.log_exit_end()
|
|
615
|
-
|
|
616
589
|
|
|
617
|
-
|
|
618
|
-
"""Run runscripts() in the current (main) thread — used when Textual is not active.
|
|
590
|
+
from execsql.script.executor import execute
|
|
619
591
|
|
|
620
|
-
|
|
621
|
-
conf: The active configuration object.
|
|
622
|
-
profile: When ``True``, print a per-statement timing summary after the
|
|
623
|
-
script completes. Timing data must already have been activated on
|
|
624
|
-
``_state.profile_data`` before this function is called.
|
|
625
|
-
profile_limit: Maximum number of top statements to display in the
|
|
626
|
-
profile summary (default: 20).
|
|
627
|
-
"""
|
|
628
|
-
import execsql.state as _state
|
|
629
|
-
import execsql.utils.gui as _gui
|
|
630
|
-
|
|
631
|
-
# For Textual + gui_level 3, use the persistent ConsoleApp architecture.
|
|
592
|
+
# For Textual + gui_level 3, run in background thread with ConsoleApp.
|
|
632
593
|
if conf.gui_level > 2:
|
|
633
594
|
try:
|
|
634
595
|
from execsql.gui.tui import TextualBackend
|
|
635
596
|
|
|
636
597
|
if isinstance(_gui._active_backend, TextualBackend):
|
|
637
|
-
_execute_script_textual_console(conf)
|
|
598
|
+
_execute_script_textual_console(tree, conf)
|
|
638
599
|
return
|
|
639
600
|
except ImportError:
|
|
640
601
|
pass
|
|
641
602
|
|
|
642
603
|
try:
|
|
643
|
-
|
|
604
|
+
execute(tree)
|
|
644
605
|
except SystemExit as exc:
|
|
645
606
|
if gui_console_isrunning() and conf.gui_wait_on_exit:
|
|
646
607
|
gui_console_wait_user(
|
|
@@ -662,9 +623,6 @@ def _execute_script_direct(conf: ConfigData, *, profile: bool = False, profile_l
|
|
|
662
623
|
strace = traceback.extract_tb(sys.exc_info()[2])[-1:]
|
|
663
624
|
lno = strace[0][1]
|
|
664
625
|
msg = f"{Path(sys.argv[0]).name}: Uncaught exception {sys.exc_info()[0]} ({sys.exc_info()[1]}) on line {lno}"
|
|
665
|
-
script, slno = current_script_line()
|
|
666
|
-
if script:
|
|
667
|
-
msg += f" in script {script}, line {slno}"
|
|
668
626
|
from execsql.utils.errors import exit_now
|
|
669
627
|
|
|
670
628
|
exit_now(1, ErrInfo("exception", exception_msg=msg))
|
|
@@ -682,6 +640,59 @@ def _execute_script_direct(conf: ConfigData, *, profile: bool = False, profile_l
|
|
|
682
640
|
_state.exec_log.log_exit_end()
|
|
683
641
|
|
|
684
642
|
|
|
643
|
+
def _execute_script_textual_console(tree: Any, conf: ConfigData) -> None:
|
|
644
|
+
"""Run the script in a background thread while ConsoleApp runs in the main thread."""
|
|
645
|
+
import execsql.state as _state
|
|
646
|
+
import execsql.utils.gui as _gui
|
|
647
|
+
from execsql.gui.tui import ConsoleApp, _ConsoleDialogQueue
|
|
648
|
+
|
|
649
|
+
from execsql.script.executor import execute
|
|
650
|
+
|
|
651
|
+
dialog_queue = _ConsoleDialogQueue()
|
|
652
|
+
_state.gui_manager_queue = dialog_queue
|
|
653
|
+
|
|
654
|
+
app = ConsoleApp(
|
|
655
|
+
script_runner=lambda: execute(tree),
|
|
656
|
+
dialog_queue=dialog_queue,
|
|
657
|
+
wait_on_exit=conf.gui_wait_on_exit,
|
|
658
|
+
)
|
|
659
|
+
_state.output.redir_stdout(app.write_console)
|
|
660
|
+
_state.output.redir_stderr(app.write_console)
|
|
661
|
+
_gui._active_backend._console_app = app
|
|
662
|
+
|
|
663
|
+
try:
|
|
664
|
+
app.run()
|
|
665
|
+
finally:
|
|
666
|
+
_state.output.reset()
|
|
667
|
+
_gui._active_backend._console_app = None
|
|
668
|
+
|
|
669
|
+
if app._script_exception is not None:
|
|
670
|
+
exc = app._script_exception
|
|
671
|
+
if isinstance(exc, SystemExit):
|
|
672
|
+
_state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
|
|
673
|
+
sys.exit(exc.code)
|
|
674
|
+
elif isinstance(exc, ConfigError):
|
|
675
|
+
raise exc
|
|
676
|
+
elif isinstance(exc, ErrInfo):
|
|
677
|
+
from execsql.utils.errors import exit_now
|
|
678
|
+
|
|
679
|
+
exit_now(1, exc)
|
|
680
|
+
else:
|
|
681
|
+
strace = traceback.extract_tb(exc.__traceback__)[-1:]
|
|
682
|
+
lno = strace[0][1] if strace else "?"
|
|
683
|
+
msg = f"{Path(sys.argv[0]).name}: Uncaught exception {type(exc)} ({exc}) on line {lno}"
|
|
684
|
+
script, slno = current_script_line()
|
|
685
|
+
if script is not None:
|
|
686
|
+
msg += f" in script {script}, line {slno}"
|
|
687
|
+
from execsql.utils.errors import exit_now
|
|
688
|
+
|
|
689
|
+
exit_now(1, ErrInfo("exception", exception_msg=msg))
|
|
690
|
+
|
|
691
|
+
_state.dbs.do_rollback = False
|
|
692
|
+
_state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
|
|
693
|
+
_state.exec_log.log_exit_end()
|
|
694
|
+
|
|
695
|
+
|
|
685
696
|
# ---------------------------------------------------------------------------
|
|
686
697
|
# Utility: build the database connection for the initial/default database
|
|
687
698
|
# ---------------------------------------------------------------------------
|
execsql/config.py
CHANGED
|
@@ -198,7 +198,13 @@ class ConfigData:
|
|
|
198
198
|
raise ConfigError(f"Invalid {key}: {val}; must be >= {min_val}.")
|
|
199
199
|
setattr(self, attr, val)
|
|
200
200
|
|
|
201
|
-
def __init__(
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
script_path: str,
|
|
204
|
+
variable_pool: object,
|
|
205
|
+
*,
|
|
206
|
+
config_file: str | None = None,
|
|
207
|
+
) -> None:
|
|
202
208
|
"""Load and merge all discoverable execsql.conf files for the given script path.
|
|
203
209
|
|
|
204
210
|
Args:
|
|
@@ -207,6 +213,10 @@ class ConfigData:
|
|
|
207
213
|
variable_pool: Substitution-variable registry used to expand
|
|
208
214
|
``config_file`` path values and to populate ``[variables]``
|
|
209
215
|
sections.
|
|
216
|
+
config_file: Optional explicit config file path (from ``--config``).
|
|
217
|
+
Loaded after the implicit search paths so its values take
|
|
218
|
+
precedence over system, user, script, and working-directory
|
|
219
|
+
config files.
|
|
210
220
|
"""
|
|
211
221
|
self.db_type = "a"
|
|
212
222
|
self.server = None
|
|
@@ -276,6 +286,7 @@ class ConfigData:
|
|
|
276
286
|
self.email_css = None
|
|
277
287
|
self.include_req: list = []
|
|
278
288
|
self.include_opt: list = []
|
|
289
|
+
self.export_output_dir: str | None = None
|
|
279
290
|
self.dao_flush_delay_secs = 5.0
|
|
280
291
|
self.zip_buffer_mb = 10
|
|
281
292
|
if os.name == "posix":
|
|
@@ -290,9 +301,15 @@ class ConfigData:
|
|
|
290
301
|
config_files = [sys_config_file, user_config_file, script_config_file, startdir_config_file]
|
|
291
302
|
else:
|
|
292
303
|
config_files = [sys_config_file, user_config_file, startdir_config_file]
|
|
304
|
+
if config_file:
|
|
305
|
+
config_files.append(str(Path(config_file).resolve()))
|
|
306
|
+
from collections import deque
|
|
307
|
+
|
|
293
308
|
_MAX_CONFIG_CHAIN = 20 # Guard against circular config_file references.
|
|
309
|
+
config_queue: deque[str] = deque(config_files)
|
|
294
310
|
self.files_read: list = []
|
|
295
|
-
|
|
311
|
+
while config_queue:
|
|
312
|
+
configfile = config_queue.popleft()
|
|
296
313
|
if len(self.files_read) >= _MAX_CONFIG_CHAIN:
|
|
297
314
|
break
|
|
298
315
|
if configfile not in self.files_read and Path(configfile).is_file():
|
|
@@ -425,7 +442,7 @@ class ConfigData:
|
|
|
425
442
|
conffile = str(Path(conffile) / self.config_file_name)
|
|
426
443
|
if Path(conffile).is_file():
|
|
427
444
|
# Silently ignore a non-existent file, for cross-OS compatibility.
|
|
428
|
-
|
|
445
|
+
config_queue.appendleft(conffile)
|
|
429
446
|
# OS-specific additional config files.
|
|
430
447
|
_os_config_key: str | None = None
|
|
431
448
|
if sys.platform == "linux" and cp.has_option(self._CONFIG_SECTION, "linux_config_file"):
|
|
@@ -445,7 +462,7 @@ class ConfigData:
|
|
|
445
462
|
if not Path(conffile).is_file():
|
|
446
463
|
conffile = str(Path(conffile) / self.config_file_name)
|
|
447
464
|
if Path(conffile).is_file():
|
|
448
|
-
|
|
465
|
+
config_queue.appendleft(conffile)
|
|
449
466
|
self._get_bool(cp, self._CONFIG_SECTION, "user_logfile", "user_logfile")
|
|
450
467
|
# dao_flush_delay_secs has a specific error message — keep inline
|
|
451
468
|
if cp.has_option(self._CONFIG_SECTION, "dao_flush_delay_secs"):
|
|
@@ -464,6 +481,14 @@ class ConfigData:
|
|
|
464
481
|
self._get_str(cp, self._EMAIL_SECTION, "password", "smtp_password")
|
|
465
482
|
# enc_password has special decryption logic — keep inline
|
|
466
483
|
if cp.has_option(self._EMAIL_SECTION, "enc_password"):
|
|
484
|
+
import warnings
|
|
485
|
+
|
|
486
|
+
warnings.warn(
|
|
487
|
+
"enc_password provides obfuscation only, not encryption. "
|
|
488
|
+
"Use keyring or environment variables for credential storage.",
|
|
489
|
+
DeprecationWarning,
|
|
490
|
+
stacklevel=1,
|
|
491
|
+
)
|
|
467
492
|
self.smtp_password = Encrypt().decrypt(cp.get(self._EMAIL_SECTION, "enc_password"))
|
|
468
493
|
self._get_bool(cp, self._EMAIL_SECTION, "use_ssl", "smtp_ssl")
|
|
469
494
|
self._get_bool(cp, self._EMAIL_SECTION, "use_tls", "smtp_tls")
|
execsql/db/access.py
CHANGED
|
@@ -83,6 +83,7 @@ class AccessDatabase(Database):
|
|
|
83
83
|
self.open_dao()
|
|
84
84
|
# Create the ODBC connection
|
|
85
85
|
self.open_db()
|
|
86
|
+
self.password = None # Clear cleartext password after successful connection
|
|
86
87
|
|
|
87
88
|
def __repr__(self) -> str:
|
|
88
89
|
return f"AccessDatabase({self.db_name}, {self.encoding})"
|
execsql/db/base.py
CHANGED
execsql/db/dsn.py
CHANGED
|
@@ -54,6 +54,7 @@ class DsnDatabase(Database):
|
|
|
54
54
|
self.conn = None
|
|
55
55
|
self.autocommit = True
|
|
56
56
|
self.open_db()
|
|
57
|
+
self.password = None # Clear cleartext password after successful connection
|
|
57
58
|
|
|
58
59
|
def __repr__(self) -> str:
|
|
59
60
|
return f"DsnDatabase({self.db_name!r}, {self.user!r}, {self.need_passwd!r}, {self.port!r}, {self.encoding!r})"
|
|
@@ -122,9 +123,9 @@ class DsnDatabase(Database):
|
|
|
122
123
|
"""Execute a stored procedure by name."""
|
|
123
124
|
# The querycommand must be a stored procedure
|
|
124
125
|
with self._cursor() as curs:
|
|
125
|
-
cmd = f"execute {querycommand};"
|
|
126
|
+
cmd = f"execute {self.quote_identifier(querycommand)};"
|
|
126
127
|
try:
|
|
127
|
-
curs.execute(cmd
|
|
128
|
+
curs.execute(cmd)
|
|
128
129
|
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
|
129
130
|
except Exception:
|
|
130
131
|
self.rollback()
|
execsql/db/duckdb.py
CHANGED
|
@@ -65,7 +65,7 @@ class DuckDBDatabase(Database):
|
|
|
65
65
|
# DuckDB does not support stored functions, so the querycommand
|
|
66
66
|
# is treated as (and therefore must be) a view.
|
|
67
67
|
with self._cursor() as curs:
|
|
68
|
-
cmd = f"select * from {querycommand};"
|
|
68
|
+
cmd = f"select * from {self.quote_identifier(querycommand)};"
|
|
69
69
|
try:
|
|
70
70
|
curs.execute(cmd)
|
|
71
71
|
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
execsql/db/factory.py
CHANGED
|
@@ -73,6 +73,9 @@ def db_SQLite(
|
|
|
73
73
|
encoding: str | None = None,
|
|
74
74
|
) -> SQLiteDatabase:
|
|
75
75
|
"""Open a SQLite database file via the standard-library sqlite3 module."""
|
|
76
|
+
if sqlite_fn == ":memory:":
|
|
77
|
+
# In-memory databases always exist — skip file checks
|
|
78
|
+
return SQLiteDatabase(sqlite_fn)
|
|
76
79
|
if new_db:
|
|
77
80
|
from execsql.utils.fileio import check_dir
|
|
78
81
|
|
execsql/db/firebird.py
CHANGED
|
@@ -51,6 +51,7 @@ class FirebirdDatabase(Database):
|
|
|
51
51
|
self.conn = None
|
|
52
52
|
self.autocommit = True
|
|
53
53
|
self.open_db()
|
|
54
|
+
self.password = None # Clear cleartext password after successful connection
|
|
54
55
|
|
|
55
56
|
def __repr__(self) -> str:
|
|
56
57
|
return (
|
|
@@ -117,7 +118,7 @@ class FirebirdDatabase(Database):
|
|
|
117
118
|
"""Execute a stored procedure by name."""
|
|
118
119
|
# The querycommand must be a stored function (/procedure)
|
|
119
120
|
with self._cursor() as curs:
|
|
120
|
-
cmd = f"execute procedure {querycommand};"
|
|
121
|
+
cmd = f"execute procedure {self.quote_identifier(querycommand)};"
|
|
121
122
|
try:
|
|
122
123
|
curs.execute(cmd)
|
|
123
124
|
except Exception:
|
execsql/db/mysql.py
CHANGED
|
@@ -65,6 +65,7 @@ class MySQLDatabase(Database):
|
|
|
65
65
|
self.conn = None
|
|
66
66
|
self.autocommit = True
|
|
67
67
|
self.open_db()
|
|
68
|
+
self.password = None # Clear cleartext password after successful connection
|
|
68
69
|
|
|
69
70
|
def __repr__(self) -> str:
|
|
70
71
|
return (
|
|
@@ -134,7 +135,7 @@ class MySQLDatabase(Database):
|
|
|
134
135
|
"""Execute a stored procedure by name."""
|
|
135
136
|
# The querycommand must be a stored function (/procedure)
|
|
136
137
|
with self._cursor() as curs:
|
|
137
|
-
cmd = f"call {querycommand}();"
|
|
138
|
+
cmd = f"call {self.quote_identifier(querycommand)}();"
|
|
138
139
|
try:
|
|
139
140
|
curs.execute(cmd)
|
|
140
141
|
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
execsql/db/oracle.py
CHANGED
|
@@ -53,6 +53,7 @@ class OracleDatabase(Database):
|
|
|
53
53
|
self.conn = None
|
|
54
54
|
self.autocommit = True
|
|
55
55
|
self.open_db()
|
|
56
|
+
self.password = None # Clear cleartext password after successful connection
|
|
56
57
|
|
|
57
58
|
def __repr__(self) -> str:
|
|
58
59
|
return (
|
|
@@ -264,7 +265,7 @@ class OracleDatabase(Database):
|
|
|
264
265
|
"""Execute a stored function by name."""
|
|
265
266
|
# The querycommand must be a stored function (/procedure)
|
|
266
267
|
with self._cursor() as curs:
|
|
267
|
-
cmd = f"select {querycommand}()"
|
|
268
|
+
cmd = f"select {self.quote_identifier(querycommand)}()"
|
|
268
269
|
try:
|
|
269
270
|
curs.execute(cmd)
|
|
270
271
|
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
execsql/db/postgres.py
CHANGED
|
@@ -62,6 +62,7 @@ class PostgresDatabase(Database):
|
|
|
62
62
|
self.conn = None
|
|
63
63
|
self.autocommit = True
|
|
64
64
|
self.open_db()
|
|
65
|
+
self.password = None # Clear cleartext password after successful connection
|
|
65
66
|
|
|
66
67
|
def __repr__(self) -> str:
|
|
67
68
|
return (
|
|
@@ -152,7 +153,7 @@ class PostgresDatabase(Database):
|
|
|
152
153
|
"""Execute a stored function by name."""
|
|
153
154
|
# The querycommand must be a stored function (/procedure)
|
|
154
155
|
with self._cursor() as curs:
|
|
155
|
-
cmd = f"select {querycommand}()"
|
|
156
|
+
cmd = f"select {self.quote_identifier(querycommand)}()"
|
|
156
157
|
try:
|
|
157
158
|
curs.execute(cmd)
|
|
158
159
|
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
execsql/db/sqlite.py
CHANGED
|
@@ -72,7 +72,7 @@ class SQLiteDatabase(Database):
|
|
|
72
72
|
# SQLite does not support stored functions or views, so the querycommand
|
|
73
73
|
# is treated as (and therefore must be) a view.
|
|
74
74
|
with self._cursor() as curs:
|
|
75
|
-
cmd = f"select * from {querycommand};"
|
|
75
|
+
cmd = f"select * from {self.quote_identifier(querycommand)};"
|
|
76
76
|
try:
|
|
77
77
|
curs.execute(cmd)
|
|
78
78
|
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
execsql/db/sqlserver.py
CHANGED
|
@@ -50,6 +50,7 @@ class SqlServerDatabase(Database):
|
|
|
50
50
|
self.conn = None
|
|
51
51
|
self.autocommit = True
|
|
52
52
|
self.open_db()
|
|
53
|
+
self.password = None # Clear cleartext password after successful connection
|
|
53
54
|
|
|
54
55
|
def __repr__(self) -> str:
|
|
55
56
|
return (
|
|
@@ -142,9 +143,9 @@ class SqlServerDatabase(Database):
|
|
|
142
143
|
"""Execute a stored procedure by name."""
|
|
143
144
|
# The querycommand must be a stored procedure
|
|
144
145
|
with self._cursor() as curs:
|
|
145
|
-
cmd = f"execute {querycommand};"
|
|
146
|
+
cmd = f"execute {self.quote_identifier(querycommand)};"
|
|
146
147
|
try:
|
|
147
|
-
curs.execute(cmd
|
|
148
|
+
curs.execute(cmd)
|
|
148
149
|
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
|
149
150
|
except Exception:
|
|
150
151
|
self.rollback()
|
execsql/debug/repl.py
CHANGED
|
@@ -45,23 +45,39 @@ _YELLOW = "\033[33m"
|
|
|
45
45
|
_CYAN = "\033[36m"
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
_color_cache: bool | None = None
|
|
49
|
+
|
|
50
|
+
|
|
48
51
|
def _use_color() -> bool:
|
|
49
52
|
"""Return True if the output stream supports ANSI color.
|
|
50
53
|
|
|
51
54
|
Checks ``NO_COLOR`` and ``EXECSQL_NO_COLOR`` environment variables first
|
|
52
55
|
(either set → color off). Then tests whether the active output stream
|
|
53
56
|
reports itself as a TTY.
|
|
57
|
+
|
|
58
|
+
The result is cached after the first call; call ``_reset_color_cache()``
|
|
59
|
+
to force re-evaluation (e.g. when entering the REPL).
|
|
54
60
|
"""
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
global _color_cache # noqa: PLW0603
|
|
62
|
+
if _color_cache is not None:
|
|
63
|
+
return _color_cache
|
|
64
|
+
if os.environ.get("NO_COLOR") is not None or os.environ.get("EXECSQL_NO_COLOR") is not None:
|
|
65
|
+
_color_cache = False
|
|
66
|
+
else:
|
|
67
|
+
output = _state.output
|
|
68
|
+
if output is not None and hasattr(output, "isatty"):
|
|
69
|
+
_color_cache = output.isatty()
|
|
70
|
+
else:
|
|
71
|
+
# WriteHooks (the default _state.output) has no isatty — fall through
|
|
72
|
+
# to check the underlying stream it would write to.
|
|
73
|
+
_color_cache = sys.stdout.isatty()
|
|
74
|
+
return _color_cache
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _reset_color_cache() -> None:
|
|
78
|
+
"""Clear the cached color decision so it is re-evaluated on next use."""
|
|
79
|
+
global _color_cache # noqa: PLW0603
|
|
80
|
+
_color_cache = None
|
|
65
81
|
|
|
66
82
|
|
|
67
83
|
def _c(code: str, text: str) -> str:
|
|
@@ -169,6 +185,7 @@ def _debug_repl(*, step: bool = False) -> None:
|
|
|
169
185
|
step: When ``True``, the entry banner says "Step" instead of
|
|
170
186
|
"Breakpoint" to indicate the REPL was re-entered via step mode.
|
|
171
187
|
"""
|
|
188
|
+
_reset_color_cache()
|
|
172
189
|
try:
|
|
173
190
|
import readline as _readline # noqa: F401 — side-effect: enables history/arrow keys
|
|
174
191
|
except ImportError:
|