execsql2 2.21.1__py3-none-any.whl → 2.22.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 (82) hide show
  1. execsql/api.py +21 -4
  2. execsql/cli/run.py +37 -17
  3. execsql/config.py +37 -22
  4. execsql/data/execsql.conf.template +6 -0
  5. execsql/db/access.py +6 -5
  6. execsql/db/base.py +23 -5
  7. execsql/db/dsn.py +2 -0
  8. execsql/db/duckdb.py +1 -1
  9. execsql/db/mysql.py +6 -8
  10. execsql/db/sqlite.py +1 -1
  11. execsql/exceptions.py +14 -11
  12. execsql/exporters/base.py +1 -1
  13. execsql/exporters/delimited.py +22 -16
  14. execsql/exporters/duckdb.py +6 -1
  15. execsql/exporters/feather.py +2 -2
  16. execsql/exporters/json.py +5 -3
  17. execsql/exporters/latex.py +2 -2
  18. execsql/exporters/markdown.py +1 -0
  19. execsql/exporters/ods.py +7 -3
  20. execsql/exporters/parquet.py +1 -1
  21. execsql/exporters/raw.py +8 -8
  22. execsql/exporters/sqlite.py +11 -1
  23. execsql/exporters/templates.py +2 -1
  24. execsql/exporters/xls.py +10 -10
  25. execsql/exporters/xml.py +1 -0
  26. execsql/exporters/yaml.py +1 -0
  27. execsql/exporters/zip.py +9 -7
  28. execsql/format.py +6 -6
  29. execsql/gui/console.py +3 -3
  30. execsql/gui/desktop.py +28 -10
  31. execsql/gui/tui.py +8 -6
  32. execsql/importers/base.py +14 -8
  33. execsql/importers/csv.py +8 -3
  34. execsql/importers/xls.py +1 -0
  35. execsql/metacommands/conditions.py +14 -14
  36. execsql/metacommands/connect.py +2 -2
  37. execsql/metacommands/control.py +1 -1
  38. execsql/metacommands/data.py +2 -1
  39. execsql/metacommands/io_export.py +4 -0
  40. execsql/metacommands/io_fileops.py +85 -39
  41. execsql/metacommands/io_import.py +1 -0
  42. execsql/metacommands/io_write.py +1 -1
  43. execsql/metacommands/prompt.py +24 -22
  44. execsql/metacommands/system.py +14 -1
  45. execsql/models.py +56 -17
  46. execsql/parser.py +8 -8
  47. execsql/plugins.py +1 -1
  48. execsql/script/engine.py +4 -4
  49. execsql/script/executor.py +67 -28
  50. execsql/script/parser.py +13 -12
  51. execsql/script/variables.py +4 -2
  52. execsql/state.pyi +122 -0
  53. execsql/types.py +31 -26
  54. execsql/utils/auth.py +6 -2
  55. execsql/utils/datetime.py +3 -3
  56. execsql/utils/errors.py +7 -5
  57. execsql/utils/fileio.py +77 -31
  58. execsql/utils/gui.py +15 -11
  59. execsql/utils/mail.py +9 -2
  60. execsql/utils/numeric.py +1 -1
  61. execsql/utils/regex.py +9 -9
  62. {execsql2-2.21.1.dist-info → execsql2-2.22.0.dist-info}/METADATA +3 -3
  63. execsql2-2.22.0.dist-info/RECORD +123 -0
  64. execsql2-2.21.1.dist-info/RECORD +0 -122
  65. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/README.md +0 -0
  66. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  67. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  68. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  69. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  70. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  71. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  72. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  73. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  74. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  75. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/script_template.sql +0 -0
  76. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  77. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  78. {execsql2-2.21.1.data → execsql2-2.22.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  79. {execsql2-2.21.1.dist-info → execsql2-2.22.0.dist-info}/WHEEL +0 -0
  80. {execsql2-2.21.1.dist-info → execsql2-2.22.0.dist-info}/entry_points.txt +0 -0
  81. {execsql2-2.21.1.dist-info → execsql2-2.22.0.dist-info}/licenses/LICENSE.txt +0 -0
  82. {execsql2-2.21.1.dist-info → execsql2-2.22.0.dist-info}/licenses/NOTICE +0 -0
execsql/api.py CHANGED
@@ -35,7 +35,7 @@ import io
35
35
  import os
36
36
  import time
37
37
  from pathlib import Path
38
- from typing import Any
38
+ from typing import Any, cast
39
39
 
40
40
  from execsql.cli.dsn import _parse_connection_string
41
41
  from execsql.config import ConfigData, StatObj, WriteHooks
@@ -170,6 +170,7 @@ class _LibraryConfig:
170
170
  self.log_datavars = False
171
171
  self.show_progress = False
172
172
  self.max_log_size_mb = 0
173
+ self.system_cmd_timeout = 0.0
173
174
 
174
175
  # Data handling
175
176
  self.boolean_int = True
@@ -334,6 +335,8 @@ def run(
334
335
  halt_on_error: bool = True,
335
336
  new_db: bool = False,
336
337
  allow_system_cmd: bool = True,
338
+ allow_rm_file: bool = True,
339
+ allow_serve: bool = True,
337
340
  ) -> ScriptResult:
338
341
  """Execute a SQL script and return the result.
339
342
 
@@ -359,6 +362,13 @@ def run(
359
362
  disabled and will raise an error if encountered. Matches the
360
363
  ``--no-system-cmd`` CLI flag and the ``allow_system_cmd``
361
364
  config option.
365
+ allow_rm_file: If ``False``, the RM_FILE metacommand is disabled
366
+ and will raise an error if encountered. Matches the
367
+ ``--no-rm-file`` CLI flag and the ``allow_rm_file`` config
368
+ option.
369
+ allow_serve: If ``False``, the SERVE metacommand is disabled and
370
+ will raise an error if encountered. Matches the ``--no-serve``
371
+ CLI flag and the ``allow_serve`` config option.
362
372
 
363
373
  Returns:
364
374
  A :class:`ScriptResult` with execution outcome, timing, errors,
@@ -391,6 +401,7 @@ def run(
391
401
  if script is not None:
392
402
  tree = parse_script(str(script), encoding=encoding)
393
403
  else:
404
+ assert sql is not None
394
405
  tree = parse_string(sql, source_name="<inline>")
395
406
  except ErrInfo as exc:
396
407
  return ScriptResult(
@@ -408,6 +419,7 @@ def run(
408
419
 
409
420
  # Configuration
410
421
  conf_overrides: dict[str, Any] = {"script_encoding": encoding}
422
+ conf: Any
411
423
  if config_file is not None:
412
424
  # Load a real ConfigData with the explicit config file
413
425
  from execsql.script.variables import SubVarSet
@@ -457,6 +469,8 @@ def run(
457
469
  ctx.status = StatObj()
458
470
  ctx.status.halt_on_err = halt_on_error
459
471
  conf.allow_system_cmd = allow_system_cmd
472
+ conf.allow_rm_file = allow_rm_file
473
+ conf.allow_serve = allow_serve
460
474
  ctx.conf = conf
461
475
 
462
476
  # Capture output to a buffer (suppress stdout/stderr)
@@ -465,7 +479,7 @@ def run(
465
479
  ctx.output = WriteHooks(stdout_buf.write, stderr_buf.write)
466
480
 
467
481
  # No log file for library use
468
- ctx.exec_log = _NoOpLogger()
482
+ ctx.exec_log = cast(Any, _NoOpLogger())
469
483
 
470
484
  with active_context(ctx):
471
485
  # Initialize singletons (CounterVars, Timer, DatabasePool, BatchLevels, etc.)
@@ -482,6 +496,8 @@ def run(
482
496
  else:
483
497
  db = connection
484
498
 
499
+ assert db is not None
500
+ assert ctx.dbs is not None
485
501
  ctx.dbs.add("initial", db)
486
502
  ctx.subvars.add_substitution("$CURRENT_DBMS", db.type.dbms_id)
487
503
  ctx.subvars.add_substitution("$CURRENT_DATABASE", db.name())
@@ -530,6 +546,7 @@ def run(
530
546
  # Close connection if we own it
531
547
  if owns_connection:
532
548
  try:
549
+ assert ctx.dbs is not None
533
550
  ctx.dbs.closeall()
534
551
  except Exception:
535
552
  pass
@@ -572,7 +589,7 @@ def _last_source(ctx: RuntimeContext) -> str:
572
589
  """Get the source file from the last executed command."""
573
590
  lc = ctx.last_command
574
591
  if lc is not None and hasattr(lc, "source"):
575
- return lc.source
592
+ return cast(str, lc.source)
576
593
  return "<unknown>"
577
594
 
578
595
 
@@ -580,7 +597,7 @@ def _last_line(ctx: RuntimeContext) -> int | None:
580
597
  """Get the line number from the last executed command."""
581
598
  lc = ctx.last_command
582
599
  if lc is not None and hasattr(lc, "line_no"):
583
- return lc.line_no
600
+ return cast(int | None, lc.line_no)
584
601
  return None
585
602
 
586
603
 
execsql/cli/run.py CHANGED
@@ -7,15 +7,15 @@ and drives the main execution loop. Separated from argument parsing
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import atexit
11
- from typing import Any
12
10
  import datetime
11
+ import atexit
13
12
  import getpass
14
13
  import os
15
14
  import platform
16
15
  import sys
17
16
  import traceback
18
17
  from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
19
 
20
20
  from execsql import __version__
21
21
  from execsql.cli.dsn import _parse_connection_string
@@ -26,6 +26,9 @@ from execsql.script import SubVarSet, current_script_line, substitute_vars
26
26
  from execsql.utils.fileio import FileWriter, Logger, filewriter_end
27
27
  from execsql.utils.gui import gui_connect, gui_console_isrunning, gui_console_off, gui_console_on, gui_console_wait_user
28
28
 
29
+ if TYPE_CHECKING:
30
+ from execsql.script.ast import Script
31
+
29
32
  __all__ = ["_connect_initial_db", "_ping_db", "_print_dry_run", "_print_profile", "_run"]
30
33
 
31
34
  # Lint helper — imported lazily inside _run() to keep start-up cost low, but
@@ -38,7 +41,7 @@ from execsql.cli.lint import _print_lint_results # noqa: F401 — re-export (us
38
41
  # ---------------------------------------------------------------------------
39
42
 
40
43
 
41
- def _print_dry_run(tree: object) -> None:
44
+ def _print_dry_run(tree: Script | None) -> None:
42
45
  """Print the parsed AST for --dry-run mode.
43
46
 
44
47
  Walks the AST tree and prints each SQL statement and metacommand with
@@ -304,7 +307,7 @@ def _load_script(
304
307
  command: str | None,
305
308
  script_name: str | None,
306
309
  encoding: str,
307
- ) -> Any:
310
+ ) -> Script | None:
308
311
  """Parse the SQL script (or inline command) into an AST.
309
312
 
310
313
  Returns the AST tree, or ``None`` if no script is provided.
@@ -316,6 +319,8 @@ def _load_script(
316
319
  command.replace("\\n", "\n").replace("\\t", "\t"),
317
320
  "<inline>",
318
321
  )
322
+ if script_name is None:
323
+ return None
319
324
  return parse_script(script_name, encoding=encoding)
320
325
 
321
326
 
@@ -515,7 +520,7 @@ def _setup_logging(
515
520
  }
516
521
  logger = Logger(
517
522
  script_name or "<inline>",
518
- conf.db,
523
+ conf.db or "",
519
524
  conf.server,
520
525
  opts_dict,
521
526
  conf.user_logfile,
@@ -537,6 +542,7 @@ def _setup_logging(
537
542
  for n, repl in enumerate(sub_vars):
538
543
  var = f"$ARG_{n + 1}"
539
544
  subvars.add_substitution(var, repl)
545
+ logger.add_redaction_value(repl)
540
546
  # `-a` values are positional and opaque (no name to denylist
541
547
  # against). High-entropy secrets (sk-live-*, AKIA*, ghp_*,
542
548
  # JWTs) pass any substring/value heuristic, so log the
@@ -898,8 +904,9 @@ def _execute_script_textual_console(tree: Any, conf: ConfigData) -> None:
898
904
  dialog_queue = _ConsoleDialogQueue()
899
905
  _state.gui_manager_queue = dialog_queue
900
906
 
907
+ ctx = _state.get_context()
901
908
  app = ConsoleApp(
902
- script_runner=lambda: execute(tree),
909
+ script_runner=lambda: execute(tree, ctx=ctx),
903
910
  dialog_queue=dialog_queue,
904
911
  wait_on_exit=conf.gui_wait_on_exit,
905
912
  )
@@ -945,6 +952,16 @@ def _execute_script_textual_console(tree: Any, conf: ConfigData) -> None:
945
952
  # ---------------------------------------------------------------------------
946
953
 
947
954
 
955
+ def _required_connection_value(value: str | None, description: str) -> str:
956
+ """Return a required connection value or stop with the standard fatal path."""
957
+ if value is None:
958
+ from execsql.utils.errors import fatal_error
959
+
960
+ fatal_error(f"Configured database requires {description}, but none is provided.")
961
+ raise SystemExit(1)
962
+ return value
963
+
964
+
948
965
  def _connect_initial_db(conf: ConfigData):
949
966
  """Create and return the initial database object based on conf.db_type."""
950
967
  from execsql.db.factory import (
@@ -964,6 +981,7 @@ def _connect_initial_db(conf: ConfigData):
964
981
  from execsql.utils.errors import fatal_error
965
982
 
966
983
  fatal_error("Configured to run with MS-Access, but no Access file name is provided.")
984
+ raise SystemExit(1)
967
985
  return db_Access(
968
986
  conf.db_file,
969
987
  pw_needed=conf.passwd_prompt and conf.access_username is not None,
@@ -972,8 +990,8 @@ def _connect_initial_db(conf: ConfigData):
972
990
  )
973
991
  elif conf.db_type == "p":
974
992
  return db_Postgres(
975
- conf.server,
976
- conf.db,
993
+ _required_connection_value(conf.server, "a server name"),
994
+ _required_connection_value(conf.db, "a database name"),
977
995
  user=conf.username,
978
996
  pw_needed=conf.passwd_prompt,
979
997
  port=conf.port,
@@ -983,8 +1001,8 @@ def _connect_initial_db(conf: ConfigData):
983
1001
  )
984
1002
  elif conf.db_type == "s":
985
1003
  return db_SqlServer(
986
- conf.server,
987
- conf.db,
1004
+ _required_connection_value(conf.server, "a server name"),
1005
+ _required_connection_value(conf.db, "a database name"),
988
1006
  user=conf.username,
989
1007
  pw_needed=conf.passwd_prompt,
990
1008
  port=conf.port,
@@ -996,11 +1014,12 @@ def _connect_initial_db(conf: ConfigData):
996
1014
  from execsql.utils.errors import fatal_error
997
1015
 
998
1016
  fatal_error("Configured to run with SQLite, but no SQLite file name is provided.")
1017
+ raise SystemExit(1)
999
1018
  return db_SQLite(conf.db_file, new_db=conf.new_db, encoding=conf.db_encoding)
1000
1019
  elif conf.db_type == "m":
1001
1020
  return db_MySQL(
1002
- conf.server,
1003
- conf.db,
1021
+ _required_connection_value(conf.server, "a server name"),
1022
+ _required_connection_value(conf.db, "a database name"),
1004
1023
  user=conf.username,
1005
1024
  pw_needed=conf.passwd_prompt,
1006
1025
  port=conf.port,
@@ -1012,11 +1031,12 @@ def _connect_initial_db(conf: ConfigData):
1012
1031
  from execsql.utils.errors import fatal_error
1013
1032
 
1014
1033
  fatal_error("Configured to run with DuckDB, but no DuckDB file name is provided.")
1034
+ raise SystemExit(1)
1015
1035
  return db_DuckDB(conf.db_file, new_db=conf.new_db, encoding=conf.db_encoding)
1016
1036
  elif conf.db_type == "o":
1017
1037
  return db_Oracle(
1018
- conf.server,
1019
- conf.db,
1038
+ _required_connection_value(conf.server, "a server name"),
1039
+ _required_connection_value(conf.db, "a database name"),
1020
1040
  user=conf.username,
1021
1041
  pw_needed=conf.passwd_prompt,
1022
1042
  port=conf.port,
@@ -1025,8 +1045,8 @@ def _connect_initial_db(conf: ConfigData):
1025
1045
  )
1026
1046
  elif conf.db_type == "f":
1027
1047
  return db_Firebird(
1028
- conf.server,
1029
- conf.db,
1048
+ _required_connection_value(conf.server, "a server name"),
1049
+ _required_connection_value(conf.db, "a database name"),
1030
1050
  user=conf.username,
1031
1051
  pw_needed=conf.passwd_prompt,
1032
1052
  port=conf.port,
@@ -1035,7 +1055,7 @@ def _connect_initial_db(conf: ConfigData):
1035
1055
  )
1036
1056
  elif conf.db_type == "d":
1037
1057
  return db_Dsn(
1038
- conf.db,
1058
+ _required_connection_value(conf.db, "a DSN name"),
1039
1059
  user=conf.username,
1040
1060
  pw_needed=conf.passwd_prompt,
1041
1061
  encoding=conf.db_encoding,
execsql/config.py CHANGED
@@ -17,8 +17,10 @@ Provides three classes:
17
17
 
18
18
  import os
19
19
  import sys
20
+ from collections.abc import Callable
20
21
  from configparser import ConfigParser
21
22
  from pathlib import Path
23
+ from typing import Protocol
22
24
 
23
25
  from execsql.exceptions import ConfigError
24
26
  from execsql.utils.crypto import Encrypt
@@ -30,6 +32,12 @@ __all__ = [
30
32
  ]
31
33
 
32
34
 
35
+ class _VariablePool(Protocol):
36
+ def substitute(self, strval: str) -> tuple[str, int]: ...
37
+ def var_name_ok(self, varname: str) -> bool: ...
38
+ def add_substitution(self, varname: str, strval: object) -> None: ...
39
+
40
+
33
41
  class StatObj:
34
42
  """Lightweight container for runtime status flags used by the metacommand engine.
35
43
 
@@ -315,6 +323,7 @@ class ConfigData:
315
323
  self._get_bool(cp, self._CONFIG_SECTION, "log_sql", "log_sql")
316
324
  self._get_int(cp, self._CONFIG_SECTION, "max_log_size_mb", "max_log_size_mb")
317
325
  self._get_bool(cp, self._CONFIG_SECTION, "allow_system_cmd", "allow_system_cmd")
326
+ self._get_float(cp, self._CONFIG_SECTION, "system_cmd_timeout", "system_cmd_timeout", min_val=0.0)
318
327
  self._get_bool(cp, self._CONFIG_SECTION, "allow_rm_file", "allow_rm_file")
319
328
  self._get_bool(cp, self._CONFIG_SECTION, "allow_serve", "allow_serve")
320
329
  self._get_str(cp, self._CONFIG_SECTION, "include_root", "include_root")
@@ -344,7 +353,7 @@ class ConfigData:
344
353
  def __init__(
345
354
  self,
346
355
  script_path: str,
347
- variable_pool: object,
356
+ variable_pool: _VariablePool,
348
357
  *,
349
358
  config_file: str | None = None,
350
359
  ) -> None:
@@ -361,22 +370,23 @@ class ConfigData:
361
370
  precedence over system, user, script, and working-directory
