execsql2 2.1.2__py3-none-any.whl → 2.4.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 (94) hide show
  1. execsql/cli/__init__.py +436 -0
  2. execsql/cli/dsn.py +86 -0
  3. execsql/cli/help.py +140 -0
  4. execsql/{cli.py → cli/run.py} +14 -589
  5. execsql/config.py +65 -1
  6. execsql/db/access.py +27 -15
  7. execsql/db/base.py +328 -215
  8. execsql/db/dsn.py +10 -5
  9. execsql/db/duckdb.py +6 -2
  10. execsql/db/factory.py +21 -0
  11. execsql/db/firebird.py +27 -19
  12. execsql/db/mysql.py +12 -7
  13. execsql/db/oracle.py +15 -11
  14. execsql/db/postgres.py +31 -16
  15. execsql/db/sqlite.py +15 -11
  16. execsql/db/sqlserver.py +16 -5
  17. execsql/exceptions.py +25 -7
  18. execsql/exporters/base.py +12 -1
  19. execsql/exporters/delimited.py +80 -35
  20. execsql/exporters/duckdb.py +6 -2
  21. execsql/exporters/feather.py +10 -6
  22. execsql/exporters/html.py +89 -69
  23. execsql/exporters/json.py +52 -45
  24. execsql/exporters/latex.py +37 -27
  25. execsql/exporters/ods.py +32 -11
  26. execsql/exporters/parquet.py +5 -2
  27. execsql/exporters/pretty.py +16 -9
  28. execsql/exporters/raw.py +22 -16
  29. execsql/exporters/sqlite.py +6 -2
  30. execsql/exporters/templates.py +39 -21
  31. execsql/exporters/values.py +26 -20
  32. execsql/exporters/xls.py +30 -11
  33. execsql/exporters/xml.py +31 -13
  34. execsql/exporters/zip.py +15 -0
  35. execsql/importers/base.py +6 -4
  36. execsql/importers/csv.py +8 -6
  37. execsql/importers/feather.py +6 -4
  38. execsql/importers/ods.py +6 -4
  39. execsql/importers/xls.py +6 -4
  40. execsql/metacommands/__init__.py +208 -1548
  41. execsql/metacommands/conditions.py +101 -27
  42. execsql/metacommands/control.py +8 -4
  43. execsql/metacommands/data.py +6 -6
  44. execsql/metacommands/debug.py +6 -2
  45. execsql/metacommands/dispatch.py +2011 -0
  46. execsql/metacommands/io.py +67 -1310
  47. execsql/metacommands/io_export.py +442 -0
  48. execsql/metacommands/io_fileops.py +287 -0
  49. execsql/metacommands/io_import.py +398 -0
  50. execsql/metacommands/io_write.py +248 -0
  51. execsql/metacommands/prompt.py +22 -66
  52. execsql/metacommands/system.py +7 -2
  53. execsql/models.py +7 -0
  54. execsql/parser.py +10 -0
  55. execsql/py.typed +0 -0
  56. execsql/script/__init__.py +95 -0
  57. execsql/script/control.py +162 -0
  58. execsql/{script.py → script/engine.py} +184 -402
  59. execsql/script/variables.py +281 -0
  60. execsql/types.py +49 -20
  61. execsql/utils/auth.py +2 -0
  62. execsql/utils/crypto.py +4 -6
  63. execsql/utils/datetime.py +1 -0
  64. execsql/utils/errors.py +11 -0
  65. execsql/utils/fileio.py +33 -8
  66. execsql/utils/gui.py +46 -0
  67. execsql/utils/mail.py +7 -17
  68. execsql/utils/numeric.py +2 -0
  69. execsql/utils/regex.py +9 -0
  70. execsql/utils/strings.py +16 -0
  71. execsql/utils/timer.py +2 -0
  72. execsql2-2.4.0.data/data/execsql2_extras/README.md +65 -0
  73. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/execsql.conf +1 -1
  74. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/METADATA +13 -6
  75. execsql2-2.4.0.dist-info/RECORD +108 -0
  76. execsql2-2.1.2.data/data/execsql2_extras/READ_ME.rst +0 -127
  77. execsql2-2.1.2.dist-info/RECORD +0 -96
  78. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  79. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  80. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  81. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  82. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  83. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  84. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  85. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  86. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  87. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/script_template.sql +0 -0
  88. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  89. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  90. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  91. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/WHEEL +0 -0
  92. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/entry_points.txt +0 -0
  93. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/LICENSE.txt +0 -0
  94. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ """Variable containers for execsql script execution.
4
+
5
+ Classes:
6
+ - :class:`CounterVars` — named auto-incrementing integer counters.
7
+ - :class:`SubVarSet` — global ``!!$VAR!!`` substitution-variable store.
8
+ - :class:`LocalSubVarSet` — per-script ``~``-prefixed local variable overlay.
9
+ - :class:`ScriptArgSubVarSet` — per-script ``#``-prefixed argument overlay.
10
+ """
11
+
12
+ import os
13
+ import re
14
+ from typing import Any
15
+
16
+ from execsql.exceptions import ErrInfo
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # CounterVars
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ class CounterVars:
25
+ """Named auto-incrementing integer counters referenced as ``!!$COUNTER_N!!``."""
26
+
27
+ # A dictionary of dynamically created named counter variables.
28
+ _COUNTER_RX = re.compile(r"!!\$(COUNTER_\d+)!!", re.I)
29
+
30
+ def __init__(self) -> None:
31
+ self.counters: dict[str, int] = {}
32
+
33
+ def _ctrid(self, ctr_no: int) -> str:
34
+ return f"counter_{ctr_no}"
35
+
36
+ def set_counter(self, ctr_no: int, ctr_val: int) -> None:
37
+ self.counters[self._ctrid(ctr_no)] = ctr_val
38
+
39
+ def remove_counter(self, ctr_no: int) -> None:
40
+ ctr_id = self._ctrid(ctr_no)
41
+ if ctr_id in self.counters:
42
+ del self.counters[ctr_id]
43
+
44
+ def remove_all_counters(self) -> None:
45
+ self.counters = {}
46
+
47
+ def substitute(self, command_str: str) -> tuple:
48
+ # Substitutes any counter variable references with the counter value and
49
+ # returns the modified command string and a flag indicating replacements.
50
+ m = self._COUNTER_RX.search(command_str, re.I)
51
+ if m:
52
+ ctr_id = m.group(1).lower()
53
+ if ctr_id not in self.counters:
54
+ self.counters[ctr_id] = 0
55
+ new_count = self.counters[ctr_id] + 1
56
+ self.counters[ctr_id] = new_count
57
+ return command_str.replace("!!$" + m.group(1) + "!!", str(new_count)), True
58
+ return command_str, False
59
+
60
+ def substitute_all(self, any_text: str) -> tuple:
61
+ subbed = True
62
+ any_subbed = False
63
+ while subbed:
64
+ any_text, subbed = self.substitute(any_text)
65
+ if subbed:
66
+ any_subbed = True
67
+ return any_text, any_subbed
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # SubVarSet / LocalSubVarSet / ScriptArgSubVarSet
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ class SubVarSet:
76
+ """Pool of ``!!$VAR!!``-style substitution variables.
77
+
78
+ Variable names are stored in lowercase. Supports ``$``, ``&``, and ``@``
79
+ prefixes by default. Use :meth:`add_substitution` / :meth:`remove_substitution`
80
+ to manage entries, and :meth:`substitute_all` to expand a string.
81
+ """
82
+
83
+ # A pool of substitution variables. Each variable consists of a name and
84
+ # a (string) value. All variable names are stored as lowercase text.
85
+ # Internally uses a dict for O(1) lookups; the ``substitutions`` property
86
+ # exposes the data as a list of ``(name, value)`` tuples for backward
87
+ # compatibility with external code.
88
+ def __init__(self) -> None:
89
+ self._subs_dict: dict[str, Any] = {}
90
+ self._compiled_patterns: dict[str, tuple] = {}
91
+ self.prefix_list: list[str] = ["$", "&", "@"]
92
+ # Don't construct/compile on init because deepcopy() can't handle compiled regexes.
93
+ self.var_rx = None
94
+
95
+ @property
96
+ def substitutions(self) -> list[tuple]:
97
+ """Backward-compatible view of substitutions as a list of (name, value) tuples."""
98
+ return list(self._subs_dict.items())
99
+
100
+ @substitutions.setter
101
+ def substitutions(self, value: Any) -> None:
102
+ """Accept a list of (name, value) tuples or a dict and populate the internal dict."""
103
+ if isinstance(value, dict):
104
+ self._subs_dict = dict(value)
105
+ else:
106
+ self._subs_dict = dict(value)
107
+ self._rebuild_all_patterns()
108
+
109
+ def _compile_patterns_for(self, varname: str) -> tuple:
110
+ """Compile and return the three regex patterns (plain, single-quoted, double-quoted) for *varname*."""
111
+ match_escaped = "\\" + varname if varname[0] == "$" else varname
112
+ pat = re.compile(f"!!{match_escaped}!!", re.I)
113
+ patq = re.compile(f"!'!{match_escaped}!'!", re.I)
114
+ patdq = re.compile(f'!"!{match_escaped}!"!', re.I)
115
+ return (pat, patq, patdq)
116
+
117
+ def _rebuild_all_patterns(self) -> None:
118
+ """Rebuild compiled patterns for every variable currently stored."""
119
+ self._compiled_patterns = {}
120
+ for varname in self._subs_dict:
121
+ self._compiled_patterns[varname] = self._compile_patterns_for(varname)
122
+
123
+ def compile_var_rx(self) -> None:
124
+ """Compile the variable-name validation regex from the current prefix list."""
125
+ self.var_rx_str = r"^[" + "".join(self.prefix_list) + r"]?\w+$"
126
+ self.var_rx = re.compile(self.var_rx_str, re.I)
127
+
128
+ def var_name_ok(self, varname: str) -> bool:
129
+ if self.var_rx is None:
130
+ self.compile_var_rx()
131
+ return self.var_rx.match(varname) is not None
132
+
133
+ def check_var_name(self, varname: str) -> None:
134
+ if not self.var_name_ok(varname.lower()):
135
+ raise ErrInfo("error", other_msg=f"Invalid variable name ({varname}) in this context.")
136
+
137
+ def remove_substitution(self, template_str: str) -> None:
138
+ """Remove the variable named *template_str* from the substitution pool."""
139
+ self.check_var_name(template_str)
140
+ old_sub = template_str.lower()
141
+ self._subs_dict.pop(old_sub, None)
142
+ self._compiled_patterns.pop(old_sub, None)
143
+
144
+ def add_substitution(self, varname: str, repl_str: Any) -> None:
145
+ """Add or overwrite a substitution variable, compiling its match patterns."""
146
+ self.check_var_name(varname)
147
+ varname = varname.lower()
148
+ self._subs_dict[varname] = repl_str
149
+ self._compiled_patterns[varname] = self._compile_patterns_for(varname)
150
+
151
+ def append_substitution(self, varname: str, repl_str: str) -> None:
152
+ self.check_var_name(varname)
153
+ varname = varname.lower()
154
+ if varname in self._subs_dict:
155
+ self.add_substitution(varname, f"{self._subs_dict[varname]}\n{repl_str}")
156
+ else:
157
+ self.add_substitution(varname, repl_str)
158
+
159
+ def varvalue(self, varname: str) -> str | None:
160
+ """Return the value of *varname*, or ``None`` if it is not defined."""
161
+ self.check_var_name(varname)
162
+ return self._subs_dict.get(varname.lower())
163
+
164
+ def increment_by(self, varname: str, numeric_increment: Any) -> None:
165
+ self.check_var_name(varname)
166
+ varvalue = self.varvalue(varname)
167
+ if varvalue is None:
168
+ varvalue = "0"
169
+ self.add_substitution(varname, varvalue)
170
+ # Import as_numeric lazily to avoid circular dependency
171
+ from execsql.utils.numeric import as_numeric
172
+
173
+ numvalue = as_numeric(varvalue)
174
+ numinc = as_numeric(numeric_increment)
175
+ if numvalue is None or numinc is None:
176
+ newval = f"{varvalue}+{numeric_increment}"
177
+ else:
178
+ newval = str(numvalue + numinc)
179
+ self.add_substitution(varname, newval)
180
+
181
+ def sub_exists(self, template_str: str) -> bool:
182
+ """Return True if the variable named *template_str* is defined."""
183
+ self.check_var_name(template_str)
184
+ return template_str.lower() in self._subs_dict
185
+
186
+ def merge(self, other_subvars: SubVarSet | None) -> SubVarSet:
187
+ """Return a new SubVarSet with this object's variables merged with other_subvars."""
188
+ if other_subvars is not None:
189
+ newsubs = SubVarSet()
190
+ newsubs._subs_dict = dict(self._subs_dict)
191
+ newsubs._compiled_patterns = dict(self._compiled_patterns)
192
+ newsubs.prefix_list = list(set(self.prefix_list + other_subvars.prefix_list))
193
+ newsubs.compile_var_rx()
194
+ for varname, value in other_subvars._subs_dict.items():
195
+ newsubs.add_substitution(varname, value)
196
+ return newsubs
197
+ return self
198
+
199
+ # Combined regex matching any variable token in one pass.
200
+ # Group 1: quote marker — empty for plain !!var!!, ' for !'!var!'!, " for !"!var!"!
201
+ # Group 2: variable name (with optional prefix character)
202
+ _TOKEN_RX = re.compile(
203
+ r"!(?P<q>['\"]?)!(?P<varname>[$&@~#]?\w+)!(?P=q)!",
204
+ re.I,
205
+ )
206
+
207
+ def substitute(self, command_str: str) -> tuple:
208
+ """Replace the first matching variable token in *command_str*.
209
+
210
+ Uses a single combined regex to find any ``!!var!!``, ``!'!var!'!``, or
211
+ ``!"!var!"!`` token in one pass, then looks up the variable name in the
212
+ dict. This is O(1) per call instead of O(V) where V is the number of
213
+ defined variables.
214
+
215
+ Returns ``(modified_string, True)`` if a substitution was made, or
216
+ ``(original_string, False)`` if no variable pattern matched.
217
+ """
218
+ if not isinstance(command_str, str):
219
+ return command_str, False
220
+ m = self._TOKEN_RX.search(command_str)
221
+ while m:
222
+ varname = m.group("varname").lower()
223
+ if varname in self._subs_dict:
224
+ sub = self._subs_dict[varname]
225
+ if sub is None:
226
+ sub = ""
227
+ sub = str(sub)
228
+ if os.name != "posix":
229
+ sub = sub.replace("\\", "\\\\")
230
+ quote = m.group("q")
231
+ if quote == "'":
232
+ sub = sub.replace("'", "''")
233
+ elif quote == '"':
234
+ sub = '"' + sub + '"'
235
+ return command_str[: m.start()] + sub + command_str[m.end() :], True
236
+ # Token found but variable not defined — skip it and keep searching.
237
+ m = self._TOKEN_RX.search(command_str, m.end())
238
+ return command_str, False
239
+
240
+ def substitute_all(self, any_text: str) -> tuple:
241
+ """Repeatedly apply :meth:`substitute` until no more substitutions remain.
242
+
243
+ Returns ``(fully_expanded_string, any_substitution_made)``.
244
+ """
245
+ subbed = True
246
+ any_subbed = False
247
+ while subbed:
248
+ any_text, subbed = self.substitute(any_text)
249
+ if subbed:
250
+ any_subbed = True
251
+ return any_text, any_subbed
252
+
253
+
254
+ class LocalSubVarSet(SubVarSet):
255
+ """Substitution-variable pool restricted to ``~``-prefixed local variables."""
256
+
257
+ # A pool of local substitution variables.
258
+ # Only '~' is allowed as a prefix and MUST be present.
259
+ def __init__(self) -> None:
260
+ SubVarSet.__init__(self)
261
+ self.prefix_list = ["~"]
262
+
263
+ def compile_var_rx(self) -> None:
264
+ # Prefix is required, not optional.
265
+ self.var_rx_str = r"^[" + "".join(self.prefix_list) + r"]\w+$"
266
+ self.var_rx = re.compile(self.var_rx_str, re.I)
267
+
268
+
269
+ class ScriptArgSubVarSet(SubVarSet):
270
+ """Substitution-variable pool restricted to ``#``-prefixed script arguments."""
271
+
272
+ # A pool of script argument names.
273
+ # Only '#' is allowed as a prefix and MUST be present.
274
+ def __init__(self) -> None:
275
+ SubVarSet.__init__(self)
276
+ self.prefix_list = ["#"]
277
+
278
+ def compile_var_rx(self) -> None:
279
+ # Prefix is required, not optional.
280
+ self.var_rx_str = r"^[" + "".join(self.prefix_list) + r"]\w+$"
281
+ self.var_rx = re.compile(self.var_rx_str, re.I)
execsql/types.py CHANGED
@@ -39,6 +39,35 @@ from decimal import Decimal
39
39
  from execsql.exceptions import DataTypeError, DbTypeError
40
40
  from execsql.utils.numeric import leading_zero_num
41
41
 
42
+ __all__ = [
43
+ "DataType",
44
+ "Tz",
45
+ "DT_TimestampTZ",
46
+ "DT_Timestamp",
47
+ "DT_Date",
48
+ "DT_Time",
49
+ "DT_Time_Oracle",
50
+ "DT_Boolean",
51
+ "DT_Integer",
52
+ "DT_Long",
53
+ "DT_Float",
54
+ "DT_Decimal",
55
+ "DT_Character",
56
+ "DT_Varchar",
57
+ "DT_Text",
58
+ "DT_Binary",
59
+ "DbType",
60
+ "dbt_postgres",
61
+ "dbt_sqlite",
62
+ "dbt_duckdb",
63
+ "dbt_sqlserver",
64
+ "dbt_access",
65
+ "dbt_dsn",
66
+ "dbt_mysql",
67
+ "dbt_firebird",
68
+ "dbt_oracle",
69
+ ]
70
+
42
71
 
43
72
  class DataType:
44
73
  data_type_name = None
@@ -88,8 +117,8 @@ class DataType:
88
117
  return data
89
118
  try:
90
119
  i = self.data_type(data)
91
- except Exception:
92
- raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
120
+ except Exception as e:
121
+ raise DataTypeError(self.data_type_name, self._CONV_ERR % data) from e
93
122
  return i
94
123
 
95
124
 
@@ -354,8 +383,8 @@ class DT_Integer(DataType):
354
383
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
355
384
  try:
356
385
  i = int(data)
357
- except Exception:
358
- raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
386
+ except Exception as e:
387
+ raise DataTypeError(self.data_type_name, self._CONV_ERR % data) from e
359
388
  if leading_zero_num(data):
360
389
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
361
390
  return i
@@ -390,8 +419,8 @@ class DT_Long(DataType):
390
419
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
391
420
  try:
392
421
  i = int(data)
393
- except Exception:
394
- raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
422
+ except Exception as e:
423
+ raise DataTypeError(self.data_type_name, self._CONV_ERR % data) from e
395
424
  return i
396
425
 
397
426
 
@@ -428,8 +457,8 @@ class DT_Float(DataType):
428
457
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
429
458
  try:
430
459
  i = float(data)
431
- except Exception:
432
- raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
460
+ except Exception as e:
461
+ raise DataTypeError(self.data_type_name, self._CONV_ERR % data) from e
433
462
  return i
434
463
 
435
464
 
@@ -464,8 +493,8 @@ class DT_Decimal(DataType):
464
493
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
465
494
  try:
466
495
  dec = Decimal(data)
467
- except Exception:
468
- raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
496
+ except Exception as e:
497
+ raise DataTypeError(self.data_type_name, self._CONV_ERR % data) from e
469
498
  self.set_scale_prec(dec)
470
499
  return dec
471
500
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
@@ -493,8 +522,8 @@ class DT_Character(DataType):
493
522
  if not isinstance(data, str):
494
523
  try:
495
524
  data = data_type(data)
496
- except ValueError:
497
- raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
525
+ except ValueError as e:
526
+ raise DataTypeError(self.data_type_name, self._CONV_ERR % data) from e
498
527
  if len(data) > 255:
499
528
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
500
529
  return data
@@ -521,8 +550,8 @@ class DT_Varchar(DataType):
521
550
  if isinstance(data, str):
522
551
  try:
523
552
  data = data_type(data)
524
- except ValueError:
525
- raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
553
+ except ValueError as e:
554
+ raise DataTypeError(self.data_type_name, self._CONV_ERR % data) from e
526
555
  if len(data) > 255:
527
556
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
528
557
  return data
@@ -546,8 +575,8 @@ class DT_Text(DataType):
546
575
  if not isinstance(data, str):
547
576
  try:
548
577
  data = data_type(data)
549
- except ValueError:
550
- raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
578
+ except ValueError as e:
579
+ raise DataTypeError(self.data_type_name, self._CONV_ERR % data) from e
551
580
  return data
552
581
 
553
582
 
@@ -605,12 +634,12 @@ class DbType:
605
634
  # A convenience function to simplify access to data type names.
606
635
  try:
607
636
  return self.dialect[data_type][0]
608
- except Exception:
637
+ except Exception as e:
609
638
  raise DbTypeError(
610
639
  self.dbms_id,
611
640
  data_type,
612
641
  f"{self.dbms_id} DBMS type has no specification for data type {data_type.data_type_name}",
613
- )
642
+ ) from e
614
643
 
615
644
  def quoted(self, dbms_object: str) -> str:
616
645
  if re.search(r"\W", dbms_object):
@@ -638,12 +667,12 @@ class DbType:
638
667
  data_type = self.spec_type(data_type)
639
668
  try:
640
669
  dts = self.dialect[data_type]
641
- except Exception:
670
+ except Exception as e:
642
671
  raise DbTypeError(
643
672
  self.dbms_id,
644
673
  data_type,
645
674
  f"{self.dbms_id} DBMS type has no specification for data type {data_type.data_type_name}",
646
- )
675
+ ) from e
647
676
  if max_len and max_len > 0 and dts[1]:
648
677
  spec = f"{self.quoted(column_name)} {dts[0]}({max_len})"
649
678
  elif data_type.precspec and precision and scale:
execsql/utils/auth.py CHANGED
@@ -29,6 +29,8 @@ import getpass
29
29
 
30
30
  import execsql.state as _state
31
31
 
32
+ __all__ = ["get_password", "clear_stored_password", "password_from_keyring"]
33
+
32
34
  # Tracks whether the most recent get_password() call returned a keyring-stored value.
33
35
  _last_from_keyring: bool = False
34
36
 
execsql/utils/crypto.py CHANGED
@@ -21,9 +21,13 @@ that use XOR against a fixed key table followed by base64 encoding.
21
21
  The monolith (line 2301) called this "SIMPLE ENCRYPTION".
22
22
  """
23
23
 
24
+ import base64
25
+ import itertools
24
26
  import random
25
27
  import uuid
26
28
 
29
+ __all__ = ["Encrypt"]
30
+
27
31
 
28
32
  class Encrypt:
29
33
  """Reversible XOR-based obfuscation for configuration file passwords.
@@ -55,12 +59,6 @@ class Encrypt:
55
59
  def __repr__(self) -> str:
56
60
  return "Encrypt()"
57
61
 
58
- def __init__(self) -> None:
59
- global itertools
60
- global base64
61
- import itertools
62
- import base64
63
-
64
62
  def xor(self, text: str, enckey: str) -> str:
65
63
  return "".join(chr(ord(t) ^ ord(k)) for t, k in zip(text, itertools.cycle(enckey)))
66
64
 
execsql/utils/datetime.py CHANGED
@@ -16,6 +16,7 @@ import datetime
16
16
  import re
17
17
  from typing import Any
18
18
 
19
+ __all__ = ["parse_datetime", "parse_datetimetz"]
19
20
 
20
21
  dt_fmts = collections.deque(
21
22
  (
execsql/utils/errors.py CHANGED
@@ -25,6 +25,17 @@ from typing import Any
25
25
  import execsql.state as _state
26
26
  from execsql.exceptions import ErrInfo
27
27
 
28
+ __all__ = [
29
+ "exception_info",
30
+ "exception_desc",
31
+ "exit_now",
32
+ "fatal_error",
33
+ "write_warning",
34
+ "file_size_date",
35
+ "chainfuncs",
36
+ "as_none",
37
+ ]
38
+
28
39
 
29
40
  def exception_info() -> tuple:
30
41
  # Returns the exception type, value, source file name, source line number, and source line text.
execsql/utils/fileio.py CHANGED
@@ -37,6 +37,24 @@ from typing import Any
37
37
 
38
38
  from execsql.exceptions import ErrInfo
39
39
 
40
+ __all__ = [
41
+ "make_export_dirs",
42
+ "check_dir",
43
+ "FileWriter",
44
+ "EncodedFile",
45
+ "Logger",
46
+ "TempFileMgr",
47
+ "list_encodings",
48
+ "filewriter_filestatus",
49
+ "filewriter_write",
50
+ "filewriter_open_as_new",
51
+ "filewriter_close",
52
+ "filewriter_close_all_after_write",
53
+ "filewriter_closeall",
54
+ "filewriter_shutdown",
55
+ "filewriter_end",
56
+ ]
57
+
40
58
 
41
59
  def make_export_dirs(outfile: str) -> None:
42
60
  if outfile.lower() != "stdout":
@@ -49,8 +67,8 @@ def make_export_dirs(outfile: str) -> None:
49
67
  except OSError as e:
50
68
  if e.errno != errno.EEXIST:
51
69
  raise ErrInfo("exception", exception_msg=emsg) from e
52
- except Exception:
53
- raise ErrInfo("exception", exception_msg=emsg)
70
+ except Exception as e:
71
+ raise ErrInfo("exception", exception_msg=emsg) from e
54
72
 
55
73
 
56
74
  def check_dir(filename: str) -> None:
@@ -265,14 +283,11 @@ class FileWriter(multiprocessing.Process):
265
283
  # is a command and the second is a tuple of arguments for the function indicated
266
284
  # by that command.
267
285
  while self.active:
268
- command = None
269
- argtuple = None
270
286
  try:
271
- command, argtuple = self.input_queue.get_nowait()
287
+ command, argtuple = self.input_queue.get(timeout=0.1)
272
288
  except queue.Empty:
273
- pass
274
- if command is not None:
275
- self.execvec[command](*argtuple)
289
+ continue
290
+ self.execvec[command](*argtuple)
276
291
 
277
292
 
278
293
  # Subprocess objects for asynchronous writing to text files.
@@ -539,6 +554,16 @@ class Logger:
539
554
  wmsg = f"status\t{self.run_id}\t{self.seq_no}\t{self._ts()}\twarning\t{msg or ''}\n"
540
555
  self.writelog(wmsg)
541
556
 
557
+ def log_sql_query(self, sql: str, db_name: str, line_no: int | None = None) -> None:
558
+ """Log an executed SQL statement for audit purposes."""
559
+ cleaned = sql.replace("\n", " ").replace("\t", " ")
560
+ if len(cleaned) > 2000:
561
+ cleaned = cleaned[:2000] + "..."
562
+ self.seq_no += 1
563
+ lno = line_no if line_no is not None else 0
564
+ wmsg = f"sql\t{self.run_id}\t{self.seq_no}\t{self._ts()}\t{db_name}\t{lno}\t{cleaned}\n"
565
+ self.writelog(wmsg)
566
+
542
567
  def log_user_msg(self, msg: str | None) -> None:
543
568
  msg = None if not msg else msg.replace("\n", "")
544
569
  if msg != "":
execsql/utils/gui.py CHANGED
@@ -21,6 +21,52 @@ from __future__ import annotations
21
21
  import sys
22
22
  from typing import Any
23
23
 
24
+ __all__ = [
25
+ # Constants
26
+ "GUI_HALT",
27
+ "GUI_MSG",
28
+ "GUI_PAUSE",
29
+ "GUI_DISPLAY",
30
+ "GUI_ENTRY",
31
+ "GUI_COMPARE",
32
+ "GUI_SELECTROWS",
33
+ "GUI_SELECTSUB",
34
+ "GUI_ACTION",
35
+ "GUI_MAP",
36
+ "GUI_OPENFILE",
37
+ "GUI_SAVEFILE",
38
+ "GUI_DIRECTORY",
39
+ "QUERY_CONSOLE",
40
+ "GUI_CREDENTIALS",
41
+ "GUI_CONNECT",
42
+ # Data-carrier classes
43
+ "GuiSpec",
44
+ "ConsoleUIError",
45
+ "ActionSpec",
46
+ "EntrySpec",
47
+ # Console lifecycle
48
+ "gui_console_isrunning",
49
+ "enable_gui",
50
+ "gui_console_on",
51
+ "gui_console_off",
52
+ "gui_console_hide",
53
+ "gui_console_show",
54
+ "gui_console_progress",
55
+ "gui_console_save",
56
+ "gui_console_status",
57
+ "gui_console_wait_user",
58
+ "gui_console_width",
59
+ "gui_console_height",
60
+ # Database connection GUI
61
+ "gui_connect",
62
+ "gui_credentials",
63
+ # Interactive prompts
64
+ "get_yn",
65
+ "get_yn_win",
66
+ "pause",
67
+ "pause_win",
68
+ ]
69
+
24
70
  # ---------------------------------------------------------------------------
25
71
  # GUI command constants — used to identify request types in the GUI queue.
26
72
  # ---------------------------------------------------------------------------
execsql/utils/mail.py CHANGED
@@ -11,11 +11,18 @@ metacommand and the halt/cancel email-notification hooks.
11
11
  """
12
12
 
13
13
  import re
14
+ import smtplib
15
+ from email import encoders
16
+ from email.mime.base import MIMEBase
17
+ from email.mime.multipart import MIMEMultipart
18
+ from email.mime.text import MIMEText
14
19
  from pathlib import Path
15
20
 
16
21
  import execsql.state as _state
17
22
  from execsql.exceptions import ErrInfo
18
23
 
24
+ __all__ = ["MailSpec", "Mailer"]
25
+
19
26
 
20
27
  class Mailer:
21
28
  def __repr__(self) -> str:
@@ -26,17 +33,6 @@ class Mailer:
26
33
  self.smtpconn.quit()
27
34
 
28
35
  def __init__(self) -> None:
29
- global smtplib
30
- global MIMEMultipart
31
- global MIMEText
32
- global MIMEBase
33
- global encoders
34
- import smtplib
35
- from email.mime.multipart import MIMEMultipart
36
- from email.mime.text import MIMEText
37
- from email.mime.base import MIMEBase
38
- from email import encoders
39
-
40
36
  conf = _state.conf
41
37
  if conf.smtp_host is None:
42
38
  raise ErrInfo(type="error", other_msg="Can't send email; the email host is not configured.")
@@ -69,12 +65,6 @@ class Mailer:
69
65
  content_filename: str | None = None,
70
66
  attach_filename: str | None = None,
71
67
  ) -> None:
72
- global smtplib
73
- global MIMEMultipart
74
- global MIMEText
75
- global MIMEBase
76
- global encoders
77
-
78
68
  conf = _state.conf
79
69
  if conf.email_format == "html":
80
70
  msg = MIMEMultipart("alternative")
execsql/utils/numeric.py CHANGED
@@ -15,6 +15,8 @@ Provides:
15
15
  import re
16
16
  from typing import Any
17
17
 
18
+ __all__ = ["as_numeric", "leading_zero_num"]
19
+
18
20
 
19
21
  def leading_zero_num(dataval: Any) -> bool:
20
22
  # Returns True if the data value is potentially a number but has a leading zero