execsql2 2.23.1__py3-none-any.whl → 2.25.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 (92) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +19 -586
  3. execsql/cli/application.py +309 -0
  4. execsql/cli/commands/__init__.py +13 -0
  5. execsql/cli/commands/config.py +205 -0
  6. execsql/cli/commands/format.py +100 -0
  7. execsql/cli/commands/init.py +135 -0
  8. execsql/cli/commands/lint.py +225 -0
  9. execsql/cli/commands/listing.py +81 -0
  10. execsql/cli/commands/run.py +459 -0
  11. execsql/cli/dispatch.py +66 -0
  12. execsql/cli/dsn.py +11 -4
  13. execsql/cli/help.py +175 -24
  14. execsql/cli/lint.py +780 -100
  15. execsql/cli/options.py +108 -0
  16. execsql/cli/run.py +96 -32
  17. execsql/config.py +256 -173
  18. execsql/config_validation.py +282 -0
  19. execsql/data/execsql.conf.template +38 -5
  20. execsql/data/script_header.sql +23 -0
  21. execsql/db/access.py +29 -8
  22. execsql/db/base.py +40 -8
  23. execsql/db/dsn.py +13 -4
  24. execsql/db/duckdb.py +3 -1
  25. execsql/db/factory.py +13 -4
  26. execsql/db/firebird.py +21 -7
  27. execsql/db/mysql.py +37 -16
  28. execsql/db/oracle.py +49 -16
  29. execsql/db/postgres.py +30 -6
  30. execsql/db/sqlite.py +7 -9
  31. execsql/db/sqlserver.py +25 -8
  32. execsql/debug/repl.py +51 -193
  33. execsql/exceptions.py +9 -0
  34. execsql/exporters/delimited.py +87 -28
  35. execsql/exporters/xlsx.py +24 -9
  36. execsql/exporters/zip.py +1 -2
  37. execsql/format.py +115 -76
  38. execsql/gui/desktop.py +1 -3
  39. execsql/gui/tui.py +2 -2
  40. execsql/importers/base.py +35 -1
  41. execsql/importers/csv.py +3 -1
  42. execsql/interactive.py +232 -0
  43. execsql/manifest.py +200 -0
  44. execsql/metacommands/conditions.py +7 -8
  45. execsql/metacommands/control.py +2 -3
  46. execsql/metacommands/data.py +4 -1
  47. execsql/metacommands/debug.py +2 -4
  48. execsql/metacommands/effects.py +124 -0
  49. execsql/metacommands/io_fileops.py +62 -37
  50. execsql/metacommands/io_import.py +0 -1
  51. execsql/metacommands/prompt.py +2 -0
  52. execsql/metacommands/upsert.py +5 -2
  53. execsql/models.py +3 -2
  54. execsql/parser.py +14 -5
  55. execsql/plugins.py +24 -0
  56. execsql/script/__init__.py +0 -9
  57. execsql/script/ast.py +1 -1
  58. execsql/script/engine.py +46 -104
  59. execsql/script/executor.py +162 -39
  60. execsql/script/parser.py +167 -118
  61. execsql/state.py +34 -11
  62. execsql/state.pyi +11 -0
  63. execsql/types.py +22 -13
  64. execsql/utils/auth.py +169 -24
  65. execsql/utils/color.py +18 -0
  66. execsql/utils/datetime.py +55 -17
  67. execsql/utils/errors.py +82 -10
  68. execsql/utils/fileio.py +404 -117
  69. execsql/utils/gui.py +3 -0
  70. execsql/utils/mail.py +37 -12
  71. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
  72. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
  73. execsql2-2.25.0.dist-info/RECORD +140 -0
  74. execsql2-2.25.0.dist-info/entry_points.txt +2 -0
  75. execsql2-2.23.1.dist-info/RECORD +0 -124
  76. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  77. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  78. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  79. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  80. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  81. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  82. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  83. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  84. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  85. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  86. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  87. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  88. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  89. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  90. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  91. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  92. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
