execsql2 2.23.1__py3-none-any.whl → 2.25.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 (92) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +19 -586
  3. execsql/cli/application.py +309 -0
  4. execsql/cli/commands/__init__.py +13 -0
  5. execsql/cli/commands/config.py +205 -0
  6. execsql/cli/commands/format.py +100 -0
  7. execsql/cli/commands/init.py +135 -0
  8. execsql/cli/commands/lint.py +225 -0
  9. execsql/cli/commands/listing.py +81 -0
  10. execsql/cli/commands/run.py +459 -0
  11. execsql/cli/dispatch.py +66 -0
  12. execsql/cli/dsn.py +11 -4
  13. execsql/cli/help.py +175 -24
  14. execsql/cli/lint.py +780 -100
  15. execsql/cli/options.py +108 -0
  16. execsql/cli/run.py +96 -32
  17. execsql/config.py +256 -173
  18. execsql/config_validation.py +282 -0
  19. execsql/data/execsql.conf.template +38 -5
  20. execsql/data/script_header.sql +23 -0
  21. execsql/db/access.py +29 -8
  22. execsql/db/base.py +40 -8
  23. execsql/db/dsn.py +13 -4
  24. execsql/db/duckdb.py +3 -1
  25. execsql/db/factory.py +13 -4
  26. execsql/db/firebird.py +21 -7
  27. execsql/db/mysql.py +37 -16
  28. execsql/db/oracle.py +49 -16
  29. execsql/db/postgres.py +30 -6
  30. execsql/db/sqlite.py +7 -9
  31. execsql/db/sqlserver.py +25 -8
  32. execsql/debug/repl.py +51 -193
  33. execsql/exceptions.py +9 -0
  34. execsql/exporters/delimited.py +87 -28
  35. execsql/exporters/xlsx.py +24 -9
  36. execsql/exporters/zip.py +1 -2
  37. execsql/format.py +115 -76
  38. execsql/gui/desktop.py +1 -3
  39. execsql/gui/tui.py +2 -2
  40. execsql/importers/base.py +35 -1
  41. execsql/importers/csv.py +3 -1
  42. execsql/interactive.py +232 -0
  43. execsql/manifest.py +200 -0
  44. execsql/metacommands/conditions.py +7 -8
  45. execsql/metacommands/control.py +2 -3
  46. execsql/metacommands/data.py +4 -1
  47. execsql/metacommands/debug.py +2 -4
  48. execsql/metacommands/effects.py +124 -0
  49. execsql/metacommands/io_fileops.py +62 -37
  50. execsql/metacommands/io_import.py +0 -1
  51. execsql/metacommands/prompt.py +2 -0
  52. execsql/metacommands/upsert.py +5 -2
  53. execsql/models.py +3 -2
  54. execsql/parser.py +14 -5
  55. execsql/plugins.py +24 -0
  56. execsql/script/__init__.py +0 -9
  57. execsql/script/ast.py +1 -1
  58. execsql/script/engine.py +46 -104
  59. execsql/script/executor.py +162 -39
  60. execsql/script/parser.py +167 -118
  61. execsql/state.py +34 -11
  62. execsql/state.pyi +11 -0
  63. execsql/types.py +22 -13
  64. execsql/utils/auth.py +169 -24
  65. execsql/utils/color.py +18 -0
  66. execsql/utils/datetime.py +55 -17
  67. execsql/utils/errors.py +82 -10
  68. execsql/utils/fileio.py +404 -117
  69. execsql/utils/gui.py +3 -0
  70. execsql/utils/mail.py +37 -12
  71. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
  72. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
  73. execsql2-2.25.0.dist-info/RECORD +140 -0
  74. execsql2-2.25.0.dist-info/entry_points.txt +2 -0
  75. execsql2-2.23.1.dist-info/RECORD +0 -124
  76. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  77. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  78. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  79. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  80. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  81. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  82. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  83. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  84. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  85. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  86. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  87. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  88. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  89. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  90. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  91. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  92. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
execsql/config.py CHANGED
@@ -18,11 +18,12 @@ Provides three classes:
18
18
  import os
19
19
  import sys
20
20
  from collections.abc import Callable
21
+ import configparser
21
22
  from configparser import ConfigParser
22
23
  from pathlib import Path
