execsql2 2.1.2__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. execsql/cli/__init__.py +436 -0
  2. execsql/cli/dsn.py +86 -0
  3. execsql/cli/help.py +140 -0
  4. execsql/{cli.py → cli/run.py} +14 -589
  5. execsql/config.py +65 -1
  6. execsql/db/access.py +27 -15
  7. execsql/db/base.py +328 -215
  8. execsql/db/dsn.py +10 -5
  9. execsql/db/duckdb.py +6 -2
  10. execsql/db/factory.py +21 -0
  11. execsql/db/firebird.py +27 -19
  12. execsql/db/mysql.py +12 -7
  13. execsql/db/oracle.py +15 -11
  14. execsql/db/postgres.py +31 -16
  15. execsql/db/sqlite.py +15 -11
  16. execsql/db/sqlserver.py +16 -5
  17. execsql/exceptions.py +25 -7
  18. execsql/exporters/base.py +12 -1
  19. execsql/exporters/delimited.py +80 -35
  20. execsql/exporters/duckdb.py +6 -2
  21. execsql/exporters/feather.py +10 -6
  22. execsql/exporters/html.py +89 -69
  23. execsql/exporters/json.py +52 -45
  24. execsql/exporters/latex.py +37 -27
  25. execsql/exporters/ods.py +32 -11
  26. execsql/exporters/parquet.py +5 -2
  27. execsql/exporters/pretty.py +16 -9
  28. execsql/exporters/raw.py +22 -16
  29. execsql/exporters/sqlite.py +6 -2
  30. execsql/exporters/templates.py +39 -21
  31. execsql/exporters/values.py +26 -20
  32. execsql/exporters/xls.py +30 -11
  33. execsql/exporters/xml.py +31 -13
  34. execsql/exporters/zip.py +15 -0
  35. execsql/importers/base.py +6 -4
  36. execsql/importers/csv.py +8 -6
  37. execsql/importers/feather.py +6 -4
  38. execsql/importers/ods.py +6 -4
  39. execsql/importers/xls.py +6 -4
  40. execsql/metacommands/__init__.py +208 -1548
  41. execsql/metacommands/conditions.py +101 -27
  42. execsql/metacommands/control.py +8 -4
  43. execsql/metacommands/data.py +6 -6
  44. execsql/metacommands/debug.py +6 -2
  45. execsql/metacommands/dispatch.py +2011 -0
  46. execsql/metacommands/io.py +67 -1310
  47. execsql/metacommands/io_export.py +442 -0
  48. execsql/metacommands/io_fileops.py +287 -0
  49. execsql/metacommands/io_import.py +398 -0
  50. execsql/metacommands/io_write.py +248 -0
  51. execsql/metacommands/prompt.py +22 -66
  52. execsql/metacommands/system.py +7 -2
  53. execsql/models.py +7 -0
  54. execsql/parser.py +10 -0
  55. execsql/py.typed +0 -0
  56. execsql/script/__init__.py +95 -0
  57. execsql/script/control.py +162 -0
  58. execsql/{script.py → script/engine.py} +184 -402
  59. execsql/script/variables.py +281 -0
  60. execsql/types.py +49 -20
  61. execsql/utils/auth.py +2 -0
  62. execsql/utils/crypto.py +4 -6
  63. execsql/utils/datetime.py +1 -0
  64. execsql/utils/errors.py +11 -0
  65. execsql/utils/fileio.py +33 -8
  66. execsql/utils/gui.py +46 -0
  67. execsql/utils/mail.py +7 -17
  68. execsql/utils/numeric.py +2 -0
  69. execsql/utils/regex.py +9 -0
  70. execsql/utils/strings.py +16 -0
  71. execsql/utils/timer.py +2 -0
  72. execsql2-2.4.0.data/data/execsql2_extras/README.md +65 -0
  73. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/execsql.conf +1 -1
  74. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/METADATA +13 -6
  75. execsql2-2.4.0.dist-info/RECORD +108 -0
  76. execsql2-2.1.2.data/data/execsql2_extras/READ_ME.rst +0 -127
  77. execsql2-2.1.2.dist-info/RECORD +0 -96
  78. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  79. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  80. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  81. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  82. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  83. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  84. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  85. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  86. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  87. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/script_template.sql +0 -0
  88. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  89. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  90. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  91. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/WHEEL +0 -0
  92. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/entry_points.txt +0 -0
  93. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/LICENSE.txt +0 -0
  94. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/NOTICE +0 -0
execsql/config.py CHANGED
@@ -23,12 +23,25 @@ from pathlib import Path
23
23
  from execsql.exceptions import ConfigError
24
24
  from execsql.utils.crypto import Encrypt
25
25
 
26
+ __all__ = [
27
+ "StatObj",
28
+ "ConfigData",
29
+ "WriteHooks",
30
+ ]
31
+
26
32
 
27
33
  class StatObj:
34
+ """Lightweight container for runtime status flags used by the metacommand engine.
35
+
36
+ Tracks error conditions, halt-on-error policy, dialog cancellation state,
37
+ and the current batch nesting level.
38
+ """
39
+
28
40
  # A generic object to maintain status indicators. These status
29
41
  # indicators are primarily those used in the metacommand
30
42
  # environment rather than for the program as a whole.
31
43
  def __init__(self) -> None:
44
+ """Initialise all status flags to their default (non-error) values."""
32
45
  self.halt_on_err = True
33
46
  self.sql_error = False
34
47
  self.halt_on_metacommand_err = True
@@ -42,6 +55,13 @@ class StatObj:
42
55
 
43
56
 
44
57
  class ConfigData:
58
+ """Reads and merges ``execsql.conf`` INI files, exposing all options as attributes.
59
+
60
+ Searches system, user, script-directory, and working-directory locations
61
+ (in that order) and applies each file's settings cumulatively so that
62
+ later files override earlier ones.
63
+ """
64
+
45
65
  config_file_name = "execsql.conf"
46
66
  _CONNECT_SECTION = "connect"
47
67
  _ENCODING_SECTION = "encoding"
@@ -55,6 +75,15 @@ class ConfigData:
55
75
  _INCLUDE_OPT_SECTION = "include_optional"
56
76
 
57
77
  def __init__(self, script_path: str, variable_pool: object) -> None:
78
+ """Load and merge all discoverable execsql.conf files for the given script path.
79
+
80
+ Args:
81
+ script_path: Directory of the running script; used to locate a
82
+ script-adjacent ``execsql.conf``.
83
+ variable_pool: Substitution-variable registry used to expand
84
+ ``config_file`` path values and to populate ``[variables]``
85
+ sections.
86
+ """
58
87
  self.db_type = "a"
59
88
  self.server = None
60
89
  self.port = None
@@ -106,10 +135,12 @@ class ConfigData:
106
135
  self.quote_all_text = False
107
136
  self.import_row_buffer = 1000
108
137
  self.import_progress_interval = 0
138
+ self.show_progress = False
109
139
  self.export_row_buffer = 1000
110
140
  self.template_processor = None
111
141
  self.tee_write_log = False
112
142
  self.log_datavars = True
143
+ self.log_sql = False
113
144
  self.max_log_size_mb = 0
114
145
  self.smtp_host = None
115
146
  self.smtp_port = None
@@ -143,7 +174,7 @@ class ConfigData:
143
174
  cp.read(configfile)
144
175
  if cp.has_option(self._CONNECT_SECTION, "db_type"):
145
176
  t = cp.get(self._CONNECT_SECTION, "db_type").lower()
146
- if len(t) != 1 or t not in ("a", "l", "p", "f", "m", "o", "s", "d"):
177
+ if len(t) != 1 or t not in ("a", "d", "f", "k", "l", "m", "o", "p", "s"):
147
178
  raise ConfigError(f"Invalid database type: {t}")
148
179
  self.db_type = t
149
180
  if cp.has_option(self._CONNECT_SECTION, "server"):
@@ -291,6 +322,11 @@ class ConfigData:
291
322
  self.import_progress_interval = cp.getint(self._INPUT_SECTION, "import_progress_interval")
292
323
  except Exception as e:
293
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
294
330
  if cp.has_option(self._INPUT_SECTION, "access_use_numeric"):
295
331
  try:
296
332
  self.access_use_numeric = cp.getboolean(self._INPUT_SECTION, "access_use_numeric")
@@ -466,6 +502,11 @@ class ConfigData:
466
502
  self.log_datavars = cp.getboolean(self._CONFIG_SECTION, "log_datavars")
467
503
  except Exception as e:
468
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
469
510
  if cp.has_option(self._CONFIG_SECTION, "max_log_size_mb"):
470
511
  try:
471
512
  self.max_log_size_mb = cp.getint(self._CONFIG_SECTION, "max_log_size_mb")
@@ -535,6 +576,12 @@ class ConfigData:
535
576
 
536
577
 
537
578
  class WriteHooks:
579
+ """Thin wrapper around stdout/stderr that supports GUI or test-harness redirection.
580
+
581
+ Each output hook is a callable that accepts a single string. When a hook
582
+ is ``None`` the default ``sys.stdout`` or ``sys.stderr`` is used.
583
+ """
584
+
538
585
  def __repr__(self) -> str:
539
586
  return f"WriteHooks({self.write_func!r}, {self.err_func!r}, {self.status_func!r})"
540
587
 
@@ -544,6 +591,16 @@ class WriteHooks:
544
591
  error_output_func: object = None,
545
592
  status_output_func: object = None,
546
593
  ) -> None:
594
+ """Store optional hook callables; ``None`` means use the default stream.
595
+
596
+ Args:
597
+ standard_output_func: Callable to receive standard-output text, or
598
+ ``None`` to use ``sys.stdout``.
599
+ error_output_func: Callable to receive error-output text, or
600
+ ``None`` to use ``sys.stderr``.
601
+ status_output_func: Callable to receive status-line text, or
602
+ ``None`` to suppress.
603
+ """
547
604
  # Arguments should be functions that take a single string and
548
605
  # write it to the desired destination. Both stdout and stderr can be hooked.
549
606
  # If a hook function is not specified, the default of stdout or stderr will
@@ -555,22 +612,27 @@ class WriteHooks:
555
612
  self.tee_stderr = True
556
613
 
557
614
  def reset(self) -> None:
615
+ """Reset both output hooks to ``None``, restoring stdout/stderr behaviour."""
558
616
  # Resets output to stdout and stderr.
559
617
  self.write_func = None
560
618
  self.err_func = None
561
619
 
562
620
  def redir_stdout(self, standard_output_func: object) -> None:
621
+ """Replace the standard-output hook with the given callable."""
563
622
  self.write_func = standard_output_func
564
623
 
565
624
  def redir_stderr(self, error_output_func: object, tee: bool = True) -> None:
625
+ """Replace the error-output hook and optionally keep tee-to-stderr behaviour."""
566
626
  self.err_func = error_output_func
567
627
  self.tee_stderr = tee
568
628
 
569
629
  def redir(self, standard_output_func: object, error_output_func: object) -> None:
630
+ """Redirect both stdout and stderr hooks in one call."""
570
631
  self.redir_stdout(standard_output_func)
571
632
  self.redir_stderr(error_output_func)
572
633
 
573
634
  def write(self, strval: str) -> None:
635
+ """Write a string to the standard-output hook, or to sys.stdout if unset."""
574
636
  if self.write_func:
575
637
  self.write_func(strval)
576
638
  else:
@@ -578,6 +640,7 @@ class WriteHooks:
578
640
  sys.stdout.flush()
579
641
 
580
642
  def write_err(self, strval: str) -> None:
643
+ """Write an error string to the error-output hook, or to sys.stderr if unset."""
581
644
  if strval[-1] != "\n":
582
645
  strval += "\n"
583
646
  if self.err_func:
@@ -590,5 +653,6 @@ class WriteHooks:
590
653
  sys.stderr.flush()
591
654
 
592
655
  def write_status(self, strval: str) -> None:
656
+ """Forward a status string to the status hook if one is registered."""
593
657
  if self.status_func:
594
658
  self.status_func(strval)
execsql/db/access.py CHANGED
@@ -20,8 +20,12 @@ from execsql.utils.errors import exception_desc, fatal_error
20
20
  from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
21
21
  import execsql.state as _state
22
22
 
23
+ __all__ = ["AccessDatabase"]
24
+
23
25
 
24
26
  class AccessDatabase(Database):
27
+ """MS Access adapter connecting to .mdb/.accdb files via DAO (win32com) with pyodbc fallback."""
28
+
25
29
  # Regex for the 'create temporary view' SQL extension
26
30
  temp_rx = re.compile(
27
31
  r"^\s*create(?:\s+or\s+replace)?(\s+temp(?:orary)?)?\s+(?:(view|query))\s+(\w+) as\s+",
@@ -68,7 +72,7 @@ class AccessDatabase(Database):
68
72
  self.dao_conn = None
69
73
  self.conn = None # ODBC connection
70
74
  self.paramstr = "?"
71
- self.dt_cast = dict(Database.dt_cast)
75
+ self.dt_cast = dict(self.dt_cast) # Copy the lazy-initialized default before overriding.
72
76
  self.dt_cast[datetime.date] = self.as_datetime
73
77
  self.dt_cast[datetime.datetime] = self.as_datetime
74
78
  self.dt_cast[int] = self.int_or_bool
@@ -103,9 +107,13 @@ class AccessDatabase(Database):
103
107
  try:
104
108
  self.conn = pyodbc.connect(connstr)
105
109
  except Exception:
106
- _state.exec_log.log_status_info(f"Could not connect via ODBC using: {connstr}")
110
+ _state.exec_log.log_status_info(
111
+ f"Could not connect via ODBC using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
112
+ )
107
113
  else:
108
- _state.exec_log.log_status_info(f"Connected via ODBC using: {connstr}")
114
+ _state.exec_log.log_status_info(
115
+ f"Connected via ODBC using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
116
+ )
109
117
  self.jet4 = jet4flag
110
118
  return True
111
119
  return False
@@ -290,17 +298,17 @@ class AccessDatabase(Database):
290
298
  self.dao_flush_check()
291
299
  curs = self.cursor()
292
300
  try:
293
- sql = f"select Name from MSysObjects where Name='{table_name}' And Type In (1,4,6);"
294
- curs.execute(sql)
301
+ sql = "select Name from MSysObjects where Name=? And Type In (1,4,6);"
302
+ curs.execute(sql, (table_name,))
295
303
  except ErrInfo:
296
304
  raise
297
- except Exception:
305
+ except Exception as e:
298
306
  raise ErrInfo(
299
307
  type="db",
300
308
  command_text=sql,
301
309
  exception_msg=exception_desc(),
302
310
  other_msg=f"Failure on test for existence of Access table {table_name}",
303
- )
311
+ ) from e
304
312
  rows = curs.fetchall()
305
313
  return len(rows) > 0
306
314
 
@@ -312,7 +320,9 @@ class AccessDatabase(Database):
312
320
  ) -> bool:
313
321
  self.dao_flush_check()
314
322
  curs = self.cursor()
315
- sql = f"select top 1 {column_name} from {table_name};"
323
+ quoted_col = self.quote_identifier(column_name)
324
+ quoted_tbl = self.quote_identifier(table_name)
325
+ sql = f"select top 1 {quoted_col} from {quoted_tbl};"
316
326
  try:
317
327
  curs.execute(sql)
318
328
  except Exception:
@@ -322,24 +332,25 @@ class AccessDatabase(Database):
322
332
  def table_columns(self, table_name: str, schema_name: str | None = None) -> list[str]:
323
333
  self.dao_flush_check()
324
334
  curs = self.cursor()
325
- curs.execute(f"select top 1 * from {table_name};")
335
+ quoted_tbl = self.quote_identifier(table_name)
336
+ curs.execute(f"select top 1 * from {quoted_tbl};")
326
337
  return [d[0] for d in curs.description]
327
338
 
328
339
  def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
329
340
  self.dao_flush_check()
330
341
  curs = self.cursor()
331
342
  try:
332
- sql = f"select Name from MSysObjects where Name='{view_name}' And Type = 5;"
333
- curs.execute(sql)
343
+ sql = "select Name from MSysObjects where Name=? And Type = 5;"
344
+ curs.execute(sql, (view_name,))
334
345
  except ErrInfo:
335
346
  raise
336
- except Exception:
347
+ except Exception as e:
337
348
  raise ErrInfo(
338
349
  type="db",
339
350
  command_text=sql,
340
351
  exception_msg=exception_desc(),
341
352
  other_msg=f"Test for existence of Access view/query {view_name}",
342
- )
353
+ ) from e
343
354
  rows = curs.fetchall()
344
355
  return len(rows) > 0
345
356
 
@@ -356,7 +367,7 @@ class AccessDatabase(Database):
356
367
 
357
368
  if val is None or (isinstance(val, _state.stringtypes) and len(val) == 0):
358
369
  return None
359
- if isinstance(val, (datetime.date, datetime.datetime, datetime.time)):
370
+ if isinstance(val, datetime.date | datetime.datetime | datetime.time):
360
371
  return val
361
372
  else:
362
373
  try:
@@ -414,5 +425,6 @@ class AccessDatabase(Database):
414
425
  with open(file_name, "rb") as f:
415
426
  filedata = f.read()
416
427
  sq_name = self.schema_qualified_table_name(schema_name, table_name)
417
- sql = f"insert into {sq_name} ({column_name}) values ({self.paramsubs(1)});"
428
+ quoted_col = self.quote_identifier(column_name)
429
+ sql = f"insert into {sq_name} ({quoted_col}) values ({self.paramsubs(1)});"
418
430
  self.cursor().execute(sql, (pyodbc.Binary(filedata),))