execsql2 2.4.4__py3-none-any.whl → 2.5.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 (62) hide show
  1. execsql/cli/__init__.py +14 -0
  2. execsql/cli/dsn.py +2 -0
  3. execsql/cli/help.py +2 -0
  4. execsql/cli/run.py +4 -2
  5. execsql/constants.py +11 -0
  6. execsql/db/access.py +20 -0
  7. execsql/db/base.py +4 -0
  8. execsql/db/dsn.py +11 -8
  9. execsql/db/duckdb.py +12 -8
  10. execsql/db/firebird.py +17 -8
  11. execsql/db/mysql.py +13 -8
  12. execsql/db/oracle.py +22 -8
  13. execsql/db/postgres.py +21 -9
  14. execsql/db/sqlite.py +18 -8
  15. execsql/db/sqlserver.py +14 -8
  16. execsql/exporters/__init__.py +6 -1
  17. execsql/exporters/base.py +2 -0
  18. execsql/exporters/delimited.py +10 -0
  19. execsql/exporters/protocol.py +128 -0
  20. execsql/exporters/xls.py +8 -0
  21. execsql/format.py +3 -1
  22. execsql/gui/__init__.py +2 -0
  23. execsql/gui/base.py +2 -0
  24. execsql/gui/console.py +2 -0
  25. execsql/gui/desktop.py +1 -0
  26. execsql/gui/tui.py +2 -0
  27. execsql/importers/base.py +1 -0
  28. execsql/importers/csv.py +2 -0
  29. execsql/importers/feather.py +2 -0
  30. execsql/importers/ods.py +1 -0
  31. execsql/importers/xls.py +1 -0
  32. execsql/metacommands/__init__.py +206 -0
  33. execsql/metacommands/dispatch.py +93 -2
  34. execsql/metacommands/io.py +41 -0
  35. execsql/models.py +17 -0
  36. execsql/parser.py +41 -0
  37. execsql/script/control.py +2 -0
  38. execsql/script/engine.py +19 -0
  39. execsql/script/variables.py +9 -5
  40. execsql/state.py +52 -0
  41. execsql/types.py +46 -0
  42. {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/METADATA +31 -4
  43. {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/RECORD +62 -61
  44. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/README.md +0 -0
  45. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  46. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  47. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/execsql.conf +0 -0
  48. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  49. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  50. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  51. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  52. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  53. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  54. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  55. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/script_template.sql +0 -0
  56. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  57. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  58. {execsql2-2.4.4.data → execsql2-2.5.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  59. {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/WHEEL +0 -0
  60. {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/entry_points.txt +0 -0
  61. {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/licenses/LICENSE.txt +0 -0
  62. {execsql2-2.4.4.dist-info → execsql2-2.5.0.dist-info}/licenses/NOTICE +0 -0
execsql/parser.py CHANGED
@@ -39,20 +39,26 @@ __all__ = [
39
39
 
40
40
 
41
41
  class SourceString:
42
+ """Cursor-based scanner over a raw string for token matching."""
43
+
42
44
  def __init__(self, source_string: str) -> None:
45
+ """Initialise the scanner at position zero of the given string."""
43
46
  self.str = source_string
44
47
  self.currpos = 0
45
48
 
46
49
  def eoi(self) -> bool:
50
+ """Return True if the entire source string has been consumed."""
47
51
  # Returns True or False indicating whether or not there is any of
48
52
  # the source string left to be consumed.
49
53
  return self.currpos >= len(self.str)
50
54
 
51
55
  def eat_whitespace(self) -> None:
56
+ """Advance the cursor past any whitespace at the current position."""
52
57
  while not self.eoi() and self.str[self.currpos] in [" ", "\t", "\n"]:
53
58
  self.currpos += 1
54
59
 
55
60
  def match_str(self, str: str) -> str | None:
61
+ """Match a string case-insensitively at the current position and advance."""
56
62
  # Tries to match the 'str' argument at the current position in the
57
63
  # source string. Matching is case-insensitive. If matching succeeds,
58
64
  # the matched string is returned and the internal pointer is incremented.
@@ -70,6 +76,7 @@ class SourceString:
70
76
  return None
71
77
 
72
78
  def match_regex(self, regex: Any) -> dict | None:
79
+ """Match a compiled regex at the current position and return named groups."""
73
80
  # Tries to match the 'regex' argument at the current position in the
74
81
  # source string. If it succeeds, a dictionary of all of the named
75
82
  # groups is returned, and the internal pointer is incremented.
@@ -85,6 +92,7 @@ class SourceString:
85
92
  return None
86
93
 
87
94
  def match_metacommand(self, commandlist: Any) -> tuple | None:
95
+ """Match a metacommand from the command list at the current position."""
88
96
  # Tries to match text at the current position to any metacommand
89
97
  # in the specified commandlist.
90
98
  # If it succeeds, the return value is a tuple of the MetaCommand object
@@ -102,6 +110,7 @@ class SourceString:
102
110
  return None
103
111
 
104
112
  def remainder(self) -> str:
113
+ """Return the unconsumed portion of the source string."""
105
114
  return self.str[self.currpos :]
106
115
 
107
116
 
@@ -109,10 +118,14 @@ class SourceString:
109
118
 
110
119
 
111
120
  class CondTokens:
121
+ """Integer constants for conditional-expression AST node types."""
122
+
112
123
  AND, OR, NOT, CONDITIONAL = range(4)
113
124
 
114
125
 
115
126
  class NumTokens:
127
+ """Integer constants for numeric-expression AST node types."""
128
+
116
129
  MUL, DIV, ADD, SUB, NUMBER = range(5)
117
130
 
118
131
 
@@ -120,7 +133,10 @@ class NumTokens:
120
133
 
121
134
 
122
135
  class CondAstNode(CondTokens):
136
+ """AST node for boolean expressions supporting AND, OR, NOT, and leaf conditionals."""
137
+
123
138
  def __init__(self, type: int, cond1: Any, cond2: Any) -> None:
139
+ """Create a conditional AST node with the given operator type and children."""
124
140
  # 'type' should be one of the constants AND, OR, NOT, CONDITIONAL.
125
141
  # For AND and OR types, 'cond1' and 'cond2' should be a subtree (a CondAstNode)
126
142
  # For NOT type, 'cond1' should be a CondAstNode and 'cond2' should be None
@@ -134,6 +150,7 @@ class CondAstNode(CondTokens):
134
150
  self.right = None
135
151
 
136
152
  def eval(self) -> bool:
153
+ """Evaluate this subtree and return a boolean result."""
137
154
  # Evaluates the subtrees and/or conditional value for this node,
138
155
  # returning True or False.
139
156
  if self.type == self.CONDITIONAL:
@@ -157,7 +174,10 @@ class CondAstNode(CondTokens):
157
174
 
158
175
 
159
176
  class NumericAstNode(NumTokens):
177
+ """AST node for arithmetic expressions supporting MUL, DIV, ADD, SUB, and NUMBER."""
178
+
160
179
  def __init__(self, type: int, value1: Any, value2: Any) -> None:
180
+ """Create a numeric AST node with the given operator type and operands."""
161
181
  # 'type' should be one of the constants MUL, DIV, ADD, SUB, OR NUMBER.
162
182
  # 'value1' and 'value2' should each be either a subtree (a
163
183
  # NumericAstNode) or (only 'value1' should be) a number.
@@ -169,6 +189,7 @@ class NumericAstNode(NumTokens):
169
189
  self.right = None
170
190
 
171
191
  def eval(self) -> Any:
192
+ """Evaluate this subtree and return a numeric result."""
172
193
  # Evaluates the subtrees and/or numeric value for this node,
173
194
  # returning a numeric value.
174
195
  if self.type == self.NUMBER:
@@ -190,12 +211,16 @@ class NumericAstNode(NumTokens):
190
211
 
191
212
 
192
213
  class CondParser(CondTokens):
214
+ """Recursive-descent parser for boolean conditional expressions."""
215
+
193
216
  # Takes a conditional expression string.
194
217
  def __init__(self, condexpr: str) -> None:
218
+ """Initialise the parser with the conditional expression string."""
195
219
  self.condexpr = condexpr
196
220
  self.cond_expr = SourceString(condexpr)
197
221
 
198
222
  def match_not(self) -> int | None:
223
+ """Match a NOT operator and return its token type, or None."""
199
224
  # Try to match 'NOT' operator. If not found, return None
200
225
  m1 = self.cond_expr.match_str("NOT")
201
226
  if m1 is not None:
@@ -203,6 +228,7 @@ class CondParser(CondTokens):
203
228
  return None
204
229
 
205
230
  def match_andop(self) -> int | None:
231
+ """Match an AND operator and return its token type, or None."""
206
232
  # Try to match 'AND' operator. If not found, return None
207
233
  m1 = self.cond_expr.match_str("AND")
208
234
  if m1 is not None:
@@ -210,6 +236,7 @@ class CondParser(CondTokens):
210
236
  return None
211
237
 
212
238
  def match_orop(self) -> int | None:
239
+ """Match an OR operator and return its token type, or None."""
213
240
  # Try to match 'OR' operator. If not found, return None
214
241
  m1 = self.cond_expr.match_str("OR")
215
242
  if m1 is not None:
@@ -217,6 +244,7 @@ class CondParser(CondTokens):
217
244
  return None
218
245
 
219
246
  def factor(self) -> Any:
247
+ """Parse a factor: NOT, a parenthesised expression, or a metacommand leaf."""
220
248
  m1 = self.match_not()
221
249
  if m1 is not None:
222
250
  m1 = self.factor()
@@ -244,6 +272,7 @@ class CondParser(CondTokens):
244
272
  )
245
273
 
246
274
  def term(self) -> Any:
275
+ """Parse a term: a factor optionally followed by AND and another term."""
247
276
  m1 = self.factor()
248
277
  andop = self.match_andop()
249
278
  if andop is not None:
@@ -253,6 +282,7 @@ class CondParser(CondTokens):
253
282
  return m1
254
283
 
255
284
  def expression(self) -> Any:
285
+ """Parse an expression: a term optionally followed by OR and another expression."""
256
286
  e1 = self.term()
257
287
  orop = self.match_orop()
258
288
  if orop is not None:
@@ -262,6 +292,7 @@ class CondParser(CondTokens):
262
292
  return e1
263
293
 
264
294
  def parse(self) -> Any:
295
+ """Parse the entire conditional expression and return the AST root."""
265
296
  exp = self.expression()
266
297
  if not self.cond_expr.eoi():
267
298
  raise CondParserError(
@@ -274,13 +305,17 @@ class CondParser(CondTokens):
274
305
 
275
306
 
276
307
  class NumericParser(NumTokens):
308
+ """Recursive-descent parser for arithmetic numeric expressions."""
309
+
277
310
  # Takes a numeric expression string
278
311
  def __init__(self, numexpr: str) -> None:
312
+ """Initialise the parser with the numeric expression string."""
279
313
  self.num_expr = SourceString(numexpr)
280
314
  self.rxint = re.compile(r"(?P<int_num>[+-]?[0-9]+)")
281
315
  self.rxfloat = re.compile(r"(?P<float_num>[+-]?(?:(?:[0-9]*\.[0-9]+)|(?:[0-9]+\.[0-9]*)))")
282
316
 
283
317
  def match_number(self) -> Any | None:
318
+ """Match a float or integer literal and return its numeric value, or None."""
284
319
  # Try to match a number in the source string.
285
320
  # Return it if matched, return None if unmatched.
286
321
  m1 = self.num_expr.match_regex(self.rxfloat)
@@ -293,6 +328,7 @@ class NumericParser(NumTokens):
293
328
  return None
294
329
 
295
330
  def match_mulop(self) -> int | None:
331
+ """Match a multiplication or division operator and return its token type, or None."""
296
332
  # Try to match a multiplication or division operator in the source string.
297
333
  # if found, return the matching operator type. If not found, return None.
298
334
  m1 = self.num_expr.match_str("*")
@@ -305,6 +341,7 @@ class NumericParser(NumTokens):
305
341
  return None
306
342
 
307
343
  def match_addop(self) -> int | None:
344
+ """Match an addition or subtraction operator and return its token type, or None."""
308
345
  # Try to match an addition or subtraction operator in the source string.
309
346
  # if found, return the matching operator type. If not found, return None.
310
347
  m1 = self.num_expr.match_str("+")
@@ -317,6 +354,7 @@ class NumericParser(NumTokens):
317
354
  return None
318
355
 
319
356
  def factor(self) -> Any:
357
+ """Parse a numeric factor: a number literal or a parenthesised expression."""
320
358
  # Parses a factor out of the source string and returns the
321
359
  # AST node that is created.
322
360
  m1 = self.match_number()
@@ -338,6 +376,7 @@ class NumericParser(NumTokens):
338
376
  )
339
377
 
340
378
  def term(self) -> Any:
379
+ """Parse a term: a factor optionally followed by MUL/DIV and another term."""
341
380
  # Parses a term out of the source string and returns the
342
381
  # AST node that is created.
343
382
  m1 = self.factor()
@@ -349,6 +388,7 @@ class NumericParser(NumTokens):
349
388
  return m1
350
389
 
351
390
  def expression(self) -> Any:
391
+ """Parse an expression: a term optionally followed by ADD/SUB and another expression."""
352
392
  # Parses an expression out of the source string and returns the
353
393
  # AST node that is created.
354
394
  e1 = self.term()
@@ -362,6 +402,7 @@ class NumericParser(NumTokens):
362
402
  return e1
363
403
 
364
404
  def parse(self) -> Any:
405
+ """Parse the entire numeric expression and return the AST root."""
365
406
  exp = self.expression()
366
407
  if not self.num_expr.eoi():
367
408
  raise NumericParserError(
execsql/script/control.py CHANGED
@@ -12,6 +12,8 @@ from typing import Any
12
12
 
13
13
  from execsql.exceptions import ErrInfo
14
14
 
15
+ __all__ = ["BatchLevels", "IfItem", "IfLevels"]
16
+
15
17
 
16
18
  # ---------------------------------------------------------------------------
17
19
  # BatchLevels
execsql/script/engine.py CHANGED
@@ -40,6 +40,25 @@ from execsql.script.variables import LocalSubVarSet, ScriptArgSubVarSet, SubVarS
40
40
  from execsql.utils.errors import exception_desc
41
41
  from execsql.utils.fileio import EncodedFile
42
42
 
43
+ __all__ = [
44
+ "MetaCommand",
45
+ "MetaCommandList",
46
+ "SqlStmt",
47
+ "MetacommandStmt",
48
+ "ScriptCmd",
49
+ "CommandList",
50
+ "CommandListWhileLoop",
51
+ "CommandListUntilLoop",
52
+ "ScriptFile",
53
+ "ScriptExecSpec",
54
+ "set_system_vars",
55
+ "substitute_vars",
56
+ "runscripts",
57
+ "current_script_line",
58
+ "read_sqlfile",
59
+ "read_sqlstring",
60
+ ]
61
+
43
62
 
44
63
  # ---------------------------------------------------------------------------
45
64
  # MetaCommand / MetaCommandList
@@ -15,6 +15,8 @@ from typing import Any
15
15
 
16
16
  from execsql.exceptions import ErrInfo
17
17
 
18
+ __all__ = ["CounterVars", "SubVarSet", "LocalSubVarSet", "ScriptArgSubVarSet"]
19
+
18
20
 
19
21
  # ---------------------------------------------------------------------------
20
22
  # CounterVars
@@ -184,15 +186,17 @@ class SubVarSet:
184
186
  return template_str.lower() in self._subs_dict
185
187
 
186
188
  def merge(self, other_subvars: SubVarSet | None) -> SubVarSet:
187
- """Return a new SubVarSet with this object's variables merged with other_subvars."""
189
+ """Return a new SubVarSet with this object's variables merged with other_subvars.
190
+
191
+ Copies dictionaries and pre-compiled patterns directly instead of
192
+ re-adding variables one at a time, avoiding O(V) regex recompilation.
193
+ """
188
194
  if other_subvars is not None:
189
195
  newsubs = SubVarSet()
190
- newsubs._subs_dict = dict(self._subs_dict)
191
- newsubs._compiled_patterns = dict(self._compiled_patterns)
196
+ newsubs._subs_dict = {**self._subs_dict, **other_subvars._subs_dict}
197
+ newsubs._compiled_patterns = {**self._compiled_patterns, **other_subvars._compiled_patterns}
192
198
  newsubs.prefix_list = list(set(self.prefix_list + other_subvars.prefix_list))
193
199
  newsubs.compile_var_rx()
194
- for varname, value in other_subvars._subs_dict.items():
195
- newsubs.add_substitution(varname, value)
196
200
  return newsubs
197
201
  return self
198
202
 
execsql/state.py CHANGED
@@ -55,6 +55,58 @@ if TYPE_CHECKING:
55
55
  from execsql.utils.mail import MailSpec
56
56
  from execsql.utils.timer import Timer
57
57
 
58
+ __all__ = [
59
+ # Configuration / encoding
60
+ "conf",
61
+ "logfile_encoding",
62
+ # Runtime state
63
+ "last_command",
64
+ "upass",
65
+ "varlike",
66
+ "err_halt_writespec",
67
+ "err_halt_email",
68
+ "err_halt_exec",
69
+ "cancel_halt_writespec",
70
+ "cancel_halt_mailspec",
71
+ "cancel_halt_exec",
72
+ "commandliststack",
73
+ "savedscripts",
74
+ "loopcommandstack",
75
+ "compiling_loop",
76
+ "endloop_rx",
77
+ "loop_rx",
78
+ "loop_nest_level",
79
+ "cmds_run",
80
+ "defer_rx",
81
+ "stringtypes",
82
+ "exec_log",
83
+ "subvars",
84
+ "status",
85
+ # Lazy singletons
86
+ "if_stack",
87
+ "counters",
88
+ "timer",
89
+ "output",
90
+ "dbs",
91
+ "tempfiles",
92
+ "export_metadata",
93
+ "metacommandlist",
94
+ "conditionallist",
95
+ "filewriter",
96
+ "gui_console",
97
+ "gui_manager_queue",
98
+ "gui_manager_thread",
99
+ # Version
100
+ "primary_vno",
101
+ "secondary_vno",
102
+ "tertiary_vno",
103
+ # Functions
104
+ "xcmd_test",
105
+ "endloop",
106
+ "reset",
107
+ "initialize",
108
+ ]
109
+
58
110
  # ---------------------------------------------------------------------------
59
111
  # Configuration / encoding
60
112
  # ---------------------------------------------------------------------------
execsql/types.py CHANGED
@@ -70,6 +70,8 @@ __all__ = [
70
70
 
71
71
 
72
72
  class DataType:
73
+ """Abstract base class for all data-type matchers used during column inference."""
74
+
73
75
  data_type_name = None
74
76
  data_type = None
75
77
  lenspec = False # Is a length specification required for a (SQL) declaration of this data type?
@@ -83,9 +85,11 @@ class DataType:
83
85
  return f"DataType({self.data_type_name!r}, {self.data_type!r})"
84
86
 
85
87
  def is_null(self, data: object) -> bool:
88
+ """Return True if the data value is None."""
86
89
  return data is None
87
90
 
88
91
  def matches(self, data: object) -> bool:
92
+ """Return True if the non-null data value could be of this data type."""
89
93
  # Returns T/F indicating whether the given data value could be of this data type.
90
94
  # The data value should be non-null.
91
95
  if self.is_null(data):
@@ -93,6 +97,7 @@ class DataType:
93
97
  return self._is_match(data)
94
98
 
95
99
  def from_data(self, data: object) -> object:
100
+ """Coerce the data value to this type or raise DataTypeError."""
96
101
  # Returns the data value coerced to this type, or raises a DataTypeError exception.
97
102
  # The data value should be non-null.
98
103
  if self.is_null(data):
@@ -123,16 +128,22 @@ class DataType:
123
128
 
124
129
 
125
130
  class Tz(datetime.tzinfo):
131
+ """Fixed-offset timezone implementation for timestamp-with-timezone parsing."""
132
+
126
133
  def __init__(self, sign: int, hr: int, min: int) -> None:
134
+ """Store the UTC offset as sign, hours, and minutes."""
127
135
  self.sign = sign
128
136
  self.hr = hr
129
137
  self.min = min
130
138
 
131
139
  def utcoffset(self, dt: object) -> datetime.timedelta:
140
+ """Return the UTC offset as a timedelta."""
132
141
  return self.sign * datetime.timedelta(hours=self.hr, minutes=self.min)
133
142
 
134
143
 
135
144
  class DT_TimestampTZ(DataType):
145
+ """Timezone-aware timestamp data type."""
146
+
136
147
  data_type_name = "timestamptz"
137
148
  data_type = datetime.datetime
138
149
  # There is no distinct Python type corresponding to a timestamptz, so the data_type
@@ -166,6 +177,8 @@ class DT_TimestampTZ(DataType):
166
177
 
167
178
 
168
179
  class DT_Timestamp(DataType):
180
+ """Naive timestamp (datetime without timezone) data type."""
181
+
169
182
  data_type_name = "timestamp"
170
183
  data_type = datetime.datetime
171
184
 
@@ -207,10 +220,13 @@ date_fmts = collections.deque(
207
220
 
208
221
 
209
222
  class DT_Date(DataType):
223
+ """Calendar date data type with multiple format recognition."""
224
+
210
225
  data_type_name = "date"
211
226
  data_type = datetime.date
212
227
 
213
228
  def __init__(self) -> None:
229
+ """Initialise the date format deque for adaptive format matching."""
214
230
  self._date_fmts = collections.deque(date_fmts)
215
231
 
216
232
  def __repr__(self) -> str:
@@ -239,6 +255,8 @@ class DT_Date(DataType):
239
255
 
240
256
 
241
257
  class DT_Time(DataType):
258
+ """Time-of-day data type with multiple format recognition."""
259
+
242
260
  data_type_name = "time"
243
261
  data_type = datetime.time
244
262
  time_fmts = (
@@ -288,6 +306,8 @@ class DT_Time_Oracle(DT_Time):
288
306
 
289
307
 
290
308
  class DT_Boolean(DataType):
309
+ """Boolean data type recognising word and integer true/false forms."""
310
+
291
311
  data_type_name = "boolean"
292
312
  data_type = bool
293
313
 
@@ -295,6 +315,7 @@ class DT_Boolean(DataType):
295
315
  return "DT_Boolean()"
296
316
 
297
317
  def set_bool_matches(self) -> None:
318
+ """Populate the true/false match tuples from the current configuration."""
298
319
  import execsql.state as _state
299
320
 
300
321
  conf = _state.conf
@@ -342,6 +363,8 @@ class DT_Boolean(DataType):
342
363
 
343
364
 
344
365
  class DT_Integer(DataType):
366
+ """Small integer data type bounded by the configured max_int."""
367
+
345
368
  data_type_name = "integer"
346
369
  data_type = int
347
370
 
@@ -391,6 +414,8 @@ class DT_Integer(DataType):
391
414
 
392
415
 
393
416
  class DT_Long(DataType):
417
+ """Large integer (bigint) data type using Python int."""
418
+
394
419
  data_type_name = "long"
395
420
  data_type = int # In Python 3, long is just int
396
421
 
@@ -425,6 +450,8 @@ class DT_Long(DataType):
425
450
 
426
451
 
427
452
  class DT_Float(DataType):
453
+ """IEEE double-precision floating-point data type."""
454
+
428
455
  data_type_name = "float"
429
456
  data_type = float
430
457
 
@@ -463,6 +490,8 @@ class DT_Float(DataType):
463
490
 
464
491
 
465
492
  class DT_Decimal(DataType):
493
+ """Exact decimal data type tracking precision and scale."""
494
+
466
495
  data_type_name = "decimal"
467
496
  data_type = Decimal
468
497
  precspec = True
@@ -471,6 +500,7 @@ class DT_Decimal(DataType):
471
500
  return "DT_Decimal()"
472
501
 
473
502
  def set_scale_prec(self, dec: Decimal) -> None:
503
+ """Compute and store the precision and scale from a Decimal value."""
474
504
  # 'dec' should be Decimal.
475
505
  x = dec.as_tuple()
476
506
  digits = len(x.digits)
@@ -501,6 +531,8 @@ class DT_Decimal(DataType):
501
531
 
502
532
 
503
533
  class DT_Character(DataType):
534
+ """Fixed-length string data type (up to 255 characters)."""
535
+
504
536
  data_type_name = "character"
505
537
  lenspec = True
506
538
 
@@ -530,6 +562,8 @@ class DT_Character(DataType):
530
562
 
531
563
 
532
564
  class DT_Varchar(DataType):
565
+ """Variable-length string data type (up to 255 characters)."""
566
+
533
567
  data_type_name = "varchar"
534
568
  lenspec = True
535
569
  varlen = True
@@ -558,6 +592,8 @@ class DT_Varchar(DataType):
558
592
 
559
593
 
560
594
  class DT_Text(DataType):
595
+ """Unbounded text string data type."""
596
+
561
597
  data_type_name = "character"
562
598
 
563
599
  def __repr__(self) -> str:
@@ -581,6 +617,8 @@ class DT_Text(DataType):
581
617
 
582
618
 
583
619
  class DT_Binary(DataType):
620
+ """Binary byte-array data type."""
621
+
584
622
  data_type_name = "binary"
585
623
  data_type = bytearray
586
624
 
@@ -589,7 +627,10 @@ class DT_Binary(DataType):
589
627
 
590
628
 
591
629
  class DbType:
630
+ """Map Python DataType subclasses to DBMS-specific SQL type names."""
631
+
592
632
  def __init__(self, DBMS_id: str, db_obj_quotes: str = '""') -> None:
633
+ """Initialise a DBMS dialect with its identifier and quoting characters."""
593
634
  # The DBMS_id is the name by which this DBMS is identified.
594
635
  # db_obj_quotechars is a string of two characters that are the opening and closing quotes
595
636
  # for identifiers (schema, table, and column names) that need to be quoted.
@@ -621,6 +662,7 @@ class DbType:
621
662
  precision: object = None,
622
663
  scale: object = None,
623
664
  ) -> None:
665
+ """Register a DBMS-specific SQL type name for a DataType class."""
624
666
  # data_type is a DataType class object.
625
667
  # dbms_name is the DBMS-specific name for this data type.
626
668
  # length_required indicates whether length information is required.
@@ -631,6 +673,7 @@ class DbType:
631
673
  self.dialect[data_type] = (dbms_name, length_required, casting_name, conv_mod_fn, precision, scale)
632
674
 
633
675
  def datatype_name(self, data_type: object) -> str:
676
+ """Return the DBMS-specific SQL type name for the given DataType class."""
634
677
  # A convenience function to simplify access to data type names.
635
678
  try:
636
679
  return self.dialect[data_type][0]
@@ -642,6 +685,7 @@ class DbType:
642
685
  ) from e
643
686
 
644
687
  def quoted(self, dbms_object: str) -> str:
688
+ """Quote a database identifier if it contains non-word characters."""
645
689
  if re.search(r"\W", dbms_object):
646
690
  if self.quotechars[0] == self.quotechars[1] and self.quotechars[0] in dbms_object:
647
691
  dbms_object = dbms_object.replace(self.quotechars[0], self.quotechars[0] + self.quotechars[0])
@@ -649,6 +693,7 @@ class DbType:
649
693
  return dbms_object
650
694
 
651
695
  def spec_type(self, data_type: object) -> object:
696
+ """Return the translated data type, or the original if no translation exists."""
652
697
  # Returns a translated data type or the original if there is no translation.
653
698
  if data_type in self.dt_xlate:
654
699
  return self.dt_xlate[data_type]
@@ -663,6 +708,7 @@ class DbType:
663
708
  precision: object = None,
664
709
  scale: object = None,
665
710
  ) -> str:
711
+ """Return a column specification string suitable for a CREATE TABLE statement."""
666
712
  # Returns a column specification as it would be used in a CREATE TABLE statement.
667
713
  data_type = self.spec_type(data_type)
668
714
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: execsql2
3
- Version: 2.4.4
3
+ Version: 2.5.0
4
4
  Summary: Runs a SQL script against a PostgreSQL, SQLite, MariaDB/MySQL, DuckDB, Firebird, MS-Access, MS-SQL-Server, or Oracle database, or an ODBC DSN. Provides metacommands to import and export data, copy data between databases, conditionally execute SQL and metacommands, and dynamically alter SQL and metacommands with substitution variables.
5
5
  Project-URL: Repository, https://github.com/geocoug/execsql
6
6
  Project-URL: Issues, https://github.com/geocoug/execsql/issues
@@ -107,7 +107,7 @@ Description-Content-Type: text/markdown
107
107
 
108
108
  > [!NOTE]
109
109
  > **This is a maintained fork of [execsql](https://execsql.readthedocs.io/).**
110
- > The original monolith has been fully refactored into a modular package with 2,000+ tests.
110
+ > The original monolith has been fully refactored into a modular package.
111
111
  > The CLI and configuration are backwards-compatible with upstream v1.130.1.
112
112
  > Report issues at [github.com/geocoug/execsql/issues](https://github.com/geocoug/execsql/issues).
113
113
 
@@ -215,8 +215,12 @@ execsql script.sql # read connection from config file
215
215
  | `-m` | List metacommands and exit |
216
216
  | `-n` | Create a new SQLite or PostgreSQL database if it does not exist |
217
217
  | `-v {0,1,2,3}` | GUI level (0=none, 1=password, 2=selection, 3=full) |
218
- | `--gui-framework {tkinter,textual}` | GUI framework for interactive prompts |
219
218
  | `-w` | Skip password prompt when a username is supplied |
219
+ | `--dsn URL` | Connection string (e.g. `postgresql://user:pass@host/db`) |
220
+ | `--dry-run` | Parse the script and report commands without executing |
221
+ | `--progress` | Show a progress bar for long-running IMPORT operations |
222
+ | `--dump-keywords` | Print metacommand keywords as JSON and exit |
223
+ | `--gui-framework {tkinter,textual}` | GUI framework for interactive prompts |
220
224
 
221
225
  Run `execsql --help` for the full option list, or `execsql -m` to list all metacommands.
222
226
 
@@ -283,7 +287,30 @@ execsql-format --in-place scripts/
283
287
  execsql-format --check scripts/
284
288
  ```
285
289
 
286
- See the [formatter documentation](https://execsql2.readthedocs.io/en/latest/formatter/) for all options.
290
+ `execsql-format` is also available as a [pre-commit](https://pre-commit.com/) hook:
291
+
292
+ ```yaml
293
+ repos:
294
+ - repo: https://github.com/geocoug/execsql
295
+ rev: v2.4.4
296
+ hooks:
297
+ - id: execsql-format
298
+ args: [--in-place]
299
+ ```
300
+
301
+ See the [formatter documentation](https://execsql2.readthedocs.io/en/latest/guides/formatter/) for all options.
302
+
303
+ # VS Code Syntax Highlighting
304
+
305
+ A VS Code extension for execsql syntax highlighting is included in [`extras/vscode-execsql`](extras/vscode-execsql). It injects a TextMate grammar into `.sql` files, adding highlighting for `-- !x!` metacommand markers, keywords (control flow, block, action, directive), variable substitutions (`!!var!!`, `!{var}!`), built-in functions, export formats, and config options — all layered on top of standard SQL highlighting.
306
+
307
+ To install, symlink the extension folder into your VS Code extensions directory:
308
+
309
+ ```sh
310
+ ln -s /path/to/execsql/extras/vscode-execsql ~/.vscode/extensions/execsql-syntax
311
+ ```
312
+
313
+ See the [extension README](extras/vscode-execsql/README.md) for Windows instructions, color customization, and troubleshooting.
287
314
 
288
315
  # Templates
289
316