23
24
  from typing import Protocol
24
25
 
25
- from execsql.exceptions import ConfigError
26
+ from execsql.exceptions import ConfigError, ConfigFileError
26
27
  from execsql.utils.crypto import Encrypt
27
28
 
28
29
  __all__ = [
@@ -65,6 +66,27 @@ class StatObj:
65
66
  self.batch = BatchLevels()
66
67
 
67
68
 
69
+ def describe_parse_error(exc: configparser.Error) -> tuple[int, str]:
70
+ """``(line, message)`` for a :mod:`configparser` error, in words a user can act on.
71
+
72
+ ``line`` is 0 when the error has no single line. Shared by a run, which
73
+ stops at the first such error, and ``execsql config --validate``, which
74
+ reports them all.
75
+ """
76
+ if isinstance(exc, configparser.DuplicateOptionError):
77
+ return exc.lineno or 0, f"{exc.option} is set twice in [{exc.section}]"
78
+ if isinstance(exc, configparser.DuplicateSectionError):
79
+ return exc.lineno or 0, f"[{exc.section}] appears twice"
80
+ if isinstance(exc, configparser.MissingSectionHeaderError):
81
+ return exc.lineno, "a section header such as [connect] must come before the first option"
82
+ if isinstance(exc, configparser.ParsingError):
83
+ line, text = exc.errors[0]
84
+ return line, f"cannot parse line: {text.strip()}"
85
+ if isinstance(exc, configparser.InterpolationSyntaxError):
86
+ return 0, f"{exc.option}: a literal % must be written %%"
87
+ return getattr(exc, "lineno", 0) or 0, exc.message.splitlines()[0]
88
+
89
+
68
90
  class ConfigData:
69
91
  """Reads and merges ``execsql.conf`` INI files, exposing all options as attributes.
70
92
 
@@ -84,6 +106,8 @@ class ConfigData:
84
106
  _VARIABLES_SECTION = "variables"
85
107
  _INCLUDE_REQ_SECTION = "include_required"
86
108
  _INCLUDE_OPT_SECTION = "include_optional"
109
+ _FORMAT_SECTION = "format"
110
+ _LINT_SECTION = "lint"
87
111
 
88
112
  # Schema registry: maps attribute name -> (section, ini_key, type_label).
89
113
  # Populated by the _get_* methods on every option they read. This is the
@@ -91,10 +115,17 @@ class ConfigData:
91
115
  # adding a new option below automatically makes it appear in the dump.
92
116
  _schema: dict[str, tuple[str, str, str]] = {}
93
117
 
118
+ # Every (section, ini_key, attr) spelling that sets an attribute. Unlike
119
+ # ``_schema`` this keeps aliases — ``db`` and ``database`` both set ``db``,
120
+ # and ``enc_password`` sets ``smtp_password`` — so ``sources`` can name the
121
+ # file whichever spelling it used.
122
+ _option_keys: set[tuple[str, str, str]] = {("email", "enc_password", "smtp_password")}
123
+
94
124
  @classmethod
95
125
  def _register_option(cls, section: str, key: str, attr: str, type_label: str) -> None:
96
126
  """Record ``attr`` in the schema registry so config introspection sees it."""
97
127
  cls._schema[attr] = (section, key, type_label)
128
+ cls._option_keys.add((section, key, attr))
98
129
 
99
130
  def _get_str(self, cp: ConfigParser, section: str, key: str, attr: str, *, required: bool = False) -> None:
100
131
  """Read a string option and set ``self.<attr>``.
@@ -341,7 +372,16 @@ class ConfigData:
341
372
  self._get_str(cp, self._EMAIL_SECTION, "password", "smtp_password")
342
373
  self._get_bool(cp, self._EMAIL_SECTION, "use_ssl", "smtp_ssl")
343
374
  self._get_bool(cp, self._EMAIL_SECTION, "use_tls", "smtp_tls")
375
+ self._get_bool(cp, self._EMAIL_SECTION, "verify_certificate", "smtp_verify_certificate")
376
+ self._get_str(cp, self._EMAIL_SECTION, "ca_file", "smtp_ca_file")
344
377
  self._get_str(cp, self._EMAIL_SECTION, "message_css", "email_css")
378
+ # --- [format] and [lint]: read by `execsql format` / `execsql lint`; a run ignores them ---
379
+ self._get_int(cp, self._FORMAT_SECTION, "indent", "format_indent")
380
+ self._get_bool(cp, self._FORMAT_SECTION, "leading_comma", "format_leading_comma")
381
+ self._get_bool(cp, self._FORMAT_SECTION, "sql", "format_sql")
382
+ self._get_str(cp, self._LINT_SECTION, "select", "lint_select")
383
+ self._get_str(cp, self._LINT_SECTION, "ignore", "lint_ignore")
384
+ self._get_bool(cp, self._LINT_SECTION, "strict", "lint_strict")
345
385
 
346
386
  # Register options whose loading lives inline in __init__ (because they
347
387
  # need special-case validation or side effects) so they still appear in
@@ -374,6 +414,51 @@ class ConfigData:
374
414
  precedence over system, user, script, and working-directory
375
415
  config files.
376
416
  """
417
+ self._set_defaults()
418
+ current_script = str(Path(sys.argv[0]).resolve())
419
+ from collections import deque
420
+
421
+ config_queue: deque[str] = deque(self._search_paths(script_path, config_file))
422
+ self.files_read: list = []
423
+ # Warm the schema registry by running the option reader against an
424
+ # empty ConfigParser. cp.has_option() is False for everything so no
425
+ # attribute values change, but every _get_* call registers its
426
+ # (section, ini_key, attr) tuple in ConfigData._schema. This means
427
+ # DEBUG WRITE CONFIG / DEBUG LOG CONFIG see the full option set even
428
+ # when no execsql.conf files exist on the system.
429
+ self._read_known_options(ConfigParser())
430
+ # What each registered option holds before any file is read, and the
431
+ # last file that set it: together they let ``execsql config`` say
432
+ # where every value came from.
433
+ self.defaults: dict[str, object] = {attr: getattr(self, attr) for attr in self._schema}
434
+ self.sources: dict[str, str] = {}
435
+ while config_queue:
436
+ configfile = config_queue.popleft()
437
+ if len(self.files_read) >= self._MAX_CONFIG_CHAIN:
438
+ break
439
+ if configfile not in self.files_read and Path(configfile).is_file():
440
+ self.files_read.append(configfile)
441
+ cp = ConfigParser()
442
+ try:
443
+ cp.read(configfile)
444
+ chained_files = self._apply_file(cp, variable_pool, current_script)
445
+ except ConfigError as exc:
446
+ raise ConfigFileError(f"{configfile}: {exc}") from exc
447
+ except configparser.Error as exc:
448
+ line, text = describe_parse_error(exc)
449
+ where = f"{configfile}, line {line}" if line else configfile
450
+ raise ConfigFileError(f"{where}: {text}") from exc
451
+ for chained in chained_files:
452
+ config_queue.appendleft(chained)
453
+ for section, key, attr in self._option_keys:
454
+ if cp.has_option(section, key):
455
+ self.sources[attr] = configfile
456
+
457
+ #: Guard against circular config_file references.
458
+ _MAX_CONFIG_CHAIN = 20
459
+
460
+ def _set_defaults(self) -> None:
461
+ """Give every option its built-in default, before any file is read."""
377
462
  self.db_type: str | None = "l"
378
463
  self.server: str | None = None
379
464
  self.port: int | None = None
@@ -434,14 +519,16 @@ class ConfigData:
434
519
  self.log_datavars = True
435
520
  self.log_sql = False
436
521
  self.max_log_size_mb = 0
437
- self.smtp_host = None
438
- self.smtp_port = None
439
- self.smtp_username = None
440
- self.smtp_password = None
522
+ self.smtp_host: str | None = None
523
+ self.smtp_port: int | None = None
524
+ self.smtp_username: str | None = None
525
+ self.smtp_password: str | None = None
441
526
  self.smtp_ssl = False
442
527
  self.smtp_tls = False
528
+ self.smtp_verify_certificate = True
529
+ self.smtp_ca_file: str | None = None
443
530
  self.email_format = "plain"
444
- self.email_css = None
531
+ self.email_css: str | None = None
445
532
  self.include_req: list = []
446
533
  self.include_opt: list = []
447
534
  self.export_output_dir: str | None = None
@@ -463,163 +550,177 @@ class ConfigData:
463
550
  # default (10 MB).
464
551
  self.max_substitution_bytes: int | None = None
465
552
  self.zip_buffer_mb = 10
553
+ # [format] and [lint]: the defaults `execsql format` and `execsql lint`
554
+ # use when neither a flag nor a config file says otherwise.
555
+ self.format_indent = 4
556
+ self.format_leading_comma = False
557
+ self.format_sql = True
558
+ self.lint_select: str | None = None
559
+ self.lint_ignore: str | None = None
560
+ self.lint_strict = False
561
+
562
+ @classmethod
563
+ def _search_paths(cls, script_path: str, config_file: str | None) -> list[str]:
564
+ """The files a run reads, lowest precedence first: system, user, script dir, working dir, ``--config``.
565
+
566
+ Files that ``[config] config_file`` and its OS-specific variants chain
567
+ to are found while reading, by :meth:`_apply_file`.
568
+ """
466
569
  if os.name == "posix":
467
- sys_config_file = str(Path("/etc") / self.config_file_name)
570
+ sys_config_file = str(Path("/etc") / cls.config_file_name)
468
571
  else:
469
- sys_config_file = str(Path(os.path.expandvars(r"%APPDATA%")) / self.config_file_name)
470
- current_script = str(Path(sys.argv[0]).resolve())
471
- user_config_file = str(Path("~/.config").expanduser() / self.config_file_name)
472
- script_config_file = str(Path(script_path) / self.config_file_name)
473
- startdir_config_file = str(Path(".").resolve() / self.config_file_name)
572
+ sys_config_file = str(Path(os.path.expandvars(r"%APPDATA%")) / cls.config_file_name)
573
+ user_config_file = str(Path("~/.config").expanduser() / cls.config_file_name)
574
+ script_config_file = str(Path(script_path) / cls.config_file_name)
575
+ startdir_config_file = str(Path(".").resolve() / cls.config_file_name)
474
576
  if startdir_config_file != script_config_file:
475
577
  config_files = [sys_config_file, user_config_file, script_config_file, startdir_config_file]
476
578
  else:
477
579
  config_files = [sys_config_file, user_config_file, startdir_config_file]
478
580
  if config_file:
479
581
  config_files.append(str(Path(config_file).resolve()))
480
- from collections import deque
582
+ return config_files
481
583
 
482
- _MAX_CONFIG_CHAIN = 20 # Guard against circular config_file references.
483
- config_queue: deque[str] = deque(config_files)
484
- self.files_read: list = []
485
- # Warm the schema registry by running the option reader against an
486
- # empty ConfigParser. cp.has_option() is False for everything so no
487
- # attribute values change, but every _get_* call registers its
488
- # (section, ini_key, attr) tuple in ConfigData._schema. This means
489
- # DEBUG WRITE CONFIG / DEBUG LOG CONFIG see the full option set even
490
- # when no execsql.conf files exist on the system.
491
- self._read_known_options(ConfigParser())
492
- while config_queue:
493
- configfile = config_queue.popleft()
494
- if len(self.files_read) >= _MAX_CONFIG_CHAIN:
495
- break
496
- if configfile not in self.files_read and Path(configfile).is_file():
497
- self.files_read.append(configfile)
498
- cp = ConfigParser()
499
- cp.read(configfile)
500
- # --- [connect] ---
501
- if cp.has_option(self._CONNECT_SECTION, "db_type"):
502
- t = cp.get(self._CONNECT_SECTION, "db_type").lower()
503
- if t not in ("a", "d", "f", "k", "l", "m", "o", "p", "s"):
504
- raise ConfigError(f"Invalid database type: {t}")
505
- self.db_type = t
506
- self._read_known_options(cp)
507
- # write_prefix / write_suffix have special "clear" → None handling
508
- if cp.has_option(self._INTERFACE_SECTION, "write_prefix"):
509
- try:
510
- self.write_prefix = cp.get(self._INTERFACE_SECTION, "write_prefix")
511
- except Exception as e:
512
- raise ConfigError("Invalid or missing argument to write_prefix.") from e
513
- if self.write_prefix.lower() == "clear":
514
- self.write_prefix = None
515
- if cp.has_option(self._INTERFACE_SECTION, "write_suffix"):
516
- try:
517
- self.write_suffix = cp.get(self._INTERFACE_SECTION, "write_suffix")
518
- except Exception as e:
519
- raise ConfigError("Invalid or missing argument to write_suffix.") from e
520
- if self.write_suffix.lower() == "clear":
521
- self.write_suffix = None
522
- # gui_level is an integer enum — keep inline to preserve exact error message
523
- if cp.has_option(self._INTERFACE_SECTION, "gui_level"):
524
- self.gui_level = cp.getint(self._INTERFACE_SECTION, "gui_level")
525
- if self.gui_level not in (0, 1, 2, 3):
526
- raise ConfigError(f"Invalid GUI level: {self.gui_level}")
527
- # gui_framework has a specific error message — keep inline
528
- if cp.has_option(self._INTERFACE_SECTION, "gui_framework"):
529
- fw = cp.get(self._INTERFACE_SECTION, "gui_framework").lower()
530
- if fw not in ("tkinter", "textual"):
531
- raise ConfigError("gui_framework must be 'tkinter' or 'textual'.")
532
- self.gui_framework = fw
533
- # --- [config] ---
534
- # config_file / OS-specific config files retain special chaining logic
535
- if cp.has_option(self._CONFIG_SECTION, "config_file"):
536
- conffile = cp.get(self._CONFIG_SECTION, "config_file")
537
- if os.name == "posix" and conffile[0] == "~":
538
- if len(conffile) == 1:
539
- conffile = str(Path("~").expanduser())
540
- elif len(conffile) > 1 and conffile[1] == os.sep:
541
- conffile = str(Path("~").expanduser() / conffile[2:])
542
- conffile = variable_pool.substitute(conffile)[0]
543
- if not Path(conffile).is_file():
544
- conffile = str(Path(conffile) / self.config_file_name)
545
- if Path(conffile).is_file():
546
- # Silently ignore a non-existent file, for cross-OS compatibility.
547
- config_queue.appendleft(conffile)
548
- # OS-specific additional config files.
549
- _os_config_key: str | None = None
550
- if sys.platform == "linux" and cp.has_option(self._CONFIG_SECTION, "linux_config_file"):
551
- _os_config_key = "linux_config_file"
552
- elif sys.platform == "darwin" and cp.has_option(self._CONFIG_SECTION, "macos_config_file"):
553
- _os_config_key = "macos_config_file"
554
- elif os.name == "nt" and cp.has_option(self._CONFIG_SECTION, "win_config_file"):
555
- _os_config_key = "win_config_file"
556
- if _os_config_key:
557
- conffile = cp.get(self._CONFIG_SECTION, _os_config_key)
558
- if conffile and conffile[0] == "~":
559
- if len(conffile) == 1:
560
- conffile = str(Path("~").expanduser())
561
- elif len(conffile) > 1 and conffile[1] == os.sep:
562
- conffile = str(Path("~").expanduser() / conffile[2:])
563
- conffile = variable_pool.substitute(conffile)[0]
564
- if not Path(conffile).is_file():
565
- conffile = str(Path(conffile) / self.config_file_name)
566
- if Path(conffile).is_file():
567
- config_queue.appendleft(conffile)
568
- # dao_flush_delay_secs has a specific error message — keep inline
569
- if cp.has_option(self._CONFIG_SECTION, "dao_flush_delay_secs"):
570
- self.dao_flush_delay_secs = cp.getfloat(self._CONFIG_SECTION, "dao_flush_delay_secs")
571
- if self.dao_flush_delay_secs < 5.0:
572
- raise ConfigError(
573
- f"Invalid DAO flush delay: {self.dao_flush_delay_secs}; must be >= 5.0.",
574
- )
575
- # --- [email] ---
576
- # enc_password has special decryption logic — keep inline
577
- if cp.has_option(self._EMAIL_SECTION, "enc_password"):
578
- import warnings
579
-
580
- warnings.warn(
581
- "enc_password provides obfuscation only, not encryption. "
582
- "Use keyring or environment variables for credential storage.",
583
- DeprecationWarning,
584
- stacklevel=1,
585
- )
586
- self.smtp_password = Encrypt().decrypt(cp.get(self._EMAIL_SECTION, "enc_password"))
587
- # email_format has a specific error message — keep inline
588
- if cp.has_option(self._EMAIL_SECTION, "email_format"):
589
- fmt = cp.get(self._EMAIL_SECTION, "email_format").lower()
590
- if fmt not in ("plain", "html"):
591
- raise ConfigError(f"Invalid email format: {fmt}")
592
- self.email_format = fmt
593
- if cp.has_section(self._VARIABLES_SECTION) and variable_pool:
594
- varsect = cp.items(self._VARIABLES_SECTION)
595
- for sub, repl in varsect:
596
- if not variable_pool.var_name_ok(sub):
597
- raise ConfigError(f"Invalid variable name: {sub}")
598
- variable_pool.add_substitution(sub, repl)
599
- if cp.has_section(self._INCLUDE_REQ_SECTION):
600
- imp_items = cp.items(self._INCLUDE_REQ_SECTION)
601
- ord_items = sorted([(int(i[0]), i[1]) for i in imp_items], key=lambda x: x[0])
602
- newfiles = [str(Path(f[1]).resolve()) for f in ord_items]
603
- u_files = []
604
- for f in newfiles:
605
- if not (f in u_files or f in self.include_req or f in self.include_opt) and f != current_script:
606
- if not Path(f).exists():
607
- raise ConfigError(f"Required include file {f} does not exist.")
608
- u_files.append(f)
609
- self.include_req.extend(u_files)
610
- if cp.has_section(self._INCLUDE_OPT_SECTION):
611
- imp_items = cp.items(self._INCLUDE_OPT_SECTION)
612
- ord_items = sorted([(int(i[0]), i[1]) for i in imp_items], key=lambda x: x[0])
613
- newfiles = [str(Path(f[1]).resolve()) for f in ord_items]
614
- u_files = []
615
- for f in newfiles:
616
- if (
617
- not (f in u_files or f in self.include_req or f in self.include_opt)
618
- and f != current_script
619
- and Path(f).exists()
620
- ):
621
- u_files.append(f)
622
- self.include_opt.extend(u_files)
584
+ @classmethod
585
+ def _chain_target(cls, value: str, variable_pool: _VariablePool, *, expand_home: bool) -> str:
586
+ """Resolve a ``config_file``-style value to the file it names.
587
+
588
+ A leading ``~`` is expanded when *expand_home* is true, substitution
589
+ variables are replaced, and a directory means the ``execsql.conf``
590
+ inside it. The result may not exist; callers decide what that means.
591
+ """
592
+ conffile = value
593
+ if expand_home and conffile and conffile[0] == "~":
594
+ if len(conffile) == 1:
595
+ conffile = str(Path("~").expanduser())
596
+ elif len(conffile) > 1 and conffile[1] == os.sep:
597
+ conffile = str(Path("~").expanduser() / conffile[2:])
598
+ conffile = variable_pool.substitute(conffile)[0]
599
+ if not Path(conffile).is_file():
600
+ conffile = str(Path(conffile) / cls.config_file_name)
601
+ return conffile
602
+
603
+ def _apply_file(self, cp: ConfigParser, variable_pool: _VariablePool, current_script: str) -> list[str]:
604
+ """Apply one parsed config file to ``self``; return the files it chains to.
605
+
606
+ Raises :class:`ConfigError` on the first invalid value. The chained
607
+ files are in the order the caller must push them to the front of its
608
+ queue. ``execsql config --validate`` calls this once per option, on a
609
+ fresh instance, so validation and a run apply exactly the same rules.
610
+ """
611
+ chained: list[str] = []
612
+ # --- [connect] ---
613
+ if cp.has_option(self._CONNECT_SECTION, "db_type"):
614
+ t = cp.get(self._CONNECT_SECTION, "db_type").lower()
615
+ if t not in ("a", "d", "f", "k", "l", "m", "o", "p", "s"):
616
+ raise ConfigError(f"Invalid database type: {t}")
617
+ self.db_type = t
618
+ self._read_known_options(cp)
619
+ # write_prefix / write_suffix have special "clear" → None handling
620
+ if cp.has_option(self._INTERFACE_SECTION, "write_prefix"):
621
+ try:
622
+ self.write_prefix = cp.get(self._INTERFACE_SECTION, "write_prefix")
623
+ except Exception as e:
624
+ raise ConfigError("Invalid or missing argument to write_prefix.") from e
625
+ if self.write_prefix.lower() == "clear":
626
+ self.write_prefix = None
627
+ if cp.has_option(self._INTERFACE_SECTION, "write_suffix"):
628
+ try:
629
+ self.write_suffix = cp.get(self._INTERFACE_SECTION, "write_suffix")
630
+ except Exception as e:
631
+ raise ConfigError("Invalid or missing argument to write_suffix.") from e
632
+ if self.write_suffix.lower() == "clear":
633
+ self.write_suffix = None
634
+ # gui_level is an integer enum — keep inline to preserve exact error message
635
+ if cp.has_option(self._INTERFACE_SECTION, "gui_level"):
636
+ self.gui_level = cp.getint(self._INTERFACE_SECTION, "gui_level")
637
+ if self.gui_level not in (0, 1, 2, 3):
638
+ raise ConfigError(f"Invalid GUI level: {self.gui_level}")
639
+ # gui_framework has a specific error message — keep inline
640
+ if cp.has_option(self._INTERFACE_SECTION, "gui_framework"):
641
+ fw = cp.get(self._INTERFACE_SECTION, "gui_framework").lower()
642
+ if fw not in ("tkinter", "textual"):
643
+ raise ConfigError("gui_framework must be 'tkinter' or 'textual'.")
644
+ self.gui_framework = fw
645
+ # --- [config] ---
646
+ # config_file / OS-specific config files retain special chaining logic
647
+ if cp.has_option(self._CONFIG_SECTION, "config_file"):
648
+ conffile = self._chain_target(
649
+ cp.get(self._CONFIG_SECTION, "config_file"),
650
+ variable_pool,
651
+ expand_home=os.name == "posix",
652
+ )
653
+ if Path(conffile).is_file():
654
+ # Silently ignore a non-existent file, for cross-OS compatibility.
655
+ chained.append(conffile)
656
+ # OS-specific additional config files.
657
+ _os_config_key: str | None = None
658
+ if sys.platform == "linux" and cp.has_option(self._CONFIG_SECTION, "linux_config_file"):
659
+ _os_config_key = "linux_config_file"
660
+ elif sys.platform == "darwin" and cp.has_option(self._CONFIG_SECTION, "macos_config_file"):
661
+ _os_config_key = "macos_config_file"
662
+ elif os.name == "nt" and cp.has_option(self._CONFIG_SECTION, "win_config_file"):
663
+ _os_config_key = "win_config_file"
664
+ if _os_config_key:
665
+ conffile = self._chain_target(cp.get(self._CONFIG_SECTION, _os_config_key), variable_pool, expand_home=True)
666
+ if Path(conffile).is_file():
667
+ chained.append(conffile)
668
+ # dao_flush_delay_secs has a specific error message — keep inline
669
+ if cp.has_option(self._CONFIG_SECTION, "dao_flush_delay_secs"):
670
+ self.dao_flush_delay_secs = cp.getfloat(self._CONFIG_SECTION, "dao_flush_delay_secs")
671
+ if self.dao_flush_delay_secs < 5.0:
672
+ raise ConfigError(
673
+ f"Invalid DAO flush delay: {self.dao_flush_delay_secs}; must be >= 5.0.",
674
+ )
675
+ # --- [email] ---
676
+ # enc_password has special decryption logic — keep inline
677
+ if cp.has_option(self._EMAIL_SECTION, "enc_password"):
678
+ import warnings
679
+
680
+ warnings.warn(
681
+ "enc_password provides obfuscation only, not encryption. "
682
+ "Use keyring or environment variables for credential storage.",
683
+ DeprecationWarning,
684
+ stacklevel=1,
685
+ )
686
+ self.smtp_password = Encrypt().decrypt(cp.get(self._EMAIL_SECTION, "enc_password"))
687
+ # email_format has a specific error message — keep inline
688
+ if cp.has_option(self._EMAIL_SECTION, "email_format"):
689
+ fmt = cp.get(self._EMAIL_SECTION, "email_format").lower()
690
+ if fmt not in ("plain", "html"):
691
+ raise ConfigError(f"Invalid email format: {fmt}")
692
+ self.email_format = fmt
693
+ if cp.has_section(self._VARIABLES_SECTION) and variable_pool:
694
+ varsect = cp.items(self._VARIABLES_SECTION)
695
+ for sub, repl in varsect:
696
+ if not variable_pool.var_name_ok(sub):
697
+ raise ConfigError(f"Invalid variable name: {sub}")
698
+ variable_pool.add_substitution(sub, repl)
699
+ if cp.has_section(self._INCLUDE_REQ_SECTION):
700
+ imp_items = cp.items(self._INCLUDE_REQ_SECTION)
701
+ ord_items = sorted([(int(i[0]), i[1]) for i in imp_items], key=lambda x: x[0])
702
+ newfiles = [str(Path(f[1]).resolve()) for f in ord_items]
703
+ u_files = []
704
+ for f in newfiles:
705
+ if not (f in u_files or f in self.include_req or f in self.include_opt) and f != current_script:
706
+ if not Path(f).exists():
707
+ raise ConfigError(f"Required include file {f} does not exist.")
708
+ u_files.append(f)
709
+ self.include_req.extend(u_files)
710
+ if cp.has_section(self._INCLUDE_OPT_SECTION):
711
+ imp_items = cp.items(self._INCLUDE_OPT_SECTION)
712
+ ord_items = sorted([(int(i[0]), i[1]) for i in imp_items], key=lambda x: x[0])
713
+ newfiles = [str(Path(f[1]).resolve()) for f in ord_items]
714
+ u_files = []
715
+ for f in newfiles:
716
+ if (
717
+ not (f in u_files or f in self.include_req or f in self.include_opt)
718
+ and f != current_script
719
+ and Path(f).exists()
720
+ ):
721
+ u_files.append(f)
722
+ self.include_opt.extend(u_files)
723
+ return chained
623
724
 
624
725
 
625
726
  class WriteHooks:
@@ -630,13 +731,12 @@ class WriteHooks:
630
731
  """
631
732
 
632
733
  def __repr__(self) -> str:
633
- return f"WriteHooks({self.write_func!r}, {self.err_func!r}, {self.status_func!r})"
734
+ return f"WriteHooks({self.write_func!r}, {self.err_func!r})"
634
735
 
635
736
  def __init__(
636
737
  self,
637
738
  standard_output_func: Callable[[str], object] | None = None,
638
739
  error_output_func: Callable[[str], object] | None = None,
639
- status_output_func: Callable[[str], object] | None = None,
640
740
  ) -> None:
641
741
  """Store optional hook callables; ``None`` means use the default stream.
642
742
 
@@ -645,8 +745,6 @@ class WriteHooks:
645
745
  ``None`` to use ``sys.stdout``.
646
746
  error_output_func: Callable to receive error-output text, or
647
747
  ``None`` to use ``sys.stderr``.
648
- status_output_func: Callable to receive status-line text, or
649
- ``None`` to suppress.
650
748
  """
651
749
  # Arguments should be functions that take a single string and
652
750
  # write it to the desired destination. Both stdout and stderr can be hooked.
@@ -655,7 +753,6 @@ class WriteHooks:
655
753
  # The purpose is to allow writing to be redirected to a GUI.
656
754
  self.write_func = standard_output_func
657
755
  self.err_func = error_output_func
658
- self.status_func = status_output_func
659
756
  self.tee_stderr = True
660
757
 
661
758
  def reset(self) -> None:
@@ -673,15 +770,6 @@ class WriteHooks:
673
770
  self.err_func = error_output_func
674
771
  self.tee_stderr = tee
675
772
 
676
- def redir(
677
- self,
678
- standard_output_func: Callable[[str], object],
679
- error_output_func: Callable[[str], object],
680
- ) -> None:
681
- """Redirect both stdout and stderr hooks in one call."""
682
- self.redir_stdout(standard_output_func)
683
- self.redir_stderr(error_output_func)
684
-
685
773
  def write(self, strval: str) -> None:
686
774
  """Write a string to the standard-output hook, or to sys.stdout if unset."""
687
775
  if self.write_func:
@@ -702,8 +790,3 @@ class WriteHooks:
702
790
  else:
703
791
  sys.stderr.write(strval)
704
792
  sys.stderr.flush()
705
-
706
- def write_status(self, strval: str) -> None:
707
- """Forward a status string to the status hook if one is registered."""
708
- if self.status_func:
709
- self.status_func(strval)