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.
- execsql/api.py +169 -160
- execsql/cli/__init__.py +19 -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 +225 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +459 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/dsn.py +11 -4
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +780 -100
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +96 -32
- execsql/config.py +256 -173
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +38 -5
- execsql/data/script_header.sql +23 -0
- execsql/db/access.py +29 -8
- execsql/db/base.py +40 -8
- execsql/db/dsn.py +13 -4
- execsql/db/duckdb.py +3 -1
- execsql/db/factory.py +13 -4
- execsql/db/firebird.py +21 -7
- execsql/db/mysql.py +37 -16
- execsql/db/oracle.py +49 -16
- execsql/db/postgres.py +30 -6
- execsql/db/sqlite.py +7 -9
- execsql/db/sqlserver.py +25 -8
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +115 -76
- execsql/gui/desktop.py +1 -3
- execsql/gui/tui.py +2 -2
- execsql/importers/base.py +35 -1
- execsql/importers/csv.py +3 -1
- execsql/interactive.py +232 -0
- execsql/manifest.py +200 -0
- execsql/metacommands/conditions.py +7 -8
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/effects.py +124 -0
- execsql/metacommands/io_fileops.py +62 -37
- execsql/metacommands/io_import.py +0 -1
- execsql/metacommands/prompt.py +2 -0
- execsql/metacommands/upsert.py +5 -2
- execsql/models.py +3 -2
- execsql/parser.py +14 -5
- execsql/plugins.py +24 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +46 -104
- execsql/script/executor.py +162 -39
- execsql/script/parser.py +167 -118
- execsql/state.py +34 -11
- execsql/state.pyi +11 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/color.py +18 -0
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +82 -10
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
- execsql2-2.25.0.dist-info/RECORD +140 -0
- execsql2-2.25.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.1.dist-info/RECORD +0 -124
- execsql2-2.23.1.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {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") /
|
|
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%")) /
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
582
|
+
return config_files
|
|
481
583
|
|
|
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)
|
|
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}
|
|
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)
|