@@ -48,6 +48,7 @@ import re
48
48
  import time as _time
49
49
  from pathlib import Path
50
50
  from typing import Any, cast
51
+ from collections.abc import Callable
51
52
 
52
53
  from execsql.exceptions import ErrInfo
53
54
  from execsql.script.ast import (
@@ -64,12 +65,13 @@ from execsql.script.ast import (
64
65
  SqlBlock,
65
66
  SqlStatement,
66
67
  )
68
+ from execsql import manifest as _manifest
67
69
  from execsql.script.engine import set_dynamic_system_vars, set_static_system_vars, substitute_vars
68
70
  from execsql.script.variables import SubVarSet
69
71
  from execsql.state import ExecFrame, RuntimeContext, active_context, get_context, xcmd_test
70
- from execsql.utils.errors import exception_desc, exit_now, stamp_errinfo
72
+ from execsql.utils.errors import exception_desc, exit_now, logged_error, stamp_errinfo
71
73
 
72
- __all__ = ["execute"]
74
+ __all__ = ["execute", "execute_input"]
73
75
 
74
76
 
75
77
  # ---------------------------------------------------------------------------
@@ -252,35 +254,44 @@ def _exec_sql(
252
254
  effective_locals = _stack_localvars(ctx) or localvars
253
255
  cmd = substitute_vars(text, effective_locals, ctx=ctx)
254
256
  if _VARLIKE.search(cmd):
257
+ from execsql.utils.fileio import redact
258
+
255
259
  output.write(
256
- f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
260
+ f"Warning: There is a potential un-substituted variable in the command\n {redact(cmd)}\n",
257
261
  )
258
262
  e = None
263
+ result = None
259
264
  try:
260
265
  db = dbs.current()
261
266
  if conf.log_sql and ctx.exec_log:
262
267
  ctx.exec_log.log_sql_query(cmd, db.name(), line_no)
263
- db.execute(cmd)
268
+ if ctx.prompt_input is not None:
269
+ result = db.execute(cmd, fetch=True)
270
+ else:
271
+ db.execute(cmd)
264
272
  if commit:
265
273
  db.commit()
266
274
  except ErrInfo as errinfo:
267
275
  e = errinfo
268
276
  except SystemExit:
269
277
  raise
270
- except Exception:
278
+ except Exception as exc:
271
279
  e = ErrInfo(type="exception", exception_msg=exception_desc())
280
+ e.__cause__ = exc # the driver's own message, which a prompt shows
272
281
  if e:
273
282
  stamp_errinfo(e)
274
283
  subvars.add_substitution("$LAST_ERROR", cmd)
275
284
  subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
276
285
  status.sql_error = True
277
286
  if ctx.exec_log is not None:
278
- ctx.exec_log.log_status_info(f"SQL error: {e.errmsg()}")
287
+ ctx.exec_log.log_status_info(f"SQL error: {logged_error(e)}")
279
288
  if status.halt_on_err:
280
289
  exit_now(1, e)
281
290
  status.error_history.append((source, line_no, cmd, e.errmsg()))
282
291
  return
283
292
  subvars.add_substitution("$LAST_SQL", cmd)
293
+ if ctx.prompt_input is not None:
294
+ ctx.prompt_input(result)
284
295
 
285
296
 
286
297
  # ---------------------------------------------------------------------------
@@ -312,27 +323,32 @@ def _exec_metacommand(
312
323
  assert subvars is not None
313
324
 
314
325
  if _VARLIKE.search(cmd):
326
+ from execsql.utils.fileio import redact
327
+
315
328
  output.write(
316
- f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
329
+ f"Warning: There is a potential un-substituted variable in the command\n {redact(cmd)}\n",
317
330
  )
318
331
  e = None
319
332
  try:
320
333
  applies, result = metacommandlist.eval(cmd)
321
334
  if applies:
335
+ if (manifest := _manifest.current()) is not None:
336
+ manifest.record_metacommand(cmd, source, line_no, metacommandlist)
322
337
  return result
323
338
  except ErrInfo as errinfo:
324
339
  e = errinfo
325
340
  except SystemExit:
326
341
  raise
327
- except Exception:
342
+ except Exception as exc:
328
343
  e = ErrInfo(type="exception", exception_msg=exception_desc())
344
+ e.__cause__ = exc
329
345
  if e:
330
346
  stamp_errinfo(e)
331
347
  status.metacommand_error = True
332
348
  subvars.add_substitution("$LAST_ERROR", cmd)
333
349
  subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
334
350
  if ctx.exec_log is not None:
335
- ctx.exec_log.log_status_info(f"Metacommand error: {e.errmsg()}")
351
+ ctx.exec_log.log_status_info(f"Metacommand error: {logged_error(e)}")
336
352
  if status.halt_on_metacommand_err:
337
353
  raise e
338
354
  status.error_history.append((source, line_no, cmd, e.errmsg()))
@@ -402,9 +418,9 @@ def _execute_node(
402
418
  text = node.text
403
419
  if in_loop:
404
420
  text = _convert_deferred_vars(text)
405
- # Deduplicate trailing semicolons (matches SqlStmt.__init__)
421
+ # Deduplicate trailing semicolons
406
422
  text = re.sub(r"\s*;(\s*;\s*)+$", ";", text)
407
- ctx.last_command = _FakeScriptCmd(node)
423
+ ctx.last_command = ExecutingStatement(node)
408
424
  _exec_sql(
409
425
  ctx,
410
426
  text,
@@ -413,6 +429,8 @@ def _execute_node(
413
429
  localvars,
414
430
  commit=not ctx.status.batch.in_batch(),
415
431
  )
432
+ if (manifest := _manifest.current()) is not None:
433
+ manifest.record_sql()
416
434
 
417
435
  elif isinstance(node, MetaCommandStatement):
418
436
  command = node.command
@@ -425,31 +443,31 @@ def _execute_node(
425
443
  expanded = substitute_vars(command, effective_locals, ctx=ctx)
426
444
  if _BREAK_RX.match(expanded):
427
445
  raise _BreakLoop
428
- ctx.last_command = _FakeScriptCmd(node)
446
+ ctx.last_command = ExecutingStatement(node)
429
447
  _exec_metacommand(ctx, expanded, node.span.file, node.span.start_line)
430
448
 
431
449
  elif isinstance(node, IfBlock):
432
- ctx.last_command = _FakeScriptCmd(node)
450
+ ctx.last_command = ExecutingStatement(node)
433
451
  _execute_if(ctx, node, localvars, in_loop=in_loop)
434
452
 
435
453
  elif isinstance(node, LoopBlock):
436
- ctx.last_command = _FakeScriptCmd(node)
454
+ ctx.last_command = ExecutingStatement(node)
437
455
  _execute_loop(ctx, node, localvars)
438
456
 
439
457
  elif isinstance(node, BatchBlock):
440
- ctx.last_command = _FakeScriptCmd(node)
458
+ ctx.last_command = ExecutingStatement(node)
441
459
  _execute_batch(ctx, node, localvars, in_loop=in_loop)
442
460
 
443
461
  elif isinstance(node, ScriptBlock):
444
- ctx.last_command = _FakeScriptCmd(node)
462
+ ctx.last_command = ExecutingStatement(node)
445
463
  _register_script_block(ctx, node)
446
464
 
447
465
  elif isinstance(node, SqlBlock):
448
- ctx.last_command = _FakeScriptCmd(node)
466
+ ctx.last_command = ExecutingStatement(node)
449
467
  _execute_sql_block(ctx, node, localvars, in_loop=in_loop)
450
468
 
451
469
  elif isinstance(node, IncludeDirective):
452
- ctx.last_command = _FakeScriptCmd(node)
470
+ ctx.last_command = ExecutingStatement(node)
453
471
  _execute_include(ctx, node, localvars)
454
472
 
455
473
  else:
@@ -861,6 +879,8 @@ def _execute_include_native(
861
879
  # Parse with AST parser
862
880
  encoding = ctx.conf.script_encoding if ctx.conf is not None else "utf-8"
863
881
  included_tree = parse_script(target, encoding=encoding)
882
+ if (manifest := _manifest.current()) is not None:
883
+ manifest.record_include(target, node.span.file, node.span.start_line)
864
884
 
865
885
  # Pre-register SCRIPT blocks in the included file so forward references work.
866
886
  _pre_register_scripts(ctx, included_tree.body)
@@ -885,40 +905,104 @@ def _execute_include_native(
885
905
  # ---------------------------------------------------------------------------
886
906
 
887
907
 
888
- class _BreakLoop(Exception):
889
- """Raised by BREAK metacommand to exit the innermost loop."""
908
+ class _BreakLoop(BaseException):
909
+ """Raised by BREAK metacommand to exit the innermost loop.
910
+
911
+ A ``BaseException``, like ``KeyboardInterrupt``, so the ``except
912
+ Exception`` handlers it may pass on its way to the loop leave it alone:
913
+ a BREAK typed at a BREAKPOINT prompt travels out through the BREAKPOINT
914
+ metacommand's handler.
915
+ """
916
+
917
+
918
+ def _inside_loop(ctx: RuntimeContext) -> bool:
919
+ """Whether a BREAK at the current statement would leave a loop within its scope."""
920
+ for frame in reversed(ctx.ast_exec_stack):
921
+ if frame.kind in ("loop_while", "loop_until"):
922
+ return True
923
+ if frame.kind in ("script", "main"):
924
+ return frame.kind == "script" and frame.iteration > 0
925
+ return False
890
926
 
891
927
 
892
928
  _BREAK_RX = re.compile(r"^\s*BREAK\s*$", re.I)
893
929
 
894
930
 
895
931
  # ---------------------------------------------------------------------------
896
- # Fake ScriptCmd for ctx.last_command compatibility
932
+ # The statement the executor is currently on
897
933
  # ---------------------------------------------------------------------------
898
934
 
899
935
 
900
- class _FakeScriptCmd:
901
- """Minimal stand-in for ScriptCmd to satisfy ctx.last_command readers."""
936
+ class _StatementText:
937
+ """The text of one statement, in the two spellings its readers expect.
938
+
939
+ ``statement`` is the raw text; ``commandline()`` is the text as it
940
+ appeared in the source, so a metacommand gets its ``-- !x!`` marker back.
941
+ """
942
+
943
+ __slots__ = ("statement", "_prefix")
902
944
 
903
- __slots__ = ("source", "line_no", "source_dir", "source_name", "command", "command_type")
945
+ def __init__(self, statement: str, prefix: str = "") -> None:
946
+ self.statement = statement
947
+ self._prefix = prefix
948
+
949
+ def commandline(self) -> str:
950
+ return self._prefix + self.statement
951
+
952
+
953
+ class ExecutingStatement:
954
+ """Where the executor is, as ``ctx.last_command`` exposes it.
955
+
956
+ The AST node is the single representation of a statement. This wraps one
957
+ rather than copying fields out of it, so there is nothing to keep in sync
958
+ and no second source of truth about what is executing. Error reporting
959
+ (:mod:`execsql.utils.errors`), the debug REPL, and ``api.run()`` read
960
+ these five attributes; everything is derived from ``node`` on access, so
961
+ constructing one costs a single small object per statement rather than
962
+ the two classes the previous shim built each time.
963
+ """
964
+
965
+ __slots__ = ("node", "_source_dir")
904
966
 
905
967
  def __init__(self, node: Node) -> None:
906
- self.source = node.span.file
907
- self.line_no = node.span.start_line
908
- _p = Path(node.span.file)
909
- self.source_dir = str(_p.resolve().parent) + os.sep
910
- self.source_name = _p.name
911
- self.command_type = "sql" if isinstance(node, SqlStatement) else "cmd"
968
+ self.node = node
969
+ self._source_dir: str | None = None
970
+
971
+ @property
972
+ def source(self) -> str:
973
+ return self.node.span.file
974
+
975
+ @property
976
+ def line_no(self) -> int:
977
+ return self.node.span.start_line
978
+
979
+ @property
980
+ def source_name(self) -> str:
981
+ return Path(self.node.span.file).name
982
+
983
+ @property
984
+ def source_dir(self) -> str:
985
+ # resolve() touches the filesystem, so it is done once per statement
986
+ # object rather than on every read.
987
+ if self._source_dir is None:
988
+ self._source_dir = str(Path(self.node.span.file).resolve().parent) + os.sep
989
+ return self._source_dir
990
+
991
+ @property
992
+ def command_type(self) -> str:
993
+ return "sql" if isinstance(self.node, SqlStatement) else "cmd"
994
+
995
+ @property
996
+ def command(self) -> _StatementText:
997
+ node = self.node
912
998
  if isinstance(node, SqlStatement):
913
- self.command = type("_cmd", (), {"statement": node.text, "commandline": lambda self: self.statement})()
914
- elif isinstance(node, MetaCommandStatement):
915
- self.command = type(
916
- "_cmd",
917
- (),
918
- {"statement": node.command, "commandline": lambda self: "-- !x! " + self.statement},
919
- )()
920
- else:
921
- self.command = type("_cmd", (), {"statement": "", "commandline": lambda self: ""})()
999
+ return _StatementText(node.text)
1000
+ if isinstance(node, MetaCommandStatement):
1001
+ return _StatementText(node.command, "-- !x! ")
1002
+ return _StatementText("")
1003
+
1004
+ def __repr__(self) -> str:
1005
+ return f"ExecutingStatement({self.source}:{self.line_no}, {self.command_type})"
922
1006
 
923
1007
  def current_script_line(self) -> tuple[str, int]:
924
1008
  return (self.source, self.line_no)
@@ -1008,3 +1092,42 @@ def execute(script: Script, *, ctx: RuntimeContext | None = None) -> None:
1008
1092
  ) from exc
1009
1093
  finally:
1010
1094
  _pop_frame(ctx)
1095
+
1096
+
1097
+ # ---------------------------------------------------------------------------
1098
+ # Input typed at a prompt
1099
+ # ---------------------------------------------------------------------------
1100
+
1101
+
1102
+ def execute_input(
1103
+ script: Script,
1104
+ show: Callable[[tuple[list[str], list] | None], None],
1105
+ *,
1106
+ ctx: RuntimeContext | None = None,
1107
+ ) -> None:
1108
+ """Run input typed at a BREAKPOINT prompt as the next lines of the paused script.
1109
+
1110
+ The input runs as if it were written where the script paused: in its
1111
+ variable scope, its batch and its loop, so a BREAK leaves that loop.
1112
+ Unlike :func:`execute`, nothing is reset.
1113
+
1114
+ *show* receives the result of each SQL statement run. An error that
1115
+ would halt the run is raised to the caller instead (see ``exit_now``).
1116
+ The paused script's current statement is restored afterwards, so the
1117
+ debug REPL's ``.where`` still shows it.
1118
+ """
1119
+ if ctx is None:
1120
+ ctx = get_context()
1121
+ with active_context(ctx):
1122
+ _pre_register_scripts(ctx, script.body)
1123
+ last_command, prompt_input = ctx.last_command, ctx.prompt_input
1124
+ ctx.prompt_input = show
1125
+ try:
1126
+ _execute_nodes(ctx, script.body, script.source)
1127
+ except _BreakLoop as exc:
1128
+ if not _inside_loop(ctx):
1129
+ raise ErrInfo(type="cmd", other_msg="BREAK metacommand outside of a LOOP block.") from exc
1130
+ raise
1131
+ finally:
1132
+ ctx.prompt_input = prompt_input
1133
+ ctx.last_command = last_command