362
371
  config files.
363
372
  """
364
- self.db_type = "l"
365
- self.server = None
366
- self.port = None
367
- self.db = None
368
- self.username = None
369
- self.access_username = None
373
+ self.db_type: str | None = "l"
374
+ self.server: str | None = None
375
+ self.port: int | None = None
376
+ self.db: str | None = None
377
+ self.username: str | None = None
378
+ self.access_username: str | None = None
379
+ self.db_password: str | None = None
370
380
  self.passwd_prompt = True
371
381
  self.use_keyring = True
372
- self.db_file = None
382
+ self.db_file: str | None = None
373
383
  self.new_db = False
374
384
  self.user_logfile = False
375
- self.db_encoding = None
385
+ self.db_encoding: str | None = None
376
386
  self.script_encoding = "utf8"
377
387
  self.output_encoding = "utf8"
378
388
  self.import_encoding = "utf8"
379
- self.enc_err_disposition = None
389
+ self.enc_err_disposition: str | None = None
380
390
  self.import_common_cols_only = False
381
391
  self.max_int = 2147483647
382
392
  self.boolean_int = True
@@ -396,17 +406,17 @@ class ConfigData:
396
406
  self.scan_lines = 100
397
407
  self.hdf5_text_len = 1000
398
408
  self.write_warnings = False
399
- self.write_prefix = None
400
- self.write_suffix = None
401
- self.gui_level = 0
409
+ self.write_prefix: str | None = None
410
+ self.write_suffix: str | None = None
411
+ self.gui_level: int = 0
402
412
  self.gui_framework = "tkinter"
403
413
  self.gui_wait_on_exit = False
404
414
  self.gui_wait_on_error_halt = False
405
415
  self.gui_console_height = 25
406
416
  self.gui_console_width = 100
407
417
  self.import_buffer = 32 * 1024
408
- self.css_file = None
409
- self.css_styles = None
418
+ self.css_file: str | None = None
419
+ self.css_styles: str | None = None
410
420
  self.make_export_dirs = False
411
421
  self.outfile_open_timeout = 600
412
422
  self.quote_all_text = False
@@ -414,7 +424,7 @@ class ConfigData:
414
424
  self.import_progress_interval = 0
415
425
  self.show_progress = False
416
426
  self.export_row_buffer = 1000
417
- self.template_processor = None
427
+ self.template_processor: str | None = None
418
428
  self.tee_write_log = False
419
429
  self.log_datavars = True
420
430
  self.log_sql = False
@@ -432,6 +442,7 @@ class ConfigData:
432
442
  self.export_output_dir: str | None = None
433
443
  self.dao_flush_delay_secs = 5.0
434
444
  self.allow_system_cmd = True
445
+ self.system_cmd_timeout = 0.0
435
446
  # B05: path-containment controls. ``*_root`` keys, when set, force
436
447
  # the corresponding handler to confine resolved paths under the
437
448
  # named root and reject anything that escapes via ``..``,
@@ -618,9 +629,9 @@ class WriteHooks:
618
629
 
619
630
  def __init__(
620
631
  self,
621
- standard_output_func: object = None,
622
- error_output_func: object = None,
623
- status_output_func: object = None,
632
+ standard_output_func: Callable[[str], object] | None = None,
633
+ error_output_func: Callable[[str], object] | None = None,
634
+ status_output_func: Callable[[str], object] | None = None,
624
635
  ) -> None:
625
636
  """Store optional hook callables; ``None`` means use the default stream.
626
637
 
@@ -648,16 +659,20 @@ class WriteHooks:
648
659
  self.write_func = None
649
660
  self.err_func = None
650
661
 
651
- def redir_stdout(self, standard_output_func: object) -> None:
662
+ def redir_stdout(self, standard_output_func: Callable[[str], object]) -> None:
652
663
  """Replace the standard-output hook with the given callable."""
653
664
  self.write_func = standard_output_func
654
665
 
655
- def redir_stderr(self, error_output_func: object, tee: bool = True) -> None:
666
+ def redir_stderr(self, error_output_func: Callable[[str], object], tee: bool = True) -> None:
656
667
  """Replace the error-output hook and optionally keep tee-to-stderr behaviour."""
657
668
  self.err_func = error_output_func
658
669
  self.tee_stderr = tee
659
670
 
660
- def redir(self, standard_output_func: object, error_output_func: object) -> None:
671
+ def redir(
672
+ self,
673
+ standard_output_func: Callable[[str], object],
674
+ error_output_func: Callable[[str], object],
675
+ ) -> None:
661
676
  """Redirect both stdout and stderr hooks in one call."""
662
677
  self.redir_stdout(standard_output_func)
663
678
  self.redir_stderr(error_output_func)
@@ -279,6 +279,12 @@
279
279
  # Values: Yes or No. Default: Yes.
280
280
  #allow_system_cmd=Yes
281
281
 
282
+ # Maximum seconds a foreground SYSTEM_CMD may run before execsql stops it.
283
+ # Set to 0 for no timeout. SYSTEM_CMD ... CONTINUE is detached and is not
284
+ # timeout-managed by this setting.
285
+ # Value: A non-negative number of seconds. Default: 0.
286
+ #system_cmd_timeout=0
287
+
282
288
  # Whether to allow the RM_FILE metacommand (which deletes a file).
283
289
  # Set to No to prevent scripts from deleting files.
284
290
  # Also controllable via --no-rm-file CLI flag.
execsql/db/access.py CHANGED
@@ -12,7 +12,7 @@ import datetime
12
12
  import re
13
13
  import time
14
14
  from pathlib import Path
15
- from typing import Any
15
+ from typing import Any, cast
16
16
 
17
17
  from execsql.db.base import Database
18
18
  from execsql.exceptions import ErrInfo
@@ -69,7 +69,7 @@ class AccessDatabase(Database):
69
69
  # Encoding is only applicable to Jet < 4.0: non-accdb databases.
70
70
  self.encoding = encoding or "windows-1252"
71
71
  self.encode_commands = True
72
- self.dao_conn = None
72
+ self.dao_conn: Any = None
73
73
  self.conn = None # ODBC connection
74
74
  self.paramstr = "?"
75
75
  self.dt_cast = dict(self.dt_cast) # Copy the lazy-initialized default before overriding.
@@ -253,6 +253,7 @@ class AccessDatabase(Database):
253
253
  else:
254
254
  self.dao_flush_check()
255
255
  with self._cursor() as curs:
256
+ encoded_sql: Any
256
257
  if self.jet4:
257
258
  encoded_sql = str(sql)
258
259
  else:
@@ -398,7 +399,7 @@ class AccessDatabase(Database):
398
399
  tablename = self.type.quoted(tablename)
399
400
  self.execute(f"drop table {tablename};")
400
401
 
401
- def as_datetime(self, val: Any) -> datetime.datetime | None:
402
+ def as_datetime(self, val: Any) -> datetime.datetime | datetime.date | datetime.time | None:
402
403
  """Convert a value to a datetime object suitable for Access."""
403
404
  from execsql.types import DT_Timestamp, DT_Date, DT_Time, DataTypeError
404
405
 
@@ -414,7 +415,7 @@ class AccessDatabase(Database):
414
415
  v = DT_Date().from_data(val)
415
416
  except DataTypeError:
416
417
  # If this generates an exception, let it go up to get caught.
417
- v = DT_Time().from_data(val)
418
+ v = cast(datetime.time, DT_Time().from_data(val))
418
419
  n = datetime.datetime.now()
