execsql2 2.15.0__py3-none-any.whl → 2.15.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 (41) hide show
  1. execsql/config.py +238 -310
  2. execsql/db/base.py +0 -1
  3. execsql/db/duckdb.py +6 -7
  4. execsql/db/sqlite.py +47 -47
  5. execsql/gui/base.py +173 -28
  6. execsql/gui/console.py +50 -13
  7. execsql/gui/desktop.py +70 -28
  8. execsql/gui/tui.py +57 -32
  9. execsql/metacommands/conditions.py +0 -24
  10. execsql/metacommands/io_export.py +6 -0
  11. execsql/metacommands/io_import.py +5 -5
  12. execsql/metacommands/upsert.py +17 -33
  13. execsql/models.py +0 -1
  14. execsql/parser.py +22 -23
  15. execsql/script/engine.py +2 -0
  16. execsql/types.py +28 -30
  17. execsql/utils/datetime.py +52 -246
  18. execsql/utils/errors.py +0 -19
  19. execsql/utils/fileio.py +0 -8
  20. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/METADATA +2 -1
  21. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/RECORD +40 -41
  22. execsql/constants.py +0 -370
  23. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/README.md +0 -0
  24. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  25. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  26. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/execsql.conf +0 -0
  27. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
  28. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/md_compare.sql +0 -0
  29. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
  30. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
  31. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
  32. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  33. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  34. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/script_template.sql +0 -0
  35. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
  36. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  37. {execsql2-2.15.0.data → execsql2-2.15.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  38. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/WHEEL +0 -0
  39. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/entry_points.txt +0 -0
  40. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/licenses/LICENSE.txt +0 -0
  41. {execsql2-2.15.0.dist-info → execsql2-2.15.2.dist-info}/licenses/NOTICE +0 -0
execsql/config.py CHANGED
@@ -74,6 +74,130 @@ class ConfigData:
74
74
  _INCLUDE_REQ_SECTION = "include_required"
75
75
  _INCLUDE_OPT_SECTION = "include_optional"
76
76
 
77
+ def _get_str(self, cp: ConfigParser, section: str, key: str, attr: str, *, required: bool = False) -> None:
78
+ """Read a string option and set ``self.<attr>``.
79
+
80
+ Args:
81
+ cp: ConfigParser instance to read from.
82
+ section: INI section name.
83
+ key: Option key within the section.
84
+ attr: Attribute name to set on ``self``.
85
+ required: If ``True``, raise :class:`ConfigError` when the value is ``None``.
86
+ """
87
+ if cp.has_option(section, key):
88
+ val = cp.get(section, key)
89
+ if required and val is None:
90
+ raise ConfigError(f"The {key} cannot be missing.")
91
+ setattr(self, attr, val)
92
+
93
+ def _get_enum(
94
+ self,
95
+ cp: ConfigParser,
96
+ section: str,
97
+ key: str,
98
+ attr: str,
99
+ choices: tuple,
100
+ *,
101
+ lower: bool = True,
102
+ ) -> None:
103
+ """Read a string option, validate against ``choices``, and set ``self.<attr>``.
104
+
105
+ Args:
106
+ cp: ConfigParser instance to read from.
107
+ section: INI section name.
108
+ key: Option key within the section.
109
+ attr: Attribute name to set on ``self``.
110
+ choices: Tuple of permitted values.
111
+ lower: If ``True`` (default), lower-case the raw value before validation.
112
+ """
113
+ if cp.has_option(section, key):
114
+ val = cp.get(section, key)
115
+ if lower:
116
+ val = val.lower()
117
+ if val not in choices:
118
+ raise ConfigError(f"Invalid argument to {key}: {val}")
119
+ setattr(self, attr, val)
120
+
121
+ def _get_bool(self, cp: ConfigParser, section: str, key: str, attr: str) -> None:
122
+ """Read a boolean option and set ``self.<attr>``.
123
+
124
+ Args:
125
+ cp: ConfigParser instance to read from.
126
+ section: INI section name.
127
+ key: Option key within the section.
128
+ attr: Attribute name to set on ``self``.
129
+
130
+ Raises:
131
+ ConfigError: If the value cannot be parsed as a boolean.
132
+ """
133
+ if cp.has_option(section, key):
134
+ try:
135
+ setattr(self, attr, cp.getboolean(section, key))
136
+ except Exception as e:
137
+ raise ConfigError(f"Invalid argument for {key}.") from e
138
+
139
+ def _get_int(
140
+ self,
141
+ cp: ConfigParser,
142
+ section: str,
143
+ key: str,
144
+ attr: str,
145
+ *,
146
+ multiply: int = 1,
147
+ min_val: int | None = None,
148
+ ) -> None:
149
+ """Read an integer option, optionally multiply it, and set ``self.<attr>``.
150
+
151
+ Args:
152
+ cp: ConfigParser instance to read from.
153
+ section: INI section name.
154
+ key: Option key within the section.
155
+ attr: Attribute name to set on ``self``.
156
+ multiply: Multiply the parsed integer by this factor (default 1).
157
+ min_val: If given, clamp the result to at least this value.
158
+
159
+ Raises:
160
+ ConfigError: If the value cannot be parsed as an integer.
161
+ """
162
+ if cp.has_option(section, key):
163
+ try:
164
+ val = cp.getint(section, key) * multiply
165
+ except Exception as e:
166
+ raise ConfigError(f"Invalid argument for {key}.") from e
167
+ if min_val is not None:
168
+ val = max(min_val, val)
169
+ setattr(self, attr, val)
170
+
171
+ def _get_float(
172
+ self,
173
+ cp: ConfigParser,
174
+ section: str,
175
+ key: str,
176
+ attr: str,
177
+ *,
178
+ min_val: float | None = None,
179
+ ) -> None:
180
+ """Read a float option and set ``self.<attr>``.
181
+
182
+ Args:
183
+ cp: ConfigParser instance to read from.
184
+ section: INI section name.
185
+ key: Option key within the section.
186
+ attr: Attribute name to set on ``self``.
187
+ min_val: If given, raise :class:`ConfigError` when the value is below this minimum.
188
+
189
+ Raises:
190
+ ConfigError: If the value cannot be parsed as a float, or is below ``min_val``.
191
+ """
192
+ if cp.has_option(section, key):
193
+ try:
194
+ val = cp.getfloat(section, key)
195
+ except Exception as e:
196
+ raise ConfigError(f"Invalid argument for {key}.") from e
197
+ if min_val is not None and val < min_val:
198
+ raise ConfigError(f"Invalid {key}: {val}; must be >= {min_val}.")
199
+ setattr(self, attr, val)
200
+
77
201
  def __init__(self, script_path: str, variable_pool: object) -> None:
78
202
  """Load and merge all discoverable execsql.conf files for the given script path.
79
203
 
@@ -172,245 +296,89 @@ class ConfigData:
172
296
  self.files_read.append(configfile)
173
297
  cp = ConfigParser()
174
298
  cp.read(configfile)
299
+ # --- [connect] ---
175
300
  if cp.has_option(self._CONNECT_SECTION, "db_type"):
176
301
  t = cp.get(self._CONNECT_SECTION, "db_type").lower()
177
- if len(t) != 1 or t not in ("a", "d", "f", "k", "l", "m", "o", "p", "s"):
302
+ if t not in ("a", "d", "f", "k", "l", "m", "o", "p", "s"):
178
303
  raise ConfigError(f"Invalid database type: {t}")
179
304
  self.db_type = t
180
- if cp.has_option(self._CONNECT_SECTION, "server"):
181
- self.server = cp.get(self._CONNECT_SECTION, "server")
182
- if self.server is None:
183
- raise ConfigError("The server name cannot be missing.")
184
- if cp.has_option(self._CONNECT_SECTION, "db"):
185
- self.db = cp.get(self._CONNECT_SECTION, "db")
186
- if self.db is None:
187
- raise ConfigError("The database name cannot be missing.")
188
- if cp.has_option(self._CONNECT_SECTION, "port"):
189
- try:
190
- self.port = cp.getint(self._CONNECT_SECTION, "port")
191
- except Exception as e:
192
- raise ConfigError("Invalid port number.") from e
193
- if cp.has_option(self._CONNECT_SECTION, "database"):
194
- self.db = cp.get(self._CONNECT_SECTION, "database")
195
- if self.db is None:
196
- raise ConfigError("The database name cannot be missing.")
197
- if cp.has_option(self._CONNECT_SECTION, "db_file"):
198
- self.db_file = cp.get(self._CONNECT_SECTION, "db_file")
199
- if self.db_file is None:
200
- raise ConfigError("The database file name cannot be missing.")
201
- if cp.has_option(self._CONNECT_SECTION, "username"):
202
- self.username = cp.get(self._CONNECT_SECTION, "username")
203
- if self.username is None:
204
- raise ConfigError("The user name cannot be missing.")
205
- if cp.has_option(self._CONNECT_SECTION, "access_username"):
206
- self.access_username = cp.get(self._CONNECT_SECTION, "access_username")
207
- if cp.has_option(self._CONNECT_SECTION, "password_prompt"):
208
- try:
209
- self.passwd_prompt = cp.getboolean(self._CONNECT_SECTION, "password_prompt")
210
- except Exception as e:
211
- raise ConfigError("Invalid argument for password_prompt.") from e
212
- if cp.has_option(self._CONNECT_SECTION, "use_keyring"):
213
- try:
214
- self.use_keyring = cp.getboolean(self._CONNECT_SECTION, "use_keyring")
215
- except Exception as e:
216
- raise ConfigError("Invalid argument for use_keyring.") from e
217
- if cp.has_option(self._CONNECT_SECTION, "new_db"):
218
- try:
219
- self.new_db = cp.getboolean(self._CONNECT_SECTION, "new_db")
220
- except Exception as e:
221
- raise ConfigError("Invalid argument for new_db.") from e
222
- if cp.has_option(self._ENCODING_SECTION, "database"):
223
- self.db_encoding = cp.get(self._ENCODING_SECTION, "database")
224
- if cp.has_option(self._ENCODING_SECTION, "script"):
225
- self.script_encoding = cp.get(self._ENCODING_SECTION, "script")
226
- if self.script_encoding is None:
227
- raise ConfigError("The script encoding cannot be missing.")
228
- if cp.has_option(self._ENCODING_SECTION, "import"):
229
- self.import_encoding = cp.get(self._ENCODING_SECTION, "import")
230
- if self.import_encoding is None:
231
- raise ConfigError("The import encoding cannot be missing.")
232
- if cp.has_option(self._ENCODING_SECTION, "output"):
233
- self.output_encoding = cp.get(self._ENCODING_SECTION, "output")
234
- if self.output_encoding is None:
235
- raise ConfigError("The output encoding cannot be missing.")
236
- if cp.has_option(self._ENCODING_SECTION, "error_response"):
237
- handler = cp.get(self._ENCODING_SECTION, "error_response").lower()
238
- if handler not in ("ignore", "replace", "xmlcharrefreplace", "backslashreplace"):
239
- raise ConfigError(f"Invalid encoding error handler: {handler}")
240
- self.enc_err_disposition = handler
241
- if cp.has_option(self._INPUT_SECTION, "max_int"):
242
- try:
243
- maxint = cp.getint(self._INPUT_SECTION, "max_int")
244
- except Exception as e:
245
- raise ConfigError("Invalid argument to max_int.") from e
246
- else:
247
- self.max_int = maxint
248
- if cp.has_option(self._INPUT_SECTION, "boolean_int"):
249
- try:
250
- self.boolean_int = cp.getboolean(self._INPUT_SECTION, "boolean_int")
251
- except Exception as e:
252
- raise ConfigError("Invalid argument to boolean_int.") from e
253
- if cp.has_option(self._INPUT_SECTION, "boolean_words"):
254
- try:
255
- self.boolean_words = cp.getboolean(self._INPUT_SECTION, "boolean_words")
256
- except Exception as e:
257
- raise ConfigError("Invalid argument to boolean_words.") from e
258
- if cp.has_option(self._INPUT_SECTION, "empty_strings"):
259
- try:
260
- self.empty_strings = cp.getboolean(self._INPUT_SECTION, "empty_strings")
261
- except Exception as e:
262
- raise ConfigError("Invalid argument to empty_strings.") from e
263
- if cp.has_option(self._INPUT_SECTION, "only_strings"):
264
- try:
265
- self.only_strings = cp.getboolean(self._INPUT_SECTION, "only_strings")
266
- except Exception as e:
267
- raise ConfigError("Invalid argument to only_strings.") from e
268
- if cp.has_option(self._INPUT_SECTION, "empty_rows"):
269
- try:
270
- self.empty_rows = cp.getboolean(self._INPUT_SECTION, "empty_rows")
271
- except Exception as e:
272
- raise ConfigError("Invalid argument to empty_rows.") from e
273
- if cp.has_option(self._INPUT_SECTION, "delete_empty_columns"):
274
- try:
275
- self.del_empty_cols = cp.getboolean(self._INPUT_SECTION, "delete_empty_columns")
276
- except Exception as e:
277
- raise ConfigError("Invalid argument to delete_empty_columns.") from e
278
- if cp.has_option(self._INPUT_SECTION, "create_column_headers"):
279
- try:
280
- self.create_col_hdrs = cp.getboolean(self._INPUT_SECTION, "create_column_headers")
281
- except Exception as e:
282
- raise ConfigError("Invalid argument to create_column_headers.") from e
283
- if cp.has_option(self._INPUT_SECTION, "trim_column_headers"):
284
- try:
285
- self.trim_col_hdrs = cp.get(self._INPUT_SECTION, "trim_column_headers").lower()
286
- except Exception as e:
287
- raise ConfigError("Invalid argument to trim_column_headers.") from e
288
- if self.trim_col_hdrs not in ("none", "both", "left", "right"):
289
- raise ConfigError(f"Invalid argument to trim_column_headers: {self.trim_col_hdrs}.")
290
- if cp.has_option(self._INPUT_SECTION, "clean_column_headers"):
291
- try:
292
- self.clean_col_hdrs = cp.getboolean(self._INPUT_SECTION, "clean_column_headers")
293
- except Exception as e:
294
- raise ConfigError("Invalid argument to clean_column_headers.") from e
295
- if cp.has_option(self._INPUT_SECTION, "fold_column_headers"):
296
- foldspec = cp.get(self._INPUT_SECTION, "fold_column_headers").lower()
297
- if foldspec not in ("no", "lower", "upper"):
298
- raise ConfigError(f"Invalid argument to fold_column_headers: {foldspec}.")
299
- self.fold_col_hdrs = foldspec
300
- if cp.has_option(self._INPUT_SECTION, "dedup_column_headers"):
301
- try:
302
- self.dedup_col_hdrs = cp.getboolean(self._INPUT_SECTION, "dedup_column_headers")
303
- except Exception as e:
304
- raise ConfigError("Invalid argument to dedup_column_headers.") from e
305
- if cp.has_option(self._INPUT_SECTION, "trim_strings"):
306
- try:
307
- self.trim_strings = cp.getboolean(self._INPUT_SECTION, "trim_strings")
308
- except Exception as e:
309
- raise ConfigError("Invalid argument to trim_strings.") from e
310
- if cp.has_option(self._INPUT_SECTION, "replace_newlines"):
311
- try:
312
- self.replace_newlines = cp.getboolean(self._INPUT_SECTION, "replace_newlines")
313
- except Exception as e:
314
- raise ConfigError("Invalid argument to replace_newlines.") from e
315
- if cp.has_option(self._INPUT_SECTION, "import_row_buffer"):
316
- try:
317
- self.import_row_buffer = cp.getint(self._INPUT_SECTION, "import_row_buffer")
318
- except Exception as e:
319
- raise ConfigError("Invalid argument for import_row_buffer.") from e
320
- if cp.has_option(self._INPUT_SECTION, "import_progress_interval"):
321
- try:
322
- self.import_progress_interval = cp.getint(self._INPUT_SECTION, "import_progress_interval")
323
- except Exception as e:
324
- raise ConfigError("Invalid argument for import_progress_interval.") from e
325
- if cp.has_option(self._INPUT_SECTION, "show_progress"):
326
- try:
327
- self.show_progress = cp.getboolean(self._INPUT_SECTION, "show_progress")
328
- except Exception as e:
329
- raise ConfigError("Invalid argument for show_progress.") from e
330
- if cp.has_option(self._INPUT_SECTION, "access_use_numeric"):
331
- try:
332
- self.access_use_numeric = cp.getboolean(self._INPUT_SECTION, "access_use_numeric")
333
- except Exception as e:
334
- raise ConfigError("Invalid argument to access_use_numeric.") from e
335
- if cp.has_option(self._INPUT_SECTION, "import_only_common_columns"):
336
- try:
337
- self.import_common_cols_only = cp.getboolean(
338
- self._INPUT_SECTION,
339
- "import_only_common_columns",
340
- )
341
- except Exception as e:
342
- raise ConfigError("Invalid argument to import_only_common_columns.") from e
343
- if cp.has_option(self._INPUT_SECTION, "import_common_columns_only"):
344
- try:
345
- self.import_common_cols_only = cp.getboolean(
346
- self._INPUT_SECTION,
347
- "import_common_columns_only",
348
- )
349
- except Exception as e:
350
- raise ConfigError("Invalid argument to import_common_columns_only.") from e
351
- if cp.has_option(self._INPUT_SECTION, "scan_lines"):
352
- try:
353
- self.scan_lines = cp.getint(self._INPUT_SECTION, "scan_lines")
354
- except Exception as e:
355
- raise ConfigError("Invalid argument to scan_lines.") from e
356
- if cp.has_option(self._INPUT_SECTION, "import_buffer"):
357
- try:
358
- self.import_buffer = cp.getint(self._INPUT_SECTION, "import_buffer") * 1024
359
- except Exception as e:
360
- raise ConfigError("Invalid argument for import_buffer.") from e
361
- if cp.has_option(self._OUTPUT_SECTION, "log_write_messages"):
362
- try:
363
- self.tee_write_log = cp.getboolean(self._OUTPUT_SECTION, "log_write_messages")
364
- except Exception as e:
365
- raise ConfigError("Invalid argument to log_write_messages") from e
366
- if cp.has_option(self._OUTPUT_SECTION, "hdf5_text_len"):
367
- try:
368
- self.hdf5_text_len = cp.getint(self._OUTPUT_SECTION, "hdf5_text_len")
369
- except Exception as e:
370
- raise ConfigError("Invalid argument to log_write_messages") from e
371
- if cp.has_option(self._OUTPUT_SECTION, "css_file"):
372
- self.css_file = cp.get(self._OUTPUT_SECTION, "css_file")
373
- if self.css_file is None:
374
- raise ConfigError("The css_file name is missing.")
375
- if cp.has_option(self._OUTPUT_SECTION, "css_styles"):
376
- self.css_styles = cp.get(self._OUTPUT_SECTION, "css_styles")
377
- if self.css_styles is None:
378
- raise ConfigError("The css_styles are missing.")
379
- if cp.has_option(self._OUTPUT_SECTION, "make_export_dirs"):
380
- try:
381
- self.make_export_dirs = cp.getboolean(self._OUTPUT_SECTION, "make_export_dirs")
382
- except Exception as e:
383
- raise ConfigError("Invalid argument for make_export_dirs.") from e
384
- if cp.has_option(self._OUTPUT_SECTION, "quote_all_text"):
385
- try:
386
- self.quote_all_text = cp.getboolean(self._OUTPUT_SECTION, "quote_all_text")
387
- except Exception as e:
388
- raise ConfigError("Invalid argument for make_export_dirs.") from e
389
- if cp.has_option(self._OUTPUT_SECTION, "outfile_open_timeout"):
390
- try:
391
- self.outfile_open_timeout = cp.getint(self._OUTPUT_SECTION, "outfile_open_timeout")
392
- except Exception as e:
393
- raise ConfigError("Invalid argument for outfile_open_timeout.") from e
394
- if cp.has_option(self._OUTPUT_SECTION, "export_row_buffer"):
395
- try:
396
- self.export_row_buffer = cp.getint(self._OUTPUT_SECTION, "export_row_buffer")
397
- except Exception as e:
398
- raise ConfigError("Invalid argument for export_row_buffer.") from e
399
- if cp.has_option(self._OUTPUT_SECTION, "template_processor"):
400
- tp = cp.get(self._OUTPUT_SECTION, "template_processor").lower()
401
- if tp not in ("jinja",):
402
- raise ConfigError(f"Invalid template processor name: {tp}")
403
- self.template_processor = tp
404
- if cp.has_option(self._OUTPUT_SECTION, "zip_buffer_mb"):
405
- try:
406
- self.zip_buffer_mb = cp.getint(self._OUTPUT_SECTION, "zip_buffer_mb")
407
- except Exception as e:
408
- raise ConfigError("Invalid argument for zip_buffer_mb.") from e
409
- if cp.has_option(self._INTERFACE_SECTION, "write_warnings"):
410
- try:
411
- self.write_warnings = cp.getboolean(self._INTERFACE_SECTION, "write_warnings")
412
- except Exception as e:
413
- raise ConfigError("Invalid argument to write_warnings.") from e
305
+ self._get_str(cp, self._CONNECT_SECTION, "server", "server", required=True)
306
+ self._get_str(cp, self._CONNECT_SECTION, "db", "db", required=True)
307
+ self._get_int(cp, self._CONNECT_SECTION, "port", "port")
308
+ self._get_str(cp, self._CONNECT_SECTION, "database", "db", required=True)
309
+ self._get_str(cp, self._CONNECT_SECTION, "db_file", "db_file", required=True)
310
+ self._get_str(cp, self._CONNECT_SECTION, "username", "username", required=True)
311
+ self._get_str(cp, self._CONNECT_SECTION, "access_username", "access_username")
312
+ self._get_bool(cp, self._CONNECT_SECTION, "password_prompt", "passwd_prompt")
313
+ self._get_bool(cp, self._CONNECT_SECTION, "use_keyring", "use_keyring")
314
+ self._get_bool(cp, self._CONNECT_SECTION, "new_db", "new_db")
315
+ # --- [encoding] ---
316
+ self._get_str(cp, self._ENCODING_SECTION, "database", "db_encoding")
317
+ self._get_str(cp, self._ENCODING_SECTION, "script", "script_encoding", required=True)
318
+ self._get_str(cp, self._ENCODING_SECTION, "import", "import_encoding", required=True)
319
+ self._get_str(cp, self._ENCODING_SECTION, "output", "output_encoding", required=True)
320
+ self._get_enum(
321
+ cp,
322
+ self._ENCODING_SECTION,
323
+ "error_response",
324
+ "enc_err_disposition",
325
+ ("ignore", "replace", "xmlcharrefreplace", "backslashreplace"),
326
+ )
327
+ # --- [input] ---
328
+ self._get_int(cp, self._INPUT_SECTION, "max_int", "max_int")
329
+ self._get_bool(cp, self._INPUT_SECTION, "boolean_int", "boolean_int")
330
+ self._get_bool(cp, self._INPUT_SECTION, "boolean_words", "boolean_words")
331
+ self._get_bool(cp, self._INPUT_SECTION, "empty_strings", "empty_strings")
332
+ self._get_bool(cp, self._INPUT_SECTION, "only_strings", "only_strings")
333
+ self._get_bool(cp, self._INPUT_SECTION, "empty_rows", "empty_rows")
334
+ self._get_bool(cp, self._INPUT_SECTION, "delete_empty_columns", "del_empty_cols")
335
+ self._get_bool(cp, self._INPUT_SECTION, "create_column_headers", "create_col_hdrs")
336
+ self._get_enum(
337
+ cp,
338
+ self._INPUT_SECTION,
339
+ "trim_column_headers",
340
+ "trim_col_hdrs",
341
+ ("none", "both", "left", "right"),
342
+ )
343
+ self._get_bool(cp, self._INPUT_SECTION, "clean_column_headers", "clean_col_hdrs")
344
+ self._get_enum(
345
+ cp,
346
+ self._INPUT_SECTION,
347
+ "fold_column_headers",
348
+ "fold_col_hdrs",
349
+ ("no", "lower", "upper"),
350
+ )
351
+ self._get_bool(cp, self._INPUT_SECTION, "dedup_column_headers", "dedup_col_hdrs")
352
+ self._get_bool(cp, self._INPUT_SECTION, "trim_strings", "trim_strings")
353
+ self._get_bool(cp, self._INPUT_SECTION, "replace_newlines", "replace_newlines")
354
+ self._get_int(cp, self._INPUT_SECTION, "import_row_buffer", "import_row_buffer")
355
+ self._get_int(cp, self._INPUT_SECTION, "import_progress_interval", "import_progress_interval")
356
+ self._get_bool(cp, self._INPUT_SECTION, "show_progress", "show_progress")
357
+ self._get_bool(cp, self._INPUT_SECTION, "access_use_numeric", "access_use_numeric")
358
+ self._get_bool(cp, self._INPUT_SECTION, "import_only_common_columns", "import_common_cols_only")
359
+ self._get_bool(cp, self._INPUT_SECTION, "import_common_columns_only", "import_common_cols_only")
360
+ self._get_int(cp, self._INPUT_SECTION, "scan_lines", "scan_lines")
361
+ self._get_int(cp, self._INPUT_SECTION, "import_buffer", "import_buffer", multiply=1024)
362
+ # --- [output] ---
363
+ self._get_bool(cp, self._OUTPUT_SECTION, "log_write_messages", "tee_write_log")
364
+ self._get_int(cp, self._OUTPUT_SECTION, "hdf5_text_len", "hdf5_text_len")
365
+ self._get_str(cp, self._OUTPUT_SECTION, "css_file", "css_file", required=True)
366
+ self._get_str(cp, self._OUTPUT_SECTION, "css_styles", "css_styles", required=True)
367
+ self._get_bool(cp, self._OUTPUT_SECTION, "make_export_dirs", "make_export_dirs")
368
+ self._get_bool(cp, self._OUTPUT_SECTION, "quote_all_text", "quote_all_text")
369
+ self._get_int(cp, self._OUTPUT_SECTION, "outfile_open_timeout", "outfile_open_timeout")
370
+ self._get_int(cp, self._OUTPUT_SECTION, "export_row_buffer", "export_row_buffer")
371
+ self._get_enum(
372
+ cp,
373
+ self._OUTPUT_SECTION,
374
+ "template_processor",
375
+ "template_processor",
376
+ ("jinja",),
377
+ )
378
+ self._get_int(cp, self._OUTPUT_SECTION, "zip_buffer_mb", "zip_buffer_mb")
379
+ # --- [interface] ---
380
+ self._get_bool(cp, self._INTERFACE_SECTION, "write_warnings", "write_warnings")
381
+ # write_prefix / write_suffix have special "clear" → None handling
414
382
  if cp.has_option(self._INTERFACE_SECTION, "write_prefix"):
415
383
  try:
416
384
  self.write_prefix = cp.get(self._INTERFACE_SECTION, "write_prefix")
@@ -425,38 +393,23 @@ class ConfigData:
425
393
  raise ConfigError("Invalid or missing argument to write_suffix.") from e
426
394
  if self.write_suffix.lower() == "clear":
427
395
  self.write_suffix = None
396
+ # gui_level is an integer enum — keep inline to preserve exact error message
428
397
  if cp.has_option(self._INTERFACE_SECTION, "gui_level"):
429
398
  self.gui_level = cp.getint(self._INTERFACE_SECTION, "gui_level")
430
399
  if self.gui_level not in (0, 1, 2, 3):
431
400
  raise ConfigError(f"Invalid GUI level: {self.gui_level}")
401
+ # gui_framework has a specific error message — keep inline
432
402
  if cp.has_option(self._INTERFACE_SECTION, "gui_framework"):
433
403
  fw = cp.get(self._INTERFACE_SECTION, "gui_framework").lower()
434
404
  if fw not in ("tkinter", "textual"):
435
405
  raise ConfigError("gui_framework must be 'tkinter' or 'textual'.")
436
406
  self.gui_framework = fw
437
- if cp.has_option(self._INTERFACE_SECTION, "console_height"):
438
- try:
439
- self.gui_console_height = max(5, cp.getint(self._INTERFACE_SECTION, "console_height"))
440
- except Exception as e:
441
- raise ConfigError("Invalid argument for console_height.") from e
442
- if cp.has_option(self._INTERFACE_SECTION, "console_width"):
443
- try:
444
- self.gui_console_width = max(20, cp.getint(self._INTERFACE_SECTION, "console_width"))
445
- except Exception as e:
446
- raise ConfigError("Invalid argument for console_width.") from e
447
- if cp.has_option(self._INTERFACE_SECTION, "console_wait_when_done"):
448
- try:
449
- self.gui_wait_on_exit = cp.getboolean(self._INTERFACE_SECTION, "console_wait_when_done")
450
- except Exception as e:
451
- raise ConfigError("Invalid argument for console_wait_when_done.") from e
452
- if cp.has_option(self._INTERFACE_SECTION, "console_wait_when_error_halt"):
453
- try:
454
- self.gui_wait_on_error_halt = cp.getboolean(
455
- self._INTERFACE_SECTION,
456
- "console_wait_when_error_halt",
457
- )
458
- except Exception as e:
459
- raise ConfigError("Invalid argument for console_wait_when_error_halt.") from e
407
+ self._get_int(cp, self._INTERFACE_SECTION, "console_height", "gui_console_height", min_val=5)
408
+ self._get_int(cp, self._INTERFACE_SECTION, "console_width", "gui_console_width", min_val=20)
409
+ self._get_bool(cp, self._INTERFACE_SECTION, "console_wait_when_done", "gui_wait_on_exit")
410
+ self._get_bool(cp, self._INTERFACE_SECTION, "console_wait_when_error_halt", "gui_wait_on_error_halt")
411
+ # --- [config] ---
412
+ # config_file / OS-specific config files retain special chaining logic
460
413
  if cp.has_option(self._CONFIG_SECTION, "config_file"):
461
414
  conffile = cp.get(self._CONFIG_SECTION, "config_file")
462
415
  if os.name == "posix" and conffile[0] == "~":
@@ -470,9 +423,17 @@ class ConfigData:
470
423
  if Path(conffile).is_file():
471
424
  # Silently ignore a non-existent file, for cross-OS compatibility.
472
425
  config_files.insert(ix + 1, conffile)
473
- if os.name == "posix" and cp.has_option(self._CONFIG_SECTION, "linux_config_file"):
474
- conffile = cp.get(self._CONFIG_SECTION, "linux_config_file")
475
- if conffile[0] == "~":
426
+ # OS-specific additional config files.
427
+ _os_config_key: str | None = None
428
+ if sys.platform == "linux" and cp.has_option(self._CONFIG_SECTION, "linux_config_file"):
429
+ _os_config_key = "linux_config_file"
430
+ elif sys.platform == "darwin" and cp.has_option(self._CONFIG_SECTION, "macos_config_file"):
431
+ _os_config_key = "macos_config_file"
432
+ elif os.name == "nt" and cp.has_option(self._CONFIG_SECTION, "win_config_file"):
433
+ _os_config_key = "win_config_file"
434
+ if _os_config_key:
435
+ conffile = cp.get(self._CONFIG_SECTION, _os_config_key)
436
+ if conffile and conffile[0] == "~":
476
437
  if len(conffile) == 1:
477
438
  conffile = str(Path("~").expanduser())
478
439
  elif len(conffile) > 1 and conffile[1] == os.sep:
@@ -482,67 +443,34 @@ class ConfigData:
482
443
  conffile = str(Path(conffile) / self.config_file_name)
483
444
  if Path(conffile).is_file():
484
445
  config_files.insert(ix + 1, conffile)
485
- if os.name == "windows" and cp.has_option(self._CONFIG_SECTION, "win_config_file"):
486
- conffile = cp.get(self._CONFIG_SECTION, "win_config_file")
487
- conffile = variable_pool.substitute(conffile)[0]
488
- if not Path(conffile).is_file():
489
- conffile = str(Path(conffile) / self.config_file_name)
490
- if Path(conffile).is_file():
491
- config_files.insert(ix + 1, conffile)
492
- if cp.has_option(self._CONFIG_SECTION, "user_logfile"):
493
- self.user_logfile = cp.getboolean(self._CONFIG_SECTION, "user_logfile")
446
+ self._get_bool(cp, self._CONFIG_SECTION, "user_logfile", "user_logfile")
447
+ # dao_flush_delay_secs has a specific error message — keep inline
494
448
  if cp.has_option(self._CONFIG_SECTION, "dao_flush_delay_secs"):
495
449
  self.dao_flush_delay_secs = cp.getfloat(self._CONFIG_SECTION, "dao_flush_delay_secs")
496
450
  if self.dao_flush_delay_secs < 5.0:
497
451
  raise ConfigError(
498
452
  f"Invalid DAO flush delay: {self.dao_flush_delay_secs}; must be >= 5.0.",
499
453
  )
500
- if cp.has_option(self._CONFIG_SECTION, "log_datavars"):
501
- try:
502
- self.log_datavars = cp.getboolean(self._CONFIG_SECTION, "log_datavars")
503
- except Exception as e:
504
- raise ConfigError("Invalid argument to log_datavars setting.") from e
505
- if cp.has_option(self._CONFIG_SECTION, "log_sql"):
506
- try:
507
- self.log_sql = cp.getboolean(self._CONFIG_SECTION, "log_sql")
508
- except Exception as e:
509
- raise ConfigError("Invalid argument to log_sql setting.") from e
510
- if cp.has_option(self._CONFIG_SECTION, "max_log_size_mb"):
511
- try:
512
- self.max_log_size_mb = cp.getint(self._CONFIG_SECTION, "max_log_size_mb")
513
- except Exception as e:
514
- raise ConfigError("Invalid argument to max_log_size_mb setting.") from e
515
- if cp.has_option(self._EMAIL_SECTION, "host"):
516
- self.smtp_host = cp.get(self._EMAIL_SECTION, "host")
517
- if cp.has_option(self._EMAIL_SECTION, "port"):
518
- self.smtp_port = cp.get(self._EMAIL_SECTION, "port")
519
- try:
520
- self.smtp_port = cp.getint(self._EMAIL_SECTION, "port")
521
- except Exception as e:
522
- raise ConfigError("Invalid argument for email port.") from e
523
- if cp.has_option(self._EMAIL_SECTION, "username"):
524
- self.smtp_username = cp.get(self._EMAIL_SECTION, "username")
525
- if cp.has_option(self._EMAIL_SECTION, "password"):
526
- self.smtp_password = cp.get(self._EMAIL_SECTION, "password")
454
+ self._get_bool(cp, self._CONFIG_SECTION, "log_datavars", "log_datavars")
455
+ self._get_bool(cp, self._CONFIG_SECTION, "log_sql", "log_sql")
456
+ self._get_int(cp, self._CONFIG_SECTION, "max_log_size_mb", "max_log_size_mb")
457
+ # --- [email] ---
458
+ self._get_str(cp, self._EMAIL_SECTION, "host", "smtp_host")
459
+ self._get_int(cp, self._EMAIL_SECTION, "port", "smtp_port")
460
+ self._get_str(cp, self._EMAIL_SECTION, "username", "smtp_username")
461
+ self._get_str(cp, self._EMAIL_SECTION, "password", "smtp_password")
462
+ # enc_password has special decryption logic — keep inline
527
463
  if cp.has_option(self._EMAIL_SECTION, "enc_password"):
528
464
  self.smtp_password = Encrypt().decrypt(cp.get(self._EMAIL_SECTION, "enc_password"))
529
- if cp.has_option(self._EMAIL_SECTION, "use_ssl"):
530
- try:
531
- self.smtp_ssl = cp.getboolean(self._EMAIL_SECTION, "use_ssl")
532
- except Exception as e:
533
- raise ConfigError("Invalid argument for email use_ssl.") from e
534
- if cp.has_option(self._EMAIL_SECTION, "use_tls"):
535
- try:
536
- self.smtp_tls = cp.getboolean(self._EMAIL_SECTION, "use_tls")
537
- except Exception as e:
538
- raise ConfigError("Invalid argument for email use_tls.") from e
465
+ self._get_bool(cp, self._EMAIL_SECTION, "use_ssl", "smtp_ssl")
466
+ self._get_bool(cp, self._EMAIL_SECTION, "use_tls", "smtp_tls")
467
+ # email_format has a specific error message — keep inline
539
468
  if cp.has_option(self._EMAIL_SECTION, "email_format"):
540
469
  fmt = cp.get(self._EMAIL_SECTION, "email_format").lower()
541
470
  if fmt not in ("plain", "html"):
542
471
  raise ConfigError(f"Invalid email format: {fmt}")
543
472
  self.email_format = fmt
544
- if cp.has_option(self._EMAIL_SECTION, "message_css"):
545
- self.email_css = cp.get(self._EMAIL_SECTION, "message_css")
473
+ self._get_str(cp, self._EMAIL_SECTION, "message_css", "email_css")
546
474
  if cp.has_section(self._VARIABLES_SECTION) and variable_pool:
547
475
  varsect = cp.items(self._VARIABLES_SECTION)
548
476
  for sub, repl in varsect:
@@ -641,7 +569,7 @@ class WriteHooks:
641
569
 
642
570
  def write_err(self, strval: str) -> None:
643
571
  """Write an error string to the error-output hook, or to sys.stderr if unset."""
644
- if strval[-1] != "\n":
572
+ if not strval.endswith("\n"):
645
573
  strval += "\n"
646
574
  if self.err_func:
647
575
  self.err_func(strval)
execsql/db/base.py CHANGED
@@ -505,7 +505,6 @@ class Database(ABC):
505
505
  and isinstance(line[cno], _state.stringtypes)
506
506
  and len(line[cno].strip()) == 0
507
507
  )
508
- and _state.conf.del_empty_cols
509
508
  ):
510
509
  any_non_empty = True
511
510
  break