execsql2 2.17.0__py3-none-any.whl → 2.17.2__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 (73) hide show
  1. execsql/__init__.py +6 -2
  2. execsql/api.py +25 -6
  3. execsql/cli/__init__.py +5 -3
  4. execsql/cli/lint.py +30 -34
  5. execsql/cli/run.py +10 -0
  6. execsql/config.py +145 -92
  7. execsql/db/access.py +54 -40
  8. execsql/db/base.py +33 -6
  9. execsql/db/firebird.py +3 -1
  10. execsql/db/mysql.py +4 -3
  11. execsql/db/oracle.py +36 -14
  12. execsql/db/postgres.py +8 -6
  13. execsql/db/sqlite.py +5 -2
  14. execsql/db/sqlserver.py +8 -6
  15. execsql/debug/repl.py +59 -21
  16. execsql/exceptions.py +19 -4
  17. execsql/exporters/base.py +3 -2
  18. execsql/exporters/delimited.py +2 -3
  19. execsql/exporters/feather.py +3 -3
  20. execsql/exporters/ods.py +1 -1
  21. execsql/exporters/xls.py +12 -4
  22. execsql/exporters/xlsx.py +1 -1
  23. execsql/gui/desktop.py +129 -15
  24. execsql/importers/__init__.py +1 -1
  25. execsql/importers/ods.py +1 -1
  26. execsql/importers/xls.py +1 -1
  27. execsql/metacommands/__init__.py +34 -5
  28. execsql/metacommands/conditions.py +26 -14
  29. execsql/metacommands/connect.py +21 -14
  30. execsql/metacommands/control.py +55 -68
  31. execsql/metacommands/data.py +25 -9
  32. execsql/metacommands/debug.py +132 -77
  33. execsql/metacommands/io_export.py +14 -2
  34. execsql/metacommands/io_import.py +11 -2
  35. execsql/metacommands/io_write.py +113 -11
  36. execsql/metacommands/prompt.py +46 -32
  37. execsql/metacommands/script_ext.py +63 -34
  38. execsql/metacommands/system.py +4 -3
  39. execsql/script/__init__.py +28 -37
  40. execsql/script/ast.py +7 -7
  41. execsql/script/control.py +4 -101
  42. execsql/script/engine.py +37 -251
  43. execsql/script/executor.py +181 -222
  44. execsql/script/parser.py +1 -3
  45. execsql/script/variables.py +8 -3
  46. execsql/state.py +125 -37
  47. execsql/utils/errors.py +0 -2
  48. execsql/utils/fileio.py +47 -3
  49. execsql/utils/mail.py +3 -2
  50. execsql/utils/strings.py +5 -5
  51. {execsql2-2.17.0.dist-info → execsql2-2.17.2.dist-info}/METADATA +42 -36
  52. execsql2-2.17.2.dist-info/RECORD +124 -0
  53. execsql2-2.17.2.dist-info/licenses/NOTICE +11 -0
  54. execsql2-2.17.0.dist-info/RECORD +0 -124
  55. execsql2-2.17.0.dist-info/licenses/NOTICE +0 -10
  56. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/README.md +0 -0
  57. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  58. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  59. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/execsql.conf +0 -0
  60. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
  61. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/md_compare.sql +0 -0
  62. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
  63. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
  64. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
  65. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  66. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  67. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/script_template.sql +0 -0
  68. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
  69. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  70. {execsql2-2.17.0.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  71. {execsql2-2.17.0.dist-info → execsql2-2.17.2.dist-info}/WHEEL +0 -0
  72. {execsql2-2.17.0.dist-info → execsql2-2.17.2.dist-info}/entry_points.txt +0 -0
  73. {execsql2-2.17.0.dist-info → execsql2-2.17.2.dist-info}/licenses/LICENSE.txt +0 -0
execsql/__init__.py CHANGED
@@ -3,8 +3,12 @@ execsql — a maintained fork of the execsql SQL scripting tool.
3
3
 
4
4
  This package provides the ``execsql`` CLI command (distributed as the
5
5
  ``execsql2`` package on PyPI) and the ``execsql`` importable module.
6
- The top-level package exposes only the package version; all public
7
- functionality lives in sub-modules.
6
+
7
+ The top-level package re-exports the public Python API for programmatic
8
+ use: :func:`run`, :class:`ScriptResult`, :class:`ScriptError`, and the
9
+ :class:`ExecSqlError` exception that :meth:`ScriptResult.raise_on_error`
10
+ raises. Internal sub-modules carry the rest of the implementation
11
+ (``cli``, ``db``, ``script``, ``metacommands``, etc.).
8
12
  """
9
13
 
10
14
  from __future__ import annotations
execsql/api.py CHANGED
@@ -76,8 +76,12 @@ class ScriptResult:
76
76
  commands_run: Number of SQL statements and metacommands executed.
77
77
  elapsed: Wall-clock execution time in seconds.
78
78
  errors: List of errors encountered (empty on success).
79
- variables: Final state of all user-defined substitution variables
80
- (``$``-prefixed names, without the ``$``).
79
+ variables: Final state of substitution variables, keyed by name
80
+ without any sigil. Includes user-defined variables (no prefix)
81
+ and system variables (the leading ``$`` is stripped, so
82
+ ``$DATE_TAG`` appears as ``"DATE_TAG"``). Environment (``&``),
83
+ column-data (``@``), script-local (``~``), and script-parameter
84
+ (``#``) variables are excluded.
81
85
  """
82
86
 
83
87
  success: bool
@@ -100,7 +104,20 @@ class ScriptResult:
100
104
 
101
105
 
102
106
  class ExecSqlError(Exception):
103
- """Raised by :meth:`ScriptResult.raise_on_error` when a script fails."""
107
+ """Raised by :meth:`ScriptResult.raise_on_error` when a script fails.
108
+
109
+ Note:
110
+ This is a separate class from :class:`execsql.exceptions.ExecSqlError`,
111
+ which is the internal base of the in-process exception hierarchy
112
+ (``ErrInfo``, ``ConfigError``, etc.). The library API never
113
+ propagates those — internal exceptions are caught and converted
114
+ to :class:`ScriptError` entries on the returned :class:`ScriptResult`.
115
+ Only ``raise_on_error()`` raises this public exception.
116
+
117
+ Attributes:
118
+ result: The :class:`ScriptResult` that triggered the raise. The
119
+ full error list is available on ``result.errors``.
120
+ """
104
121
 
105
122
  def __init__(self, message: str, result: ScriptResult) -> None:
106
123
  super().__init__(message)
@@ -338,8 +355,10 @@ def run(
338
355
  error. If ``False``, capture errors and continue.
339
356
  new_db: If ``True``, create the database if it does not exist
340
357
  (SQLite, PostgreSQL, DuckDB).
341
- allow_system_cmd: If ``False``, the SYSTEM_CMD (SHELL) metacommand
342
- is disabled and will raise an error if encountered.
358
+ allow_system_cmd: If ``False``, the SYSTEM_CMD metacommand is
359
+ disabled and will raise an error if encountered. Matches the
360
+ ``--no-system-cmd`` CLI flag and the ``allow_system_cmd``
361
+ config option.
343
362
 
344
363
  Returns:
345
364
  A :class:`ScriptResult` with execution outcome, timing, errors,
@@ -449,7 +468,7 @@ def run(
449
468
  ctx.exec_log = _NoOpLogger()
450
469
 
451
470
  with active_context(ctx):
452
- # Initialize singletons (IfLevels, CounterVars, Timer, DatabasePool, etc.)
471
+ # Initialize singletons (CounterVars, Timer, DatabasePool, BatchLevels, etc.)
453
472
  from execsql.state import initialize
454
473
 
455
474
  initialize(conf, DISPATCH_TABLE, CONDITIONAL_TABLE)
execsql/cli/__init__.py CHANGED
@@ -5,9 +5,11 @@ initialisation, database connection, and script execution.
5
5
 
6
6
  Submodules:
7
7
 
8
- - :mod:`execsql.cli.help` — Rich-formatted help output & console objects
9
- - :mod:`execsql.cli.dsn` — Connection-string (DSN URL) parser
10
- - :mod:`execsql.cli.run` — Core execution logic
8
+ - :mod:`execsql.cli.help` — Rich-formatted help output & console objects
9
+ - :mod:`execsql.cli.dsn` — Connection-string (DSN URL) parser
10
+ - :mod:`execsql.cli.run` — Core execution logic (``_run``, ``_connect_initial_db``, ``_ping_db``, ``_print_dry_run``, ``_print_profile``)
11
+ - :mod:`execsql.cli.lint_ast` — AST-based ``--lint`` static analyser
12
+ - :mod:`execsql.cli.lint` — Lint result printing (``_print_lint_results``); the flat-CommandList ``_lint_cmdlist`` it also contains is legacy and no longer reached from the CLI
11
13
  """
12
14
 
13
15
  from __future__ import annotations
execsql/cli/lint.py CHANGED
@@ -1,37 +1,33 @@
1
- """Static analysis (lint) for execsql scripts.
2
-
3
- :func:`_lint_script` inspects a parsed :class:`~execsql.script.CommandList`
4
- for common structural problems without connecting to a database or executing
5
- any commands.
6
-
7
- Checks performed
8
- ----------------
9
- 1. **Unmatched IF / ENDIF** — mismatched nesting depth (error).
10
- 2. **Unmatched LOOP / END LOOP** — mismatched nesting depth (error).
11
- 3. **Unmatched BEGIN BATCH / END BATCH** — mismatched nesting depth (error).
12
- 4. **Potentially undefined variables** ``!!$VAR!!`` tokens not preceded by a
13
- ``SUB`` (or ``SUB_EMPTY``, ``SUB_ADD``, ``SUB_APPEND``, ``SUBDATA``)
14
- metacommand in the same parsed command list and not in the set of built-in
15
- variables (warning). Note: ``SUB_INI`` and ``SELECT_SUB`` define variables
16
- whose names are not statically knowable those may produce false-positive
17
- warnings.
18
- 5. **EXECUTE SCRIPT flow analysis** — when an ``EXECUTE SCRIPT <name>``
19
- metacommand is encountered, the linter descends into the named script
20
- block (if found in ``_state.savedscripts``) and merges any variables it
21
- defines back into the caller's scope.
22
- 6. **Missing INCLUDE files** INCLUDE target does not exist on disk relative
23
- to the script directory (warning).
24
- 7. **Empty script** no commands found (warning).
25
-
26
- The function walks ``CommandList.cmdlist`` and also descends into any
27
- ``CommandList`` objects stored in ``_state.savedscripts`` (i.e. named scripts
28
- defined with ``BEGIN SCRIPT … END SCRIPT`` in the same file). SCRIPT blocks
29
- are analysed in isolation; nesting counters reset for each block.
30
-
31
- Exit-code contract
32
- ------------------
33
- - Returns ``1`` when at least one **error**-severity issue is found.
34
- - Returns ``0`` when only warnings (or nothing) are found.
1
+ """Legacy flat-CommandList linter and shared result printer for execsql.
2
+
3
+ The active ``--lint`` implementation is in :mod:`execsql.cli.lint_ast`,
4
+ which works against the AST produced by
5
+ :func:`execsql.script.parser.parse_script`. The CLI reaches that
6
+ linter directly; the ``_lint_cmdlist`` walker in this module is
7
+ unreached from the CLI now and is retained for reference / potential
8
+ reuse only.
9
+
10
+ What's still used from this module:
11
+
12
+ - :func:`_print_lint_results`shared Rich-formatted output for both
13
+ the AST linter and any code that still constructs lint issues
14
+ manually. Called from ``cli/__init__.py`` and re-exported via
15
+ ``cli/run.py``.
16
+ - :class:`_Issue` and the ``_error()`` / ``_warning()`` constructors
17
+ — used by both linters.
18
+
19
+ The flat-CommandList ``_lint_cmdlist``, ``_collect_defined_vars``, and
20
+ ``_discover_builtin_vars`` helpers below covered the same checks the
21
+ AST linter now performs (unmatched IF/LOOP/BATCH, undefined ``!!$VAR!!``
22
+ references, missing INCLUDE files, EXECUTE SCRIPT flow analysis, empty
23
+ script). They operate on the legacy
24
+ :class:`~execsql.script.engine.CommandList`; no CLI path constructs
25
+ that representation any more.
26
+
27
+ Exit-code contract (still honoured by the AST linter):
28
+
29
+ - ``1`` when at least one error-severity issue is found.
30
+ - ``0`` when only warnings (or nothing) are found.
35
31
  """
36
32
 
37
33
  from __future__ import annotations
execsql/cli/run.py CHANGED
@@ -630,6 +630,16 @@ def _run(
630
630
  import execsql.utils.fileio as _fileio
631
631
 
632
632
  if _state.filewriter is None or not _state.filewriter.is_alive():
633
+ # Drain stale messages from the queues so a previously-dead subprocess
634
+ # can't leak responses or unconsumed commands into the new one. On
635
+ # macOS (`spawn`) the OS pipe buffer is small enough that retained
636
+ # entries from a crashed writer would deadlock the next put().
637
+ for q in (_fileio.fw_input, _fileio.fw_output):
638
+ try:
639
+ while True:
640
+ q.get_nowait()
641
+ except Exception:
642
+ pass
633
643
  _fileio.filewriter = _state.filewriter = FileWriter(
634
644
  _fileio.fw_input,
635
645
  _fileio.fw_output,
execsql/config.py CHANGED
@@ -74,6 +74,17 @@ class ConfigData:
74
74
  _INCLUDE_REQ_SECTION = "include_required"
75
75
  _INCLUDE_OPT_SECTION = "include_optional"
76
76
 
77
+ # Schema registry: maps attribute name -> (section, ini_key, type_label).
78
+ # Populated by the _get_* methods on every option they read. This is the
79
+ # canonical source for DEBUG WRITE CONFIG / DEBUG LOG CONFIG output —
80
+ # adding a new option below automatically makes it appear in the dump.
81
+ _schema: dict[str, tuple[str, str, str]] = {}
82
+
83
+ @classmethod
84
+ def _register_option(cls, section: str, key: str, attr: str, type_label: str) -> None:
85
+ """Record ``attr`` in the schema registry so config introspection sees it."""
86
+ cls._schema[attr] = (section, key, type_label)
87
+
77
88
  def _get_str(self, cp: ConfigParser, section: str, key: str, attr: str, *, required: bool = False) -> None:
78
89
  """Read a string option and set ``self.<attr>``.
79
90
 
@@ -84,6 +95,7 @@ class ConfigData:
84
95
  attr: Attribute name to set on ``self``.
85
96
  required: If ``True``, raise :class:`ConfigError` when the value is ``None``.
86
97
  """
98
+ self._register_option(section, key, attr, "str")
87
99
  if cp.has_option(section, key):
88
100
  val = cp.get(section, key)
89
101
  if required and val is None:
@@ -110,6 +122,7 @@ class ConfigData:
110
122
  choices: Tuple of permitted values.
111
123
  lower: If ``True`` (default), lower-case the raw value before validation.
112
124
  """
125
+ self._register_option(section, key, attr, "enum")
113
126
  if cp.has_option(section, key):
114
127
  val = cp.get(section, key)
115
128
  if lower:
@@ -130,6 +143,7 @@ class ConfigData:
130
143
  Raises:
131
144
  ConfigError: If the value cannot be parsed as a boolean.
132
145
  """
146
+ self._register_option(section, key, attr, "bool")
133
147
  if cp.has_option(section, key):
134
148
  try:
135
149
  setattr(self, attr, cp.getboolean(section, key))
@@ -159,6 +173,7 @@ class ConfigData:
159
173
  Raises:
160
174
  ConfigError: If the value cannot be parsed as an integer.
161
175
  """
176
+ self._register_option(section, key, attr, "int")
162
177
  if cp.has_option(section, key):
163
178
  try:
164
179
  val = cp.getint(section, key) * multiply
@@ -189,6 +204,7 @@ class ConfigData:
189
204
  Raises:
190
205
  ConfigError: If the value cannot be parsed as a float, or is below ``min_val``.
191
206
  """
207
+ self._register_option(section, key, attr, "float")
192
208
  if cp.has_option(section, key):
193
209
  try:
194
210
  val = cp.getfloat(section, key)
@@ -198,6 +214,127 @@ class ConfigData:
198
214
  raise ConfigError(f"Invalid {key}: {val}; must be >= {min_val}.")
199
215
  setattr(self, attr, val)
200
216
 
217
+ def _read_known_options(self, cp: ConfigParser) -> None:
218
+ """Apply every schema-registered option from ``cp`` to ``self``.
219
+
220
+ Single source of truth for which options ``ConfigData`` knows about.
221
+ Called once with an empty ``ConfigParser`` at the start of ``__init__``
222
+ so :attr:`_schema` is populated even when no config files exist on the
223
+ system, then again from the file-merge loop for each real config file
224
+ (which actually sets attribute values).
225
+
226
+ Options with special-case validation (``db_type``, ``write_prefix``,
227
+ ``gui_level``, ``gui_framework``, ``dao_flush_delay_secs``,
228
+ ``enc_password``, ``email_format``) stay inline in ``__init__`` to
229
+ preserve their exact error messages and side effects.
230
+ """
231
+ # --- [connect] ---
232
+ self._get_str(cp, self._CONNECT_SECTION, "server", "server", required=True)
233
+ self._get_str(cp, self._CONNECT_SECTION, "db", "db", required=True)
234
+ self._get_int(cp, self._CONNECT_SECTION, "port", "port")
235
+ self._get_str(cp, self._CONNECT_SECTION, "database", "db", required=True)
236
+ self._get_str(cp, self._CONNECT_SECTION, "db_file", "db_file", required=True)
237
+ self._get_str(cp, self._CONNECT_SECTION, "username", "username", required=True)
238
+ self._get_str(cp, self._CONNECT_SECTION, "access_username", "access_username")
239
+ self._get_bool(cp, self._CONNECT_SECTION, "password_prompt", "passwd_prompt")
240
+ self._get_bool(cp, self._CONNECT_SECTION, "use_keyring", "use_keyring")
241
+ self._get_bool(cp, self._CONNECT_SECTION, "new_db", "new_db")
242
+ # --- [encoding] ---
243
+ self._get_str(cp, self._ENCODING_SECTION, "database", "db_encoding")
244
+ self._get_str(cp, self._ENCODING_SECTION, "script", "script_encoding", required=True)
245
+ self._get_str(cp, self._ENCODING_SECTION, "import", "import_encoding", required=True)
246
+ self._get_str(cp, self._ENCODING_SECTION, "output", "output_encoding", required=True)
247
+ self._get_enum(
248
+ cp,
249
+ self._ENCODING_SECTION,
250
+ "error_response",
251
+ "enc_err_disposition",
252
+ ("ignore", "replace", "xmlcharrefreplace", "backslashreplace"),
253
+ )
254
+ # --- [input] ---
255
+ self._get_int(cp, self._INPUT_SECTION, "max_int", "max_int")
256
+ self._get_bool(cp, self._INPUT_SECTION, "boolean_int", "boolean_int")
257
+ self._get_bool(cp, self._INPUT_SECTION, "boolean_words", "boolean_words")
258
+ self._get_bool(cp, self._INPUT_SECTION, "empty_strings", "empty_strings")
259
+ self._get_bool(cp, self._INPUT_SECTION, "only_strings", "only_strings")
260
+ self._get_bool(cp, self._INPUT_SECTION, "empty_rows", "empty_rows")
261
+ self._get_bool(cp, self._INPUT_SECTION, "delete_empty_columns", "del_empty_cols")
262
+ self._get_bool(cp, self._INPUT_SECTION, "create_column_headers", "create_col_hdrs")
263
+ self._get_enum(
264
+ cp,
265
+ self._INPUT_SECTION,
266
+ "trim_column_headers",
267
+ "trim_col_hdrs",
268
+ ("none", "both", "left", "right"),
269
+ )
270
+ self._get_bool(cp, self._INPUT_SECTION, "clean_column_headers", "clean_col_hdrs")
271
+ self._get_enum(
272
+ cp,
273
+ self._INPUT_SECTION,
274
+ "fold_column_headers",
275
+ "fold_col_hdrs",
276
+ ("no", "lower", "upper"),
277
+ )
278
+ self._get_bool(cp, self._INPUT_SECTION, "dedup_column_headers", "dedup_col_hdrs")
279
+ self._get_bool(cp, self._INPUT_SECTION, "trim_strings", "trim_strings")
280
+ self._get_bool(cp, self._INPUT_SECTION, "replace_newlines", "replace_newlines")
281
+ self._get_int(cp, self._INPUT_SECTION, "import_row_buffer", "import_row_buffer")
282
+ self._get_int(cp, self._INPUT_SECTION, "import_progress_interval", "import_progress_interval")
283
+ self._get_bool(cp, self._INPUT_SECTION, "show_progress", "show_progress")
284
+ self._get_bool(cp, self._INPUT_SECTION, "access_use_numeric", "access_use_numeric")
285
+ self._get_bool(cp, self._INPUT_SECTION, "import_only_common_columns", "import_common_cols_only")
286
+ self._get_bool(cp, self._INPUT_SECTION, "import_common_columns_only", "import_common_cols_only")
287
+ self._get_int(cp, self._INPUT_SECTION, "scan_lines", "scan_lines")
288
+ self._get_int(cp, self._INPUT_SECTION, "import_buffer", "import_buffer", multiply=1024)
289
+ # --- [output] ---
290
+ self._get_bool(cp, self._OUTPUT_SECTION, "log_write_messages", "tee_write_log")
291
+ self._get_int(cp, self._OUTPUT_SECTION, "hdf5_text_len", "hdf5_text_len")
292
+ self._get_str(cp, self._OUTPUT_SECTION, "css_file", "css_file", required=True)
293
+ self._get_str(cp, self._OUTPUT_SECTION, "css_styles", "css_styles", required=True)
294
+ self._get_bool(cp, self._OUTPUT_SECTION, "make_export_dirs", "make_export_dirs")
295
+ self._get_bool(cp, self._OUTPUT_SECTION, "quote_all_text", "quote_all_text")
296
+ self._get_int(cp, self._OUTPUT_SECTION, "outfile_open_timeout", "outfile_open_timeout")
297
+ self._get_int(cp, self._OUTPUT_SECTION, "export_row_buffer", "export_row_buffer")
298
+ self._get_enum(
299
+ cp,
300
+ self._OUTPUT_SECTION,
301
+ "template_processor",
302
+ "template_processor",
303
+ ("jinja",),
304
+ )
305
+ self._get_int(cp, self._OUTPUT_SECTION, "zip_buffer_mb", "zip_buffer_mb")
306
+ # --- [interface] ---
307
+ self._get_bool(cp, self._INTERFACE_SECTION, "write_warnings", "write_warnings")
308
+ self._get_int(cp, self._INTERFACE_SECTION, "console_height", "gui_console_height", min_val=5)
309
+ self._get_int(cp, self._INTERFACE_SECTION, "console_width", "gui_console_width", min_val=20)
310
+ self._get_bool(cp, self._INTERFACE_SECTION, "console_wait_when_done", "gui_wait_on_exit")
311
+ self._get_bool(cp, self._INTERFACE_SECTION, "console_wait_when_error_halt", "gui_wait_on_error_halt")
312
+ # --- [config] ---
313
+ self._get_bool(cp, self._CONFIG_SECTION, "user_logfile", "user_logfile")
314
+ self._get_bool(cp, self._CONFIG_SECTION, "log_datavars", "log_datavars")
315
+ self._get_bool(cp, self._CONFIG_SECTION, "log_sql", "log_sql")
316
+ self._get_int(cp, self._CONFIG_SECTION, "max_log_size_mb", "max_log_size_mb")
317
+ self._get_bool(cp, self._CONFIG_SECTION, "allow_system_cmd", "allow_system_cmd")
318
+ # --- [email] ---
319
+ self._get_str(cp, self._EMAIL_SECTION, "host", "smtp_host")
320
+ self._get_int(cp, self._EMAIL_SECTION, "port", "smtp_port")
321
+ self._get_str(cp, self._EMAIL_SECTION, "username", "smtp_username")
322
+ self._get_str(cp, self._EMAIL_SECTION, "password", "smtp_password")
323
+ self._get_bool(cp, self._EMAIL_SECTION, "use_ssl", "smtp_ssl")
324
+ self._get_bool(cp, self._EMAIL_SECTION, "use_tls", "smtp_tls")
325
+ self._get_str(cp, self._EMAIL_SECTION, "message_css", "email_css")
326
+
327
+ # Register options whose loading lives inline in __init__ (because they
328
+ # need special-case validation or side effects) so they still appear in
329
+ # DEBUG WRITE CONFIG.
330
+ self._register_option(self._CONNECT_SECTION, "db_type", "db_type", "enum")
331
+ self._register_option(self._INTERFACE_SECTION, "write_prefix", "write_prefix", "str")
332
+ self._register_option(self._INTERFACE_SECTION, "write_suffix", "write_suffix", "str")
333
+ self._register_option(self._INTERFACE_SECTION, "gui_level", "gui_level", "int")
334
+ self._register_option(self._INTERFACE_SECTION, "gui_framework", "gui_framework", "enum")
335
+ self._register_option(self._CONFIG_SECTION, "dao_flush_delay_secs", "dao_flush_delay_secs", "float")
336
+ self._register_option(self._EMAIL_SECTION, "email_format", "email_format", "enum")
337
+
201
338
  def __init__(
202
339
  self,
203
340
  script_path: str,
@@ -309,6 +446,13 @@ class ConfigData:
309
446
  _MAX_CONFIG_CHAIN = 20 # Guard against circular config_file references.
310
447
  config_queue: deque[str] = deque(config_files)
311
448
  self.files_read: list = []
449
+ # Warm the schema registry by running the option reader against an
450
+ # empty ConfigParser. cp.has_option() is False for everything so no
451
+ # attribute values change, but every _get_* call registers its
452
+ # (section, ini_key, attr) tuple in ConfigData._schema. This means
453
+ # DEBUG WRITE CONFIG / DEBUG LOG CONFIG see the full option set even
454
+ # when no execsql.conf files exist on the system.
455
+ self._read_known_options(ConfigParser())
312
456
  while config_queue:
313
457
  configfile = config_queue.popleft()
314
458
  if len(self.files_read) >= _MAX_CONFIG_CHAIN:
@@ -323,82 +467,7 @@ class ConfigData:
323
467
  if t not in ("a", "d", "f", "k", "l", "m", "o", "p", "s"):
324
468
  raise ConfigError(f"Invalid database type: {t}")
325
469
  self.db_type = t
326
- self._get_str(cp, self._CONNECT_SECTION, "server", "server", required=True)
327
- self._get_str(cp, self._CONNECT_SECTION, "db", "db", required=True)
328
- self._get_int(cp, self._CONNECT_SECTION, "port", "port")
329
- self._get_str(cp, self._CONNECT_SECTION, "database", "db", required=True)
330
- self._get_str(cp, self._CONNECT_SECTION, "db_file", "db_file", required=True)
331
- self._get_str(cp, self._CONNECT_SECTION, "username", "username", required=True)
332
- self._get_str(cp, self._CONNECT_SECTION, "access_username", "access_username")
333
- self._get_bool(cp, self._CONNECT_SECTION, "password_prompt", "passwd_prompt")
334
- self._get_bool(cp, self._CONNECT_SECTION, "use_keyring", "use_keyring")
335
- self._get_bool(cp, self._CONNECT_SECTION, "new_db", "new_db")
336
- # --- [encoding] ---
337
- self._get_str(cp, self._ENCODING_SECTION, "database", "db_encoding")
338
- self._get_str(cp, self._ENCODING_SECTION, "script", "script_encoding", required=True)
339
- self._get_str(cp, self._ENCODING_SECTION, "import", "import_encoding", required=True)
340
- self._get_str(cp, self._ENCODING_SECTION, "output", "output_encoding", required=True)
341
- self._get_enum(
342
- cp,
343
- self._ENCODING_SECTION,
344
- "error_response",
345
- "enc_err_disposition",
346
- ("ignore", "replace", "xmlcharrefreplace", "backslashreplace"),
347
- )
348
- # --- [input] ---
349
- self._get_int(cp, self._INPUT_SECTION, "max_int", "max_int")
350
- self._get_bool(cp, self._INPUT_SECTION, "boolean_int", "boolean_int")
351
- self._get_bool(cp, self._INPUT_SECTION, "boolean_words", "boolean_words")
352
- self._get_bool(cp, self._INPUT_SECTION, "empty_strings", "empty_strings")
353
- self._get_bool(cp, self._INPUT_SECTION, "only_strings", "only_strings")
354
- self._get_bool(cp, self._INPUT_SECTION, "empty_rows", "empty_rows")
355
- self._get_bool(cp, self._INPUT_SECTION, "delete_empty_columns", "del_empty_cols")
356
- self._get_bool(cp, self._INPUT_SECTION, "create_column_headers", "create_col_hdrs")
357
- self._get_enum(
358
- cp,
359
- self._INPUT_SECTION,
360
- "trim_column_headers",
361
- "trim_col_hdrs",
362
- ("none", "both", "left", "right"),
363
- )
364
- self._get_bool(cp, self._INPUT_SECTION, "clean_column_headers", "clean_col_hdrs")
365
- self._get_enum(
366
- cp,
367
- self._INPUT_SECTION,
368
- "fold_column_headers",
369
- "fold_col_hdrs",
370
- ("no", "lower", "upper"),
371
- )
372
- self._get_bool(cp, self._INPUT_SECTION, "dedup_column_headers", "dedup_col_hdrs")
373
- self._get_bool(cp, self._INPUT_SECTION, "trim_strings", "trim_strings")
374
- self._get_bool(cp, self._INPUT_SECTION, "replace_newlines", "replace_newlines")
375
- self._get_int(cp, self._INPUT_SECTION, "import_row_buffer", "import_row_buffer")
376
- self._get_int(cp, self._INPUT_SECTION, "import_progress_interval", "import_progress_interval")
377
- self._get_bool(cp, self._INPUT_SECTION, "show_progress", "show_progress")
378
- self._get_bool(cp, self._INPUT_SECTION, "access_use_numeric", "access_use_numeric")
379
- self._get_bool(cp, self._INPUT_SECTION, "import_only_common_columns", "import_common_cols_only")
380
- self._get_bool(cp, self._INPUT_SECTION, "import_common_columns_only", "import_common_cols_only")
381
- self._get_int(cp, self._INPUT_SECTION, "scan_lines", "scan_lines")
382
- self._get_int(cp, self._INPUT_SECTION, "import_buffer", "import_buffer", multiply=1024)
383
- # --- [output] ---
384
- self._get_bool(cp, self._OUTPUT_SECTION, "log_write_messages", "tee_write_log")
385
- self._get_int(cp, self._OUTPUT_SECTION, "hdf5_text_len", "hdf5_text_len")
386
- self._get_str(cp, self._OUTPUT_SECTION, "css_file", "css_file", required=True)
387
- self._get_str(cp, self._OUTPUT_SECTION, "css_styles", "css_styles", required=True)
388
- self._get_bool(cp, self._OUTPUT_SECTION, "make_export_dirs", "make_export_dirs")
389
- self._get_bool(cp, self._OUTPUT_SECTION, "quote_all_text", "quote_all_text")
390
- self._get_int(cp, self._OUTPUT_SECTION, "outfile_open_timeout", "outfile_open_timeout")
391
- self._get_int(cp, self._OUTPUT_SECTION, "export_row_buffer", "export_row_buffer")
392
- self._get_enum(
393
- cp,
394
- self._OUTPUT_SECTION,
395
- "template_processor",
396
- "template_processor",
397
- ("jinja",),
398
- )
399
- self._get_int(cp, self._OUTPUT_SECTION, "zip_buffer_mb", "zip_buffer_mb")
400
- # --- [interface] ---
401
- self._get_bool(cp, self._INTERFACE_SECTION, "write_warnings", "write_warnings")
470
+ self._read_known_options(cp)
402
471
  # write_prefix / write_suffix have special "clear" → None handling
403
472
  if cp.has_option(self._INTERFACE_SECTION, "write_prefix"):
404
473
  try:
@@ -425,10 +494,6 @@ class ConfigData:
425
494
  if fw not in ("tkinter", "textual"):
426
495
  raise ConfigError("gui_framework must be 'tkinter' or 'textual'.")
427
496
  self.gui_framework = fw
428
- self._get_int(cp, self._INTERFACE_SECTION, "console_height", "gui_console_height", min_val=5)
429
- self._get_int(cp, self._INTERFACE_SECTION, "console_width", "gui_console_width", min_val=20)
430
- self._get_bool(cp, self._INTERFACE_SECTION, "console_wait_when_done", "gui_wait_on_exit")
431
- self._get_bool(cp, self._INTERFACE_SECTION, "console_wait_when_error_halt", "gui_wait_on_error_halt")
432
497
  # --- [config] ---
433
498
  # config_file / OS-specific config files retain special chaining logic
434
499
  if cp.has_option(self._CONFIG_SECTION, "config_file"):
@@ -464,7 +529,6 @@ class ConfigData:
464
529
  conffile = str(Path(conffile) / self.config_file_name)
465
530
  if Path(conffile).is_file():
466
531
  config_queue.appendleft(conffile)
467
- self._get_bool(cp, self._CONFIG_SECTION, "user_logfile", "user_logfile")
468
532
  # dao_flush_delay_secs has a specific error message — keep inline
469
533
  if cp.has_option(self._CONFIG_SECTION, "dao_flush_delay_secs"):
470
534
  self.dao_flush_delay_secs = cp.getfloat(self._CONFIG_SECTION, "dao_flush_delay_secs")
@@ -472,15 +536,7 @@ class ConfigData:
472
536
  raise ConfigError(
473
537
  f"Invalid DAO flush delay: {self.dao_flush_delay_secs}; must be >= 5.0.",
474
538
  )
475
- self._get_bool(cp, self._CONFIG_SECTION, "log_datavars", "log_datavars")
476
- self._get_bool(cp, self._CONFIG_SECTION, "log_sql", "log_sql")
477
- self._get_int(cp, self._CONFIG_SECTION, "max_log_size_mb", "max_log_size_mb")
478
- self._get_bool(cp, self._CONFIG_SECTION, "allow_system_cmd", "allow_system_cmd")
479
539
  # --- [email] ---
480
- self._get_str(cp, self._EMAIL_SECTION, "host", "smtp_host")
481
- self._get_int(cp, self._EMAIL_SECTION, "port", "smtp_port")
482
- self._get_str(cp, self._EMAIL_SECTION, "username", "smtp_username")
483
- self._get_str(cp, self._EMAIL_SECTION, "password", "smtp_password")
484
540
  # enc_password has special decryption logic — keep inline
485
541
  if cp.has_option(self._EMAIL_SECTION, "enc_password"):
486
542
  import warnings
@@ -492,15 +548,12 @@ class ConfigData:
492
548
  stacklevel=1,
493
549
  )
494
550
  self.smtp_password = Encrypt().decrypt(cp.get(self._EMAIL_SECTION, "enc_password"))
495
- self._get_bool(cp, self._EMAIL_SECTION, "use_ssl", "smtp_ssl")
496
- self._get_bool(cp, self._EMAIL_SECTION, "use_tls", "smtp_tls")
497
551
  # email_format has a specific error message — keep inline
498
552
  if cp.has_option(self._EMAIL_SECTION, "email_format"):
499
553
  fmt = cp.get(self._EMAIL_SECTION, "email_format").lower()
500
554
  if fmt not in ("plain", "html"):
501
555
  raise ConfigError(f"Invalid email format: {fmt}")
502
556
  self.email_format = fmt
503
- self._get_str(cp, self._EMAIL_SECTION, "message_css", "email_css")
504
557
  if cp.has_section(self._VARIABLES_SECTION) and variable_pool:
505
558
  varsect = cp.items(self._VARIABLES_SECTION)
506
559
  for sub, repl in varsect:
execsql/db/access.py CHANGED
@@ -109,13 +109,15 @@ class AccessDatabase(Database):
109
109
  try:
110
110
  self.conn = pyodbc.connect(connstr)
111
111
  except Exception:
112
- _state.exec_log.log_status_info(
113
- f"Could not connect via ODBC using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
114
- )
112
+ if _state.exec_log is not None:
113
+ _state.exec_log.log_status_info(
114
+ f"Could not connect via ODBC using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
115
+ )
115
116
  else:
116
- _state.exec_log.log_status_info(
117
- f"Connected via ODBC using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
118
- )
117
+ if _state.exec_log is not None:
118
+ _state.exec_log.log_status_info(
119
+ f"Connected via ODBC using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
120
+ )
119
121
  self.jet4 = jet4flag
120
122
  return True
121
123
  return False
@@ -162,9 +164,11 @@ class AccessDatabase(Database):
162
164
  else:
163
165
  self.dao_conn = daoEngine.OpenDatabase(self.db_name)
164
166
  except Exception:
165
- _state.exec_log.log_status_info(f"Could not connect via DAO using: {engine}")
167
+ if _state.exec_log is not None:
168
+ _state.exec_log.log_status_info(f"Could not connect via DAO using: {engine}")
166
169
  else:
167
- _state.exec_log.log_status_info(f"Connected via DAO using: {engine}")
170
+ if _state.exec_log is not None:
171
+ _state.exec_log.log_status_info(f"Connected via DAO using: {engine}")
168
172
  return True
169
173
  return False
170
174
 
@@ -307,23 +311,31 @@ class AccessDatabase(Database):
307
311
  return headers, iter(dict_row, None)
308
312
 
309
313
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
310
- """Return True if the named table exists in the Access database."""
314
+ """Return True if the named table exists in the Access database.
315
+
316
+ Uses ODBC's catalog function (``cursor.tables()``) instead of
317
+ querying ``MSysObjects`` directly. Access 2016+ refuses ad-hoc
318
+ queries on the system catalog with the error
319
+ ``Record(s) cannot be read; no read permission on 'MSysObjects'``;
320
+ the ODBC catalog path is permission-clean and matches the table
321
+ view that ``CREATE TABLE`` modifies, unlike DAO ``TableDefs``
322
+ which caches independently.
323
+ """
311
324
  self.dao_flush_check()
312
- sql = "select Name from MSysObjects where Name=? And Type In (1,4,6);"
313
- with self._cursor() as curs:
314
- try:
315
- curs.execute(sql, (table_name,))
316
- except ErrInfo:
317
- raise
318
- except Exception as e:
319
- raise ErrInfo(
320
- type="db",
321
- command_text=sql,
322
- exception_msg=exception_desc(),
323
- other_msg=f"Failure on test for existence of Access table {table_name}",
324
- ) from e
325
- rows = curs.fetchall()
326
- return len(rows) > 0
325
+ try:
326
+ with self._cursor() as curs:
327
+ rows = list(curs.tables(table=table_name, tableType="TABLE"))
328
+ # Access identifiers are case-insensitive; the catalog
329
+ # returns whatever case the table was created with.
330
+ lname = table_name.lower()
331
+ return any(r.table_name.lower() == lname for r in rows)
332
+ except Exception as e:
333
+ raise ErrInfo(
334
+ type="db",
335
+ command_text=f"ODBC SQLTables for {table_name}",
336
+ exception_msg=exception_desc(),
337
+ other_msg=f"Failure on test for existence of Access table {table_name}",
338
+ ) from e
327
339
 
328
340
  def column_exists(
329
341
  self,
@@ -352,23 +364,25 @@ class AccessDatabase(Database):
352
364
  return [d[0] for d in curs.description]
353
365
 
354
366
  def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
355
- """Return True if the named view or query exists in the Access database."""
367
+ """Return True if the named view or query exists in the Access database.
368
+
369
+ Uses DAO ``QueryDefs`` (Access's saved queries play the role of views)
370
+ rather than querying ``MSysObjects`` directly — see :meth:`table_exists`
371
+ for the permission rationale.
372
+ """
356
373
  self.dao_flush_check()
357
- sql = "select Name from MSysObjects where Name=? And Type = 5;"
358
- with self._cursor() as curs:
359
- try:
360
- curs.execute(sql, (view_name,))
361
- except ErrInfo:
362
- raise
363
- except Exception as e:
364
- raise ErrInfo(
365
- type="db",
366
- command_text=sql,
367
- exception_msg=exception_desc(),
368
- other_msg=f"Test for existence of Access view/query {view_name}",
369
- ) from e
370
- rows = curs.fetchall()
371
- return len(rows) > 0
374
+ try:
375
+ with self._cursor() as curs:
376
+ rows = list(curs.tables(table=view_name, tableType="VIEW"))
377
+ lname = view_name.lower()
378
+ return any(r.table_name.lower() == lname for r in rows)
379
+ except Exception as e:
380
+ raise ErrInfo(
381
+ type="db",
382
+ command_text=f"ODBC SQLTables for view {view_name}",
383
+ exception_msg=exception_desc(),
384
+ other_msg=f"Test for existence of Access view/query {view_name}",
385
+ ) from e
372
386
 
373
387
  def schema_exists(self, schema_name: str) -> bool:
374
388
  """Return False; Access does not support schemas."""