execsql2 2.23.1__py3-none-any.whl → 2.24.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 (56) hide show
  1. execsql/cli/__init__.py +17 -586
  2. execsql/cli/application.py +309 -0
  3. execsql/cli/commands/__init__.py +13 -0
  4. execsql/cli/commands/config.py +205 -0
  5. execsql/cli/commands/format.py +100 -0
  6. execsql/cli/commands/init.py +135 -0
  7. execsql/cli/commands/lint.py +233 -0
  8. execsql/cli/commands/listing.py +81 -0
  9. execsql/cli/commands/run.py +451 -0
  10. execsql/cli/dispatch.py +66 -0
  11. execsql/cli/help.py +175 -24
  12. execsql/cli/lint.py +513 -76
  13. execsql/cli/options.py +108 -0
  14. execsql/cli/run.py +41 -14
  15. execsql/config.py +251 -154
  16. execsql/config_validation.py +282 -0
  17. execsql/data/execsql.conf.template +24 -1
  18. execsql/data/script_header.sql +23 -0
  19. execsql/db/factory.py +2 -2
  20. execsql/debug/repl.py +51 -193
  21. execsql/exceptions.py +9 -0
  22. execsql/format.py +87 -75
  23. execsql/interactive.py +232 -0
  24. execsql/manifest.py +189 -0
  25. execsql/metacommands/conditions.py +1 -1
  26. execsql/metacommands/control.py +2 -3
  27. execsql/metacommands/effects.py +124 -0
  28. execsql/script/__init__.py +0 -9
  29. execsql/script/ast.py +1 -1
  30. execsql/script/engine.py +6 -93
  31. execsql/script/executor.py +153 -34
  32. execsql/state.py +11 -2
  33. execsql/state.pyi +3 -0
  34. execsql/utils/color.py +18 -0
  35. execsql/utils/errors.py +11 -1
  36. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
  37. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
  38. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/RECORD +55 -39
  39. execsql2-2.24.0.dist-info/entry_points.txt +2 -0
  40. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  41. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  42. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  43. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  44. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  45. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  46. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  47. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  48. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  49. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  50. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
  51. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  52. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  53. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  54. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
  55. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
  56. {execsql2-2.23.1.dist-info → execsql2-2.24.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>``.
@@ -342,6 +373,13 @@ class ConfigData:
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")
344
375
  self._get_str(cp, self._EMAIL_SECTION, "message_css", "email_css")
376
+ # --- [format] and [lint]: read by `execsql format` / `execsql lint`; a run ignores them ---
377
+ self._get_int(cp, self._FORMAT_SECTION, "indent", "format_indent")
378
+ self._get_bool(cp, self._FORMAT_SECTION, "leading_comma", "format_leading_comma")
379
+ self._get_bool(cp, self._FORMAT_SECTION, "sql", "format_sql")
380
+ self._get_str(cp, self._LINT_SECTION, "select", "lint_select")
381
+ self._get_str(cp, self._LINT_SECTION, "ignore", "lint_ignore")
382
+ self._get_bool(cp, self._LINT_SECTION, "strict", "lint_strict")
345
383
 
346
384
  # Register options whose loading lives inline in __init__ (because they
347
385
  # need special-case validation or side effects) so they still appear in
@@ -374,6 +412,51 @@ class ConfigData:
374
412
  precedence over system, user, script, and working-directory
375
413
  config files.
376
414
  """
415
+ self._set_defaults()
416
+ current_script = str(Path(sys.argv[0]).resolve())
417
+ from collections import deque
418
+
419
+ config_queue: deque[str] = deque(self._search_paths(script_path, config_file))
420
+ self.files_read: list = []
421
+ # Warm the schema registry by running the option reader against an
422
+ # empty ConfigParser. cp.has_option() is False for everything so no
423
+ # attribute values change, but every _get_* call registers its
424
+ # (section, ini_key, attr) tuple in ConfigData._schema. This means
425
+ # DEBUG WRITE CONFIG / DEBUG LOG CONFIG see the full option set even
426
+ # when no execsql.conf files exist on the system.
427
+ self._read_known_options(ConfigParser())
428
+ # What each registered option holds before any file is read, and the
429
+ # last file that set it: together they let ``execsql config`` say
430
+ # where every value came from.
431
+ self.defaults: dict[str, object] = {attr: getattr(self, attr) for attr in self._schema}
432
+ self.sources: dict[str, str] = {}
433
+ while config_queue:
434
+ configfile = config_queue.popleft()
435
+ if len(self.files_read) >= self._MAX_CONFIG_CHAIN:
436
+ break
437
+ if configfile not in self.files_read and Path(configfile).is_file():
438
+ self.files_read.append(configfile)
439
+ cp = ConfigParser()
440
+ try:
441
+ cp.read(configfile)
442
+ chained_files = self._apply_file(cp, variable_pool, current_script)
443
+ except ConfigError as exc:
444
+ raise ConfigFileError(f"{configfile}: {exc}") from exc
445
+ except configparser.Error as exc:
446
+ line, text = describe_parse_error(exc)
447
+ where = f"{configfile}, line {line}" if line else configfile
448
+ raise ConfigFileError(f"{where}: {text}") from exc
449
+ for chained in chained_files:
450
+ config_queue.appendleft(chained)
451
+ for section, key, attr in self._option_keys:
452
+ if cp.has_option(section, key):
453
+ self.sources[attr] = configfile
454
+
455
+ #: Guard against circular config_file references.
456
+ _MAX_CONFIG_CHAIN = 20
457
+
458
+ def _set_defaults(self) -> None:
459
+ """Give every option its built-in default, before any file is read."""
377
460
  self.db_type: str | None = "l"
378
461
  self.server: str | None = None
379
462
  self.port: int | None = None
@@ -434,14 +517,14 @@ class ConfigData:
434
517
  self.log_datavars = True
435
518
  self.log_sql = False
436
519
  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
520
+ self.smtp_host: str | None = None
521
+ self.smtp_port: int | None = None
522
+ self.smtp_username: str | None = None
523
+ self.smtp_password: str | None = None
441
524
  self.smtp_ssl = False
442
525
  self.smtp_tls = False
443
526
  self.email_format = "plain"
444
- self.email_css = None
527
+ self.email_css: str | None = None
445
528
  self.include_req: list = []
446
529
  self.include_opt: list = []
447
530
  self.export_output_dir: str | None = None
@@ -463,163 +546,177 @@ class ConfigData:
463
546
  # default (10 MB).
464
547
  self.max_substitution_bytes: int | None = None
465
548
  self.zip_buffer_mb = 10
549
+ # [format] and [lint]: the defaults `execsql format` and `execsql lint`
550
+ # use when neither a flag nor a config file says otherwise.
551
+ self.format_indent = 4
552
+ self.format_leading_comma = False
553
+ self.format_sql = True
554
+ self.lint_select: str | None = None
555
+ self.lint_ignore: str | None = None
556
+ self.lint_strict = False
557
+
558
+ @classmethod
559
+ def _search_paths(cls, script_path: str, config_file: str | None) -> list[str]:
560
+ """The files a run reads, lowest precedence first: system, user, script dir, working dir, ``--config``.
561
+
562
+ Files that ``[config] config_file`` and its OS-specific variants chain
563
+ to are found while reading, by :meth:`_apply_file`.
564
+ """
466
565
  if os.name == "posix":
467
- sys_config_file = str(Path("/etc") / self.config_file_name)
566
+ sys_config_file = str(Path("/etc") / cls.config_file_name)
468
567
  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)
568
+ sys_config_file = str(Path(os.path.expandvars(r"%APPDATA%")) / cls.config_file_name)
569
+ user_config_file = str(Path("~/.config").expanduser() / cls.config_file_name)
570
+ script_config_file = str(Path(script_path) / cls.config_file_name)
571
+ startdir_config_file = str(Path(".").resolve() / cls.config_file_name)
474
572
  if startdir_config_file != script_config_file:
475
573
  config_files = [sys_config_file, user_config_file, script_config_file, startdir_config_file]
476
574
  else:
477
575
  config_files = [sys_config_file, user_config_file, startdir_config_file]
478
576
  if config_file:
479
577
  config_files.append(str(Path(config_file).resolve()))
480
- from collections import deque
578
+ return config_files
481
579
 
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)
580
+ @classmethod
581
+ def _chain_target(cls, value: str, variable_pool: _VariablePool, *, expand_home: bool) -> str:
582
+ """Resolve a ``config_file``-style value to the file it names.
583
+
584
+ A leading ``~`` is expanded when *expand_home* is true, substitution
585
+ variables are replaced, and a directory means the ``execsql.conf``
586
+ inside it. The result may not exist; callers decide what that means.
587
+ """
588
+ conffile = value
589
+ if expand_home and conffile and conffile[0] == "~":
590
+ if len(conffile) == 1:
591
+ conffile = str(Path("~").expanduser())
592
+ elif len(conffile) > 1 and conffile[1] == os.sep:
593
+ conffile = str(Path("~").expanduser() / conffile[2:])
594
+ conffile = variable_pool.substitute(conffile)[0]
595
+ if not Path(conffile).is_file():
596
+ conffile = str(Path(conffile) / cls.config_file_name)
597
+ return conffile
598
+
599
+ def _apply_file(self, cp: ConfigParser, variable_pool: _VariablePool, current_script: str) -> list[str]:
600
+ """Apply one parsed config file to ``self``; return the files it chains to.
601
+
602
+ Raises :class:`ConfigError` on the first invalid value. The chained
603
+ files are in the order the caller must push them to the front of its
604
+ queue. ``execsql config --validate`` calls this once per option, on a
605
+ fresh instance, so validation and a run apply exactly the same rules.
606
+ """
607
+ chained: list[str] = []
608
+ # --- [connect] ---
609
+ if cp.has_option(self._CONNECT_SECTION, "db_type"):
610
+ t = cp.get(self._CONNECT_SECTION, "db_type").lower()
611
+ if t not in ("a", "d", "f", "k", "l", "m", "o", "p", "s"):
612
+ raise ConfigError(f"Invalid database type: {t}")
613
+ self.db_type = t
614
+ self._read_known_options(cp)
615
+ # write_prefix / write_suffix have special "clear" → None handling
616
+ if cp.has_option(self._INTERFACE_SECTION, "write_prefix"):
617
+ try:
618
+ self.write_prefix = cp.get(self._INTERFACE_SECTION, "write_prefix")
619
+ except Exception as e:
620
+ raise ConfigError("Invalid or missing argument to write_prefix.") from e
621
+ if self.write_prefix.lower() == "clear":
622
+ self.write_prefix = None
623
+ if cp.has_option(self._INTERFACE_SECTION, "write_suffix"):
624
+ try:
625
+ self.write_suffix = cp.get(self._INTERFACE_SECTION, "write_suffix")
626
+ except Exception as e:
627
+ raise ConfigError("Invalid or missing argument to write_suffix.") from e
628
+ if self.write_suffix.lower() == "clear":
629
+ self.write_suffix = None
630
+ # gui_level is an integer enum — keep inline to preserve exact error message
631
+ if cp.has_option(self._INTERFACE_SECTION, "gui_level"):
632
+ self.gui_level = cp.getint(self._INTERFACE_SECTION, "gui_level")
633
+ if self.gui_level not in (0, 1, 2, 3):
634
+ raise ConfigError(f"Invalid GUI level: {self.gui_level}")
635
+ # gui_framework has a specific error message — keep inline
636
+ if cp.has_option(self._INTERFACE_SECTION, "gui_framework"):
637
+ fw = cp.get(self._INTERFACE_SECTION, "gui_framework").lower()
638
+ if fw not in ("tkinter", "textual"):
639
+ raise ConfigError("gui_framework must be 'tkinter' or 'textual'.")
640
+ self.gui_framework = fw
641
+ # --- [config] ---
642
+ # config_file / OS-specific config files retain special chaining logic
643
+ if cp.has_option(self._CONFIG_SECTION, "config_file"):
644
+ conffile = self._chain_target(
645
+ cp.get(self._CONFIG_SECTION, "config_file"),
646
+ variable_pool,
647
+ expand_home=os.name == "posix",
648
+ )
649
+ if Path(conffile).is_file():
650
+ # Silently ignore a non-existent file, for cross-OS compatibility.
651
+ chained.append(conffile)
652
+ # OS-specific additional config files.
653
+ _os_config_key: str | None = None
654
+ if sys.platform == "linux" and cp.has_option(self._CONFIG_SECTION, "linux_config_file"):
655
+ _os_config_key = "linux_config_file"
656
+ elif sys.platform == "darwin" and cp.has_option(self._CONFIG_SECTION, "macos_config_file"):
657
+ _os_config_key = "macos_config_file"
658
+ elif os.name == "nt" and cp.has_option(self._CONFIG_SECTION, "win_config_file"):
659
+ _os_config_key = "win_config_file"
660
+ if _os_config_key:
661
+ conffile = self._chain_target(cp.get(self._CONFIG_SECTION, _os_config_key), variable_pool, expand_home=True)
662
+ if Path(conffile).is_file():
663
+ chained.append(conffile)
664
+ # dao_flush_delay_secs has a specific error message — keep inline
665
+ if cp.has_option(self._CONFIG_SECTION, "dao_flush_delay_secs"):
666
+ self.dao_flush_delay_secs = cp.getfloat(self._CONFIG_SECTION, "dao_flush_delay_secs")
667
+ if self.dao_flush_delay_secs < 5.0:
668
+ raise ConfigError(
669
+ f"Invalid DAO flush delay: {self.dao_flush_delay_secs}; must be >= 5.0.",
670
+ )
671
+ # --- [email] ---
672
+ # enc_password has special decryption logic — keep inline
673
+ if cp.has_option(self._EMAIL_SECTION, "enc_password"):
674
+ import warnings
675
+
676
+ warnings.warn(
677
+ "enc_password provides obfuscation only, not encryption. "
678
+ "Use keyring or environment variables for credential storage.",
679
+ DeprecationWarning,
680
+ stacklevel=1,
681
+ )
682
+ self.smtp_password = Encrypt().decrypt(cp.get(self._EMAIL_SECTION, "enc_password"))
683
+ # email_format has a specific error message — keep inline
684
+ if cp.has_option(self._EMAIL_SECTION, "email_format"):
685
+ fmt = cp.get(self._EMAIL_SECTION, "email_format").lower()
686
+ if fmt not in ("plain", "html"):
687
+ raise ConfigError(f"Invalid email format: {fmt}")
688
+ self.email_format = fmt
689
+ if cp.has_section(self._VARIABLES_SECTION) and variable_pool:
690
+ varsect = cp.items(self._VARIABLES_SECTION)
691
+ for sub, repl in varsect:
692
+ if not variable_pool.var_name_ok(sub):
693
+ raise ConfigError(f"Invalid variable name: {sub}")
694
+ variable_pool.add_substitution(sub, repl)
695
+ if cp.has_section(self._INCLUDE_REQ_SECTION):
696
+ imp_items = cp.items(self._INCLUDE_REQ_SECTION)
697
+ ord_items = sorted([(int(i[0]), i[1]) for i in imp_items], key=lambda x: x[0])
698
+ newfiles = [str(Path(f[1]).resolve()) for f in ord_items]
699
+ u_files = []
700
+ for f in newfiles:
701
+ if not (f in u_files or f in self.include_req or f in self.include_opt) and f != current_script:
702
+ if not Path(f).exists():
703
+ raise ConfigError(f"Required include file {f} does not exist.")
704
+ u_files.append(f)
705
+ self.include_req.extend(u_files)
706
+ if cp.has_section(self._INCLUDE_OPT_SECTION):
707
+ imp_items = cp.items(self._INCLUDE_OPT_SECTION)
708
+ ord_items = sorted([(int(i[0]), i[1]) for i in imp_items], key=lambda x: x[0])
709
+ newfiles = [str(Path(f[1]).resolve()) for f in ord_items]
710
+ u_files = []
711
+ for f in newfiles:
712
+ if (
713
+ not (f in u_files or f in self.include_req or f in self.include_opt)
714
+ and f != current_script
715
+ and Path(f).exists()
716
+ ):
717
+ u_files.append(f)
718
+ self.include_opt.extend(u_files)
719
+ return chained
623
720
 
624
721
 
625
722
  class WriteHooks: