execsql2 2.23.0__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.
- execsql/cli/__init__.py +17 -586
- execsql/cli/application.py +309 -0
- execsql/cli/commands/__init__.py +13 -0
- execsql/cli/commands/config.py +205 -0
- execsql/cli/commands/format.py +100 -0
- execsql/cli/commands/init.py +135 -0
- execsql/cli/commands/lint.py +233 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +451 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +513 -76
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +41 -14
- execsql/config.py +251 -154
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +24 -1
- execsql/data/script_header.sql +23 -0
- execsql/db/access.py +22 -6
- execsql/db/base.py +105 -1
- execsql/db/dsn.py +2 -0
- execsql/db/duckdb.py +95 -2
- execsql/db/factory.py +2 -2
- execsql/db/mysql.py +11 -0
- execsql/db/oracle.py +12 -4
- execsql/db/sqlite.py +16 -0
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/format.py +87 -75
- execsql/interactive.py +232 -0
- execsql/manifest.py +189 -0
- execsql/metacommands/conditions.py +1 -1
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/effects.py +124 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +6 -93
- execsql/script/executor.py +153 -34
- execsql/state.py +11 -2
- execsql/state.pyi +3 -0
- execsql/utils/color.py +18 -0
- execsql/utils/errors.py +11 -1
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/RECORD +62 -46
- execsql2-2.24.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.0.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.0.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") /
|
|
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%")) /
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
578
|
+
return config_files
|
|
481
579
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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:
|