419
420
  v = datetime.datetime(
420
421
  n.year,
@@ -427,7 +428,7 @@ class AccessDatabase(Database):
427
428
  )
428
429
  except Exception:
429
430
  raise
430
- return v
431
+ return cast(datetime.datetime, v)
431
432
 
432
433
  def int_or_bool(self, val: Any) -> int | None:
433
434
  """Convert a value to an integer, recognizing Access boolean values."""
execsql/db/base.py CHANGED
@@ -67,6 +67,19 @@ class Database(ABC):
67
67
  ``_state.dbs.current()`` rather than constructing adapters directly.
68
68
  """
69
69
 
70
+ type: Any
71
+ server_name: Any
72
+ db_name: Any
73
+ user: Any
74
+ need_passwd: bool | None
75
+ password: str | None
76
+ port: int | None
77
+ encoding: Any
78
+ encode_commands: bool
79
+ paramstr: str
80
+ conn: Any
81
+ autocommit: bool
82
+
70
83
  _dt_cast: dict[type, Callable] | None = None
71
84
 
72
85
  @property
@@ -90,7 +103,7 @@ class Database(ABC):
90
103
  encoding: str | None = None,
91
104
  ) -> None:
92
105
  """Initialize common connection attributes for a database backend."""
93
- self.type = None
106
+ self.type: Any = None
94
107
  self.server_name = server_name
95
108
  self.db_name = db_name
96
109
  self.user = user_name
@@ -697,12 +710,14 @@ class Database(ABC):
697
710
  )
698
711
  import_cols = csv_cols
699
712
 
713
+ tablespec_cache: Any = None
714
+
700
715
  def get_ts() -> Any:
701
- if not get_ts.tablespec:
702
- get_ts.tablespec = csv_file_obj.data_table_def()
703
- return get_ts.tablespec
716
+ nonlocal tablespec_cache
717
+ if not tablespec_cache:
718
+ tablespec_cache = csv_file_obj.data_table_def()
719
+ return tablespec_cache
704
720
 
705
- get_ts.tablespec = None
706
721
  f = csv_file_obj.reader()
707
722
  next(f)
708
723
  self.populate_table(schema_name, table_name, f, import_cols, get_ts)
@@ -777,14 +792,17 @@ class DatabasePool:
777
792
 
778
793
  def current(self) -> Database:
779
794
  """Return the currently active ``Database`` object."""
795
+ assert self.current_db is not None
780
796
  return self.pool[self.current_db]
781
797
 
782
798
  def current_alias(self) -> str:
783
799
  """Return the alias string for the currently active database."""
800
+ assert self.current_db is not None
784
801
  return self.current_db
785
802
 
786
803
  def initial(self) -> Database:
787
804
  """Return the first ``Database`` that was added to the pool."""
805
+ assert self.initial_db is not None
788
806
  return self.pool[self.initial_db]
789
807
 
790
808
  def aliased_as(self, db_alias: str) -> Database:
execsql/db/dsn.py CHANGED
@@ -86,6 +86,8 @@ class DsnDatabase(Database):
86
86
  # supplied credentials cannot inject additional attributes.
87
87
  parts = [f"DSN={_odbc_quote(self.db_name)}"]
88
88
  if self.need_passwd:
89
+ assert self.user is not None
90
+ assert self.password is not None
89
91
  parts.append(f"UID={_odbc_quote(self.user)}")
90
92
  parts.append(f"PWD={_odbc_quote(self.password)}")
91
93
  connstr = ";".join(parts) + ";"
execsql/db/duckdb.py CHANGED
@@ -73,7 +73,7 @@ class DuckDBDatabase(Database):
73
73
  self.rollback()
74
74
  raise
75
75
 
76
- def view_exists(self, view_name: str) -> bool:
76
+ def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
77
77
  """Return True if the named view exists in the DuckDB database."""
78
78
  # DuckDB information_schema has no 'views' table; views are listed in 'tables'
79
79
  return self.table_exists(view_name)
execsql/db/mysql.py CHANGED
@@ -8,7 +8,7 @@ servers via ``pymysql``. Corresponds to ``-t m`` on the CLI.
8
8
  """
9
9
 
10
10
  import re
11
- from typing import Any
11
+ from typing import Any, cast
12
12
 
13
13
  from execsql.db.base import Database
14
14
  from execsql.exceptions import ErrInfo
@@ -105,7 +105,7 @@ class MySQLDatabase(Database):
105
105
  """
106
106
  cached = getattr(self, "_cached_lctn", None)
107
107
  if cached is not None:
108
- return cached
108
+ return cast(int, cached)
109
109
  try:
110
110
  _, rows = self.select_data("SELECT @@lower_case_table_names;")
111
111
  value = int(rows[0][0]) if rows else 0
@@ -114,10 +114,8 @@ class MySQLDatabase(Database):
114
114
  self._cached_lctn = value
115
115
  return value
116
116
 
117
- def _fold_identifier(self, name: str | None) -> str | None:
117
+ def _fold_identifier(self, name: str) -> str:
118
118
  """Lowercase *name* when the server is case-insensitive (LCTN 1/2)."""
119
- if name is None:
120
- return None
121
119
  return name.lower() if self._lower_case_table_names() in (1, 2) else name
122
120
 
123
121
  # NB: schema_exists is overridden below to return False unconditionally
@@ -127,7 +125,7 @@ class MySQLDatabase(Database):
127
125
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
128
126
  return super().table_exists(
129
127
  self._fold_identifier(table_name),
130
- self._fold_identifier(schema_name),
128
+ self._fold_identifier(schema_name) if schema_name is not None else None,
131
129
  )
132
130
 
133
131
  def column_exists(
@@ -139,13 +137,13 @@ class MySQLDatabase(Database):
139
137
  return super().column_exists(
140
138
  self._fold_identifier(table_name),
141
139
  self._fold_identifier(column_name),
142
- self._fold_identifier(schema_name),
140
+ self._fold_identifier(schema_name) if schema_name is not None else None,
143
141
  )
144
142
 
145
143
  def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
146
144
  return super().view_exists(
147
145
  self._fold_identifier(view_name),
148
- self._fold_identifier(schema_name),
146
+ self._fold_identifier(schema_name) if schema_name is not None else None,
149
147
  )
150
148
 
151
149
  def open_db(self) -> None:
execsql/db/sqlite.py CHANGED
@@ -130,7 +130,7 @@ class SQLiteDatabase(Database):
130
130
  ) from e
131
131
  return [d[0] for d in curs.description]
132
132
 
133
- def view_exists(self, view_name: str) -> bool:
133
+ def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
134
134
  """Return True if the named view exists in the SQLite database."""
135
135
  # Match case-insensitively — see table_exists().
136
136
  with self._cursor() as curs: