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/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, read_sqlfile, read_sqlstring, runscripts, substitute_vars
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 _lint_script, _print_lint_results # noqa: F401 — re-export
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(cmdlist: object) -> None:
41
- """Print the parsed command list for --dry-run mode.
41
+ def _print_dry_run(tree: object) -> None:
42
+ """Print the parsed AST for --dry-run mode.
42
43
 
43
- Substitution variables (``$VAR``, ``&ENV``, ``@COUNTER``) that are already
44
- populated from environment variables, ``--assign-arg`` values, or config
45
- are expanded in the displayed text. System variables that are set at
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
- if cmdlist is None or not cmdlist.cmdlist:
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
- n = len(cmdlist.cmdlist)
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, cmd in enumerate(cmdlist.cmdlist, 1):
58
- ctype = "SQL " if cmd.command_type == "sql" else "METACMD"
59
- source_info = f"[dim]{cmd.source}:{cmd.line_no}[/dim]"
60
- raw = cmd.commandline()
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
- # Security note: ALL environment variables are exposed as &-prefixed
249
- # substitution variables. Sensitive values (API keys, tokens) in the
250
- # process environment will be accessible to scripts. See the
251
- # "Environment Variables" section in docs/reference/substitution_vars.md.
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
- read_sqlstring(command.replace("\\n", "\n").replace("\\t", "\t"), "<inline>")
504
+ _ast_tree = parse_string(
505
+ command.replace("\\n", "\n").replace("\\t", "\t"),
506
+ "<inline>",
507
+ )
491
508
  else:
492
- read_sqlfile(script_name)
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(_state.commandliststack[-1] if _state.commandliststack else None)
515
+ _print_dry_run(_ast_tree)
499
516
  raise SystemExit(0)
500
517
 
501
518
  # ------------------------------------------------------------------
502
- # Lint: static analysis without connecting to DB
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
- _execute_script_direct(conf, profile=profile, profile_limit=profile_limit)
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 _execute_script_textual_console(conf: ConfigData) -> None:
567
- """Run the script in a background thread while ConsoleApp runs in the main thread."""
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
- def _execute_script_direct(conf: ConfigData, *, profile: bool = False, profile_limit: int = 20) -> None:
618
- """Run runscripts() in the current (main) thread — used when Textual is not active.
590
+ from execsql.script.executor import execute
619
591
 
620
- Args:
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
- runscripts()
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__(self, script_path: str, variable_pool: object) -> None:
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
- for ix, configfile in enumerate(config_files):
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
- config_files.insert(ix + 1, conffile)
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
- config_files.insert(ix + 1, conffile)
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
@@ -747,4 +747,7 @@ class DatabasePool:
747
747
  _state.exec_log.log_status_error(
748
748
  f"Can't close database {nm} aliased as {alias}",
749
749
  )
750
- self.__init__()
750
+ self.pool = {}
751
+ self.initial_db = None
752
+ self.current_db = None
753
+ self.do_rollback = True
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.encode(self.encoding))
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.encode(self.encoding))
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
- if os.environ.get("NO_COLOR") is not None:
56
- return False
57
- if os.environ.get("EXECSQL_NO_COLOR") is not None:
58
- return False
59
- output = _state.output
60
- if output is not None and hasattr(output, "isatty"):
61
- return output.isatty()
62
- # WriteHooks (the default _state.output) has no isatty — fall through
63
- # to check the underlying stream it would write to.
64
- return sys.stdout.isatty()
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: