execsql2 2.35.0__py3-none-any.whl → 2.37.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 (38) hide show
  1. execsql/api.py +16 -8
  2. execsql/cli/lint.py +54 -6
  3. execsql/cli/run.py +5 -10
  4. execsql/data/execsql.conf.template +10 -7
  5. execsql/db/base.py +12 -2
  6. execsql/db/duckdb.py +8 -1
  7. execsql/debug/repl.py +2 -2
  8. execsql/metacommands/connect.py +10 -9
  9. execsql/metacommands/io_fileops.py +6 -3
  10. execsql/metacommands/io_write.py +3 -1
  11. execsql/metacommands/script_ext.py +13 -10
  12. execsql/metacommands/system.py +13 -6
  13. execsql/script/ast.py +21 -0
  14. execsql/script/control.py +63 -3
  15. execsql/script/executor.py +369 -219
  16. execsql/script/parser.py +102 -52
  17. execsql/state.py +23 -0
  18. execsql/utils/errors.py +22 -4
  19. execsql/utils/sigterm.py +154 -0
  20. {execsql2-2.35.0.dist-info → execsql2-2.37.0.dist-info}/METADATA +2 -2
  21. {execsql2-2.35.0.dist-info → execsql2-2.37.0.dist-info}/RECORD +38 -37
  22. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/README.md +0 -0
  23. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  24. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  25. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  26. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  27. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  28. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  29. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  30. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  31. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/script_template.sql +0 -0
  32. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  33. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  34. {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  35. {execsql2-2.35.0.dist-info → execsql2-2.37.0.dist-info}/WHEEL +0 -0
  36. {execsql2-2.35.0.dist-info → execsql2-2.37.0.dist-info}/entry_points.txt +0 -0
  37. {execsql2-2.35.0.dist-info → execsql2-2.37.0.dist-info}/licenses/LICENSE.txt +0 -0
  38. {execsql2-2.35.0.dist-info → execsql2-2.37.0.dist-info}/licenses/NOTICE +0 -0
execsql/api.py CHANGED
@@ -540,8 +540,8 @@ def run(
540
540
  errors.append(
541
541
  ScriptError(
542
542
  message=exc.errmsg(),
543
- source=_last_source(ctx),
544
- line=_last_line(ctx),
543
+ source=_last_source(ctx, exc),
544
+ line=_last_line(ctx, exc),
545
545
  sql=getattr(exc, "command_text", None),
546
546
  ),
547
547
  )
@@ -655,8 +655,8 @@ def _capture_errors(ctx: RuntimeContext, errors: list[ScriptError], exit_code: A
655
655
  errors.append(
656
656
  ScriptError(
657
657
  message=msg,
658
- source=_last_source(ctx),
659
- line=_last_line(ctx),
658
+ source=_last_source(ctx, err),
659
+ line=_last_line(ctx, err),
660
660
  sql=sql,
661
661
  ),
662
662
  )
@@ -680,16 +680,24 @@ def _error_text(exc: BaseException) -> str:
680
680
  return exc.errmsg() if isinstance(exc, ErrInfo) else str(exc)
681
681
 
682
682
 
683
- def _last_source(ctx: RuntimeContext) -> str:
684
- """Get the source file from the last executed command."""
683
+ def _last_source(ctx: RuntimeContext, err: ErrInfo | None = None) -> str:
684
+ """The source file of *err*, as its report gives it, else of the last executed command.
685
+
686
+ An error can name a line other than the statement on record: an ELSEIF,
687
+ ANDIF or ORIF condition, or a loop condition evaluated after its body.
688
+ """
689
+ if err is not None and err.script_file and err.script_line_no:
690
+ return err.script_file
685
691
  lc = ctx.last_command
686
692
  if lc is not None and hasattr(lc, "source"):
687
693
  return cast(str, lc.source)
688
694
  return "<unknown>"
689
695
 
690
696
 
691
- def _last_line(ctx: RuntimeContext) -> int | None:
692
- """Get the line number from the last executed command."""
697
+ def _last_line(ctx: RuntimeContext, err: ErrInfo | None = None) -> int | None:
698
+ """The line of *err*, as its report gives it, else of the last executed command (see :func:`_last_source`)."""
699
+ if err is not None and err.script_file and err.script_line_no:
700
+ return err.script_line_no
693
701
  lc = ctx.last_command
694
702
  if lc is not None and hasattr(lc, "line_no"):
695
703
  return cast(int | None, lc.line_no)
execsql/cli/lint.py CHANGED
@@ -58,6 +58,7 @@ from typing import TYPE_CHECKING, NamedTuple
58
58
 
59
59
  from execsql.utils.paths import is_network_path
60
60
  from execsql.exceptions import ErrInfo
61
+ from execsql.script.parser import strip_quotes
61
62
  from execsql.script.ast import (
62
63
  BatchBlock,
63
64
  Comment,
@@ -857,10 +858,13 @@ def _referencing_text(node: Node) -> list[str]:
857
858
  texts.append(node.command)
858
859
  elif isinstance(node, IncludeDirective):
859
860
  texts.append(node.target)
861
+ texts.extend(t for t in (node.arguments, node.loop_condition) if t)
860
862
  if isinstance(node, IfBlock):
861
863
  texts.append(node.condition)
862
864
  texts.extend(m.condition for m in node.condition_modifiers)
863
- texts.extend(c.condition for c in node.elseif_clauses)
865
+ for clause in node.elseif_clauses:
866
+ texts.append(clause.condition)
867
+ texts.extend(m.condition for m in clause.condition_modifiers)
864
868
  elif isinstance(node, LoopBlock):
865
869
  texts.append(node.condition)
866
870
  return texts
@@ -913,6 +917,24 @@ def _definition_lines(nodes: list[Node], script_dir: Path | None, lines: dict[st
913
917
  # ---------------------------------------------------------------------------
914
918
 
915
919
 
920
+ _RX_FILE_EXISTS = re.compile(r"^\s*FILE_EXISTS\s*\(\s*(?P<path>.+?)\s*\)\s*$", re.I)
921
+
922
+
923
+ def _file_exists_guards(condition: str, modifiers: list[ConditionModifier]) -> frozenset[str]:
924
+ """The files an IF branch runs only if they exist: ``IF (FILE_EXISTS(f))``, with any ``ANDIF (FILE_EXISTS(g))``.
925
+
926
+ An ``ORIF`` lets the branch run without the file, so it guards nothing.
927
+ """
928
+ if any(m.kind == "OR" for m in modifiers):
929
+ return frozenset()
930
+ paths = set()
931
+ for cond in [condition, *(m.condition for m in modifiers)]:
932
+ m = _RX_FILE_EXISTS.match(cond)
933
+ if m:
934
+ paths.add(strip_quotes(m.group("path")))
935
+ return frozenset(paths)
936
+
937
+
916
938
  def _lint_nodes(
917
939
  nodes: list[Node],
918
940
  script_dir: Path | None,
@@ -921,8 +943,13 @@ def _lint_nodes(
921
943
  issues: list[_Issue],
922
944
  *,
923
945
  visited_scripts: set[str] | None = None,
946
+ guarded: frozenset[str] = frozenset(),
924
947
  ) -> None:
925
- """Walk a list of AST nodes and collect lint issues."""
948
+ """Walk a list of AST nodes and collect lint issues.
949
+
950
+ *guarded* holds the files an enclosing ``IF (FILE_EXISTS(…))`` has
951
+ checked, so an ``INCLUDE`` of one is not reported as missing.
952
+ """
926
953
  if visited_scripts is None:
927
954
  visited_scripts = set()
928
955
 
@@ -947,6 +974,9 @@ def _lint_nodes(
947
974
 
948
975
  # -- IncludeDirective checks --
949
976
  elif isinstance(node, IncludeDirective):
977
+ for text in (node.target, node.arguments, node.loop_condition):
978
+ for name in _unresolved_var_refs(text or "", defined_vars):
979
+ _check_var_ref(name, src, lno, defined_vars, issues)
950
980
  if node.is_execute_script:
951
981
  target = node.target.lower()
952
982
  if target not in script_blocks:
@@ -967,14 +997,22 @@ def _lint_nodes(
967
997
  else:
968
998
  # INCLUDE file existence check
969
999
  if not node.if_exists:
970
- raw_path = node.target.strip().strip("\"'")
971
- if not _RX_VAR_REF.search(raw_path):
1000
+ raw_path = strip_quotes(node.target.strip())
1001
+ if not _RX_VAR_REF.search(raw_path) and raw_path not in guarded:
972
1002
  _check_include_path(raw_path, script_dir, src, lno, issues)
973
1003
 
974
1004
  # -- Recurse into block children --
975
1005
  if isinstance(node, IfBlock):
976
1006
  _check_constant_condition(node, issues)
977
- _lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
1007
+ _lint_nodes(
1008
+ node.body,
1009
+ script_dir,
1010
+ defined_vars,
1011
+ script_blocks,
1012
+ issues,
1013
+ visited_scripts=visited_scripts,
1014
+ guarded=guarded | _file_exists_guards(node.condition, node.condition_modifiers),
1015
+ )
978
1016
  for clause in node.elseif_clauses:
979
1017
  _lint_nodes(
980
1018
  clause.body,
@@ -983,6 +1021,7 @@ def _lint_nodes(
983
1021
  script_blocks,
984
1022
  issues,
985
1023
  visited_scripts=visited_scripts,
1024
+ guarded=guarded | _file_exists_guards(clause.condition, clause.condition_modifiers),
986
1025
  )
987
1026
  _lint_nodes(
988
1027
  node.else_body,
@@ -991,9 +1030,18 @@ def _lint_nodes(
991
1030
  script_blocks,
992
1031
  issues,
993
1032
  visited_scripts=visited_scripts,
1033
+ guarded=guarded,
994
1034
  )
995
1035
  elif isinstance(node, (LoopBlock, BatchBlock, SqlBlock)):
996
- _lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
1036
+ _lint_nodes(
1037
+ node.body,
1038
+ script_dir,
1039
+ defined_vars,
1040
+ script_blocks,
1041
+ issues,
1042
+ visited_scripts=visited_scripts,
1043
+ guarded=guarded,
1044
+ )
997
1045
  elif isinstance(node, ScriptBlock):
998
1046
  # Lint the block body once (structural errors are caught by the
999
1047
  # parser). Issues carry their own line, which already places them
execsql/cli/run.py CHANGED
@@ -47,14 +47,14 @@ def _print_dry_run(tree: Script | None) -> None:
47
47
  source location. Variables already populated at parse time are expanded;
48
48
  execution-time variables remain unexpanded.
49
49
  """
50
- from execsql.script.ast import MetaCommandStatement, SqlStatement
50
+ from execsql.script.ast import IncludeDirective, MetaCommandStatement, SqlStatement
51
51
 
52
52
  if tree is None:
53
53
  _console.print("[yellow]No commands found in script.[/yellow]")
54
54
  return
55
55
  nodes = list(tree.walk())
56
- # Count only executable nodes (SQL and metacommands)
57
- executable = [n for n in nodes if isinstance(n, (SqlStatement, MetaCommandStatement))]
56
+ # Count only executable nodes (SQL, metacommands, INCLUDE and EXECUTE SCRIPT)
57
+ executable = [n for n in nodes if isinstance(n, (SqlStatement, MetaCommandStatement, IncludeDirective))]
58
58
  if not executable:
59
59
  _console.print("[yellow]No commands found in script.[/yellow]")
60
60
  return
@@ -871,14 +871,9 @@ def _end_run_on_sigterm() -> None:
871
871
  """
872
872
  import signal
873
873
 
874
- def _terminate(signum: int, _frame: Any) -> None:
875
- import execsql.state as _state
874
+ from execsql.utils.sigterm import terminate
876
875
 
877
- if _state.exec_log is not None:
878
- _state.exec_log.log_exit_terminated()
879
- raise SystemExit(128 + signum)
880
-
881
- signal.signal(signal.SIGTERM, _terminate)
876
+ signal.signal(signal.SIGTERM, terminate)
882
877
 
883
878
 
884
879
  def _execute_script_ast(
@@ -326,19 +326,22 @@
326
326
  # Values: Yes or No. Default: Yes.
327
327
  #allow_serve=Yes
328
328
 
329
- # Root directory under which INCLUDE / EXECUTE SCRIPT targets must
330
- # resolve. When set, attempts to include files outside this root via
331
- # ../, absolute paths, drive letters, or UNC paths are rejected with
332
- # an error. Default: no containment (any readable path is permitted).
329
+ # Root directory under which the files INCLUDE reads must resolve.
330
+ # When set, attempts to include files outside this root via ../,
331
+ # absolute paths, drive letters, or UNC paths are rejected with an
332
+ # error that stops the run, even with METACOMMAND_ERROR_HALT OFF.
333
+ # Default: no containment (any readable path is permitted).
333
334
  #include_root=
334
335
 
335
336
  # Root directory under which SERVE targets must resolve. Same
336
- # containment semantics as include_root. Default: no containment.
337
+ # containment checks as include_root, but a refused path is an
338
+ # ordinary metacommand error. Default: no containment.
337
339
  #serve_root=
338
340
 
339
341
  # Root directory under which Jinja2 / string.Template loader paths
340
- # must resolve. Same containment semantics as include_root.
341
- # Default: no containment.
342
+ # must resolve. Same containment checks as include_root, but a
343
+ # refused path is an ordinary metacommand error. Default: no
344
+ # containment.
342
345
  #template_root=
343
346
 
344
347
  # Maximum byte size of any single substitution-variable expansion,
execsql/db/base.py CHANGED
@@ -352,9 +352,19 @@ class Database(ABC):
352
352
  self.autocommit = False
353
353
 
354
354
  def commit(self) -> None:
355
- """Commit the current transaction if autocommit is enabled."""
355
+ """Commit the current transaction if autocommit is enabled.
356
+
357
+ A commit that fails (a deferred constraint, a lock) rolls the
358
+ transaction back before the error is raised: some DBMSs (SQLite)
359
+ keep it open, and the next commit on the connection would store
360
+ what was just reported as failed.
361
+ """
356
362
  if self.conn and self.autocommit:
357
- self.conn.commit()
363
+ try:
364
+ self.conn.commit()
365
+ except BaseException:
366
+ self.rollback()
367
+ raise
358
368
 
359
369
  def rollback(self) -> None:
360
370
  """Roll back the current transaction; swallows errors (best-effort)."""
execsql/db/duckdb.py CHANGED
@@ -253,7 +253,14 @@ class DuckDBDatabase(Database):
253
253
 
254
254
  def commit(self) -> None:
255
255
  """Commit the open transaction if autocommit is enabled."""
256
- self._take_connection()
256
+ try:
257
+ # Reading ahead another cursor's result can raise (DuckDB reports
258
+ # some errors at fetch time); roll back as a failed commit does.
259
+ self._take_connection()
260
+ except BaseException:
261
+ if self.autocommit and self.in_transaction:
262
+ self.rollback()
263
+ raise
257
264
  super().commit()
258
265
  if self.autocommit:
259
266
  self.in_transaction = False
execsql/debug/repl.py CHANGED
@@ -6,8 +6,8 @@ Input runs as if it were written where the script paused, through
6
6
  :mod:`execsql.interactive`: SQL (ending with ``;``, results shown as tables),
7
7
  metacommands and blocks, in the paused script's variable scope and
8
8
  transaction. A ``SUB`` typed here is still set
9
- when the script resumes; a ``BREAK`` inside a LOOP leaves the loop; ``HALT``
10
- ends the run. An error ends only the input that caused it.
9
+ when the script resumes; a ``BREAK`` leaves the LOOP, SCRIPT or included file
10
+ the script paused in; ``HALT`` ends the run. An error ends only the input that caused it.
11
11
 
12
12
  Commands to the REPL itself start with ``.``: ``.continue`` and ``.next``
13
13
  resume the script, ``.where`` and ``.stack`` show where it paused, ``.quit``
@@ -461,15 +461,16 @@ def x_use(**kwargs: Any) -> None:
461
461
  def x_disconnect(**kwargs: Any) -> None:
462
462
  alias = kwargs["alias"]
463
463
  current_alias = _state.dbs.current_alias()
464
- if alias is None:
465
- alias = _state.dbs.current_alias()
466
- if alias.lower() == "initial":
467
- raise ErrInfo(type="error", other_msg="You may not disconnect from the initial database used.")
468
- if _state.status.batch.uses_db(alias):
469
- raise ErrInfo(
470
- type="error",
471
- other_msg="You may not disconnect from a database that is currently used in a batch.",
472
- )
464
+ # Aliases are stored lowercase (DatabasePool.add), as USE expects.
465
+ alias = current_alias if alias is None else alias.lower()
466
+ # type="cmd": reported as a metacommand error; the engine adds the
467
+ # "Metacommand: DISCONNECT ..." line itself.
468
+ if alias == "initial":
469
+ raise ErrInfo(type="cmd", other_msg="You may not disconnect from the initial database used.")
470
+ if alias not in _state.dbs.aliases():
471
+ raise ErrInfo(type="cmd", other_msg=f"Unrecognized database alias: {alias}.")
472
+ if _state.status.batch.uses_db(_state.dbs.aliased_as(alias)):
473
+ raise ErrInfo(type="cmd", other_msg="You may not disconnect from a database that is currently used in a batch.")
473
474
  _state.exec_log.log_status_info(f"Disconnecting from database with alias '{alias}'")
474
475
  if alias == current_alias:
475
476
  _state.dbs.make_current("initial")
@@ -67,11 +67,14 @@ def _close_rowsource(rows: Any) -> None:
67
67
 
68
68
 
69
69
  def x_include(**kwargs: Any) -> None:
70
- # INCLUDE is now handled natively by the AST executor (_execute_include_native).
71
- # This handler exists only for dispatch table registration compatibility.
70
+ # The executor runs INCLUDE itself (an IncludeDirective) wherever the
71
+ # script writes it. Only a metacommand whose text a substitution variable
72
+ # supplies (-- !x! !!cmd!!) reaches the dispatch table. The entry also
73
+ # gives INCLUDE its place in the keyword list, the language server and the
74
+ # run manifest.
72
75
  raise ErrInfo(
73
76
  type="cmd",
74
- other_msg="INCLUDE should be handled by the AST executor, not the dispatch table.",
77
+ other_msg="An INCLUDE that a substitution variable supplies cannot run; write the INCLUDE in the script.",
75
78
  )
76
79
 
77
80
 
@@ -261,11 +261,13 @@ def _render_script_nodes(nodes: Any, emit: Any) -> None:
261
261
  # Preserve comment text verbatim; the parser stores it including
262
262
  # the leading "--" / "/* ... */" delimiters.
263
263
  emit(f"{node.text}\n")
264
+ elif isinstance(node, IncludeDirective) and node.command:
265
+ emit(f"-- !x! {node.command}\n")
264
266
  elif isinstance(node, IncludeDirective):
265
267
  kw = "EXECUTE SCRIPT" if node.is_execute_script else "INCLUDE"
266
268
  if_exists = " IF EXISTS" if node.if_exists else ""
267
269
  args = f" ({node.arguments})" if node.arguments else ""
268
- loop = f" LOOP {node.loop_type} ({node.loop_condition})" if node.loop_type else ""
270
+ loop = f" {node.loop_type} ({node.loop_condition})" if node.loop_type else ""
269
271
  emit(f"-- !x! {kw}{if_exists} {node.target}{args}{loop}\n")
270
272
  elif isinstance(node, IfBlock):
271
273
  emit(f"-- !x! IF ({node.condition})\n")
@@ -11,10 +11,11 @@ AST executor:
11
11
  an inline source).
12
12
  - ``x_extendscript_metacommand`` — ``EXTEND SCRIPT … WITH METACOMMAND …``.
13
13
  - ``x_extendscript_sql`` — ``EXTEND SCRIPT … WITH SQL …``.
14
- - ``x_executescript`` — dispatch-table sentinel only. ``EXECUTE SCRIPT``
15
- / ``RUN SCRIPT`` are handled natively by the AST executor; this stub
16
- raises ``ErrInfo`` if the parser ever fails to recognize them as
17
- structural nodes.
14
+ - ``x_executescript`` — dispatch-table entry. The executor runs
15
+ ``EXECUTE SCRIPT`` / ``RUN SCRIPT`` itself wherever the script writes them
16
+ (a line of their own, an inline ``IF``, ``EXTEND SCRIPT … WITH
17
+ METACOMMAND``); this stub is reached only by one that a substitution
18
+ variable supplies, and reports that it cannot run.
18
19
 
19
20
  Registration of ``BEGIN SCRIPT … END SCRIPT`` blocks themselves is
20
21
  handled by the AST parser (block boundaries) and executor (registering
@@ -68,12 +69,12 @@ def x_extendscript(**kwargs: Any) -> None:
68
69
 
69
70
  def x_extendscript_metacommand(**kwargs: Any) -> None:
70
71
  """Append a single metacommand line to an existing SCRIPT body."""
71
- from execsql.script.ast import MetaCommandStatement
72
+ from execsql.script.parser import metacommand_node
72
73
 
73
74
  block = _get_ast_script(kwargs["script"])
74
75
  script_file, script_line_no = current_script_line()
75
76
  span = _new_span(script_file, script_line_no or 0)
76
- block.body.append(MetaCommandStatement(span=span, command=kwargs["cmd"]))
77
+ block.body.append(metacommand_node(kwargs["cmd"].strip(), span))
77
78
 
78
79
 
79
80
  def x_extendscript_sql(**kwargs: Any) -> None:
@@ -87,10 +88,12 @@ def x_extendscript_sql(**kwargs: Any) -> None:
87
88
 
88
89
 
89
90
  def x_executescript(**kwargs: Any) -> None:
90
- # EXECUTE SCRIPT is now handled natively by the AST executor
91
- # (_execute_include / _execute_script_native). This handler exists only
92
- # for dispatch table registration compatibility.
91
+ # The executor runs EXECUTE SCRIPT itself (an IncludeDirective) wherever
92
+ # the script writes it. Only a metacommand whose text a substitution
93
+ # variable supplies (-- !x! !!cmd!!) reaches the dispatch table. The entry
94
+ # also gives EXECUTE / RUN SCRIPT their place in the keyword list, the
95
+ # language server and the run manifest.
93
96
  raise ErrInfo(
94
97
  "cmd",
95
- other_msg="EXECUTE SCRIPT should be handled by the AST executor, not the dispatch table.",
98
+ other_msg="An EXECUTE SCRIPT that a substitution variable supplies cannot run; write it in the script.",
96
99
  )
@@ -33,6 +33,7 @@ from execsql.utils.gui import (
33
33
  gui_console_width,
34
34
  )
35
35
  from execsql.utils.mail import MailSpec, Mailer
36
+ from execsql.utils.sigterm import command
36
37
 
37
38
 
38
39
  def x_system_cmd(**kwargs: Any) -> None:
@@ -51,19 +52,25 @@ def x_system_cmd(**kwargs: Any) -> None:
51
52
  if cont is None:
52
53
  timeout = getattr(_state.conf, "system_cmd_timeout", 0.0)
53
54
  try:
54
- if timeout and timeout > 0:
55
- result = subprocess.run(cmdlist, timeout=timeout)
56
- else:
57
- result = subprocess.run(cmdlist)
55
+ # The command is killed if the block is left before it finishes,
56
+ # and by a SIGTERM at any point once it has started.
57
+ with command(cmdlist) as proc:
58
+ # communicate(), as subprocess.run() does: after Ctrl-C it
59
+ # gives the command a moment to handle its own SIGINT.
60
+ proc.communicate(timeout=timeout if timeout and timeout > 0 else None)
58
61
  except subprocess.TimeoutExpired as e:
59
62
  _state.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", "124")
63
+ msg = f"SYSTEM_CMD timed out after {timeout:g} seconds."
64
+ if proc.returncode is None:
65
+ # The OS refused to kill it: it runs as another user.
66
+ msg += f" The command could not be stopped and is still running (process {proc.pid})."
60
67
  raise ErrInfo(
61
68
  type="cmd",
62
69
  command_text=kwargs.get("metacommandline", "SYSTEM_CMD"),
63
- other_msg=f"SYSTEM_CMD timed out after {timeout:g} seconds.",
70
+ other_msg=msg,
64
71
  exception_msg=exception_desc(),
65
72
  ) from e
66
- _state.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", str(result.returncode))
73
+ _state.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", str(proc.returncode))
67
74
  else:
68
75
  proc = subprocess.Popen(cmdlist)
69
76
  _state.subvars.add_substitution("$SYSTEM_CMD_PID", str(proc.pid))
execsql/script/ast.py CHANGED
@@ -212,12 +212,15 @@ class ElseIfClause:
212
212
  condition_modifiers: ANDIF/ORIF modifiers that compound the ELSEIF
213
213
  condition, evaluated left-to-right at runtime.
214
214
  body: Nodes executed when this condition is true.
215
+ command: The metacommand as written, without ``-- !x!``, for error
216
+ reports.
215
217
  """
216
218
 
217
219
  condition: str
218
220
  span: SourceSpan
219
221
  condition_modifiers: list[ConditionModifier] = field(default_factory=list)
220
222
  body: list[Node] = field(default_factory=list)
223
+ command: str = ""
221
224
 
222
225
 
223
226
  @dataclass
@@ -232,6 +235,8 @@ class IfBlock(Node):
232
235
  elseif_clauses: Zero or more ELSEIF branches, evaluated in order.
233
236
  else_body: Nodes executed when the IF condition (and all ELSEIFs)
234
237
  are false. Empty list means no ELSE branch.
238
+ command: The metacommand as written, without ``-- !x!``, for error
239
+ reports.
235
240
  """
236
241
 
237
242
  condition: str
@@ -240,6 +245,7 @@ class IfBlock(Node):
240
245
  elseif_clauses: list[ElseIfClause] = field(default_factory=list)
241
246
  else_body: list[Node] = field(default_factory=list)
242
247
  else_span: SourceSpan | None = None
248
+ command: str = ""
243
249
 
244
250
  def children(self) -> Iterator[Node]:
245
251
  yield from self.body
@@ -261,11 +267,14 @@ class LoopBlock(Node):
261
267
  loop_type: Either ``"WHILE"`` or ``"UNTIL"``.
262
268
  condition: The condition expression text.
263
269
  body: Nodes executed on each iteration.
270
+ command: The metacommand as written, without ``-- !x!``, for error
271
+ reports.
264
272
  """
265
273
 
266
274
  loop_type: str # "WHILE" or "UNTIL"
267
275
  condition: str
268
276
  body: list[Node] = field(default_factory=list)
277
+ command: str = ""
269
278
 
270
279
  def children(self) -> Iterator[Node]:
271
280
  yield from self.body
@@ -283,9 +292,12 @@ class BatchBlock(Node):
283
292
 
284
293
  Attributes:
285
294
  body: Nodes within the batch.
295
+ command: The metacommand as written, without ``-- !x!``, for error
296
+ reports.
286
297
  """
287
298
 
288
299
  body: list[Node] = field(default_factory=list)
300
+ command: str = ""
289
301
 
290
302
  def children(self) -> Iterator[Node]:
291
303
  yield from self.body
@@ -325,12 +337,15 @@ class ScriptBlock(Node):
325
337
  doc: Optional docstring extracted from comments immediately following
326
338
  the BEGIN SCRIPT line.
327
339
  body: Nodes within the script block.
340
+ command: The metacommand as written, without ``-- !x!``, for error
341
+ reports.
328
342
  """
329
343
 
330
344
  name: str
331
345
  param_defs: list[ParamDef] | None = None
332
346
  doc: str | None = None
333
347
  body: list[Node] = field(default_factory=list)
348
+ command: str = ""
334
349
 
335
350
  @property
336
351
  def param_names(self) -> list[str] | None:
@@ -358,9 +373,12 @@ class SqlBlock(Node):
358
373
  Attributes:
359
374
  body: Nodes within the SQL block (typically a single
360
375
  :class:`SqlStatement`).
376
+ command: The metacommand as written, without ``-- !x!``, for error
377
+ reports.
361
378
  """
362
379
 
363
380
  body: list[Node] = field(default_factory=list)
381
+ command: str = ""
364
382
 
365
383
  def children(self) -> Iterator[Node]:
366
384
  yield from self.body
@@ -384,6 +402,8 @@ class IncludeDirective(Node):
384
402
  arguments: Optional argument expression for EXECUTE SCRIPT.
385
403
  loop_type: Optional ``"WHILE"`` or ``"UNTIL"`` for looped execution.
386
404
  loop_condition: The loop condition expression, if *loop_type* is set.
405
+ command: The metacommand as written, without ``-- !x!``, for error
406
+ reports.
387
407
  """
388
408
 
389
409
  target: str
@@ -392,6 +412,7 @@ class IncludeDirective(Node):
392
412
  arguments: str | None = None
393
413
  loop_type: str | None = None
394
414
  loop_condition: str | None = None
415
+ command: str = ""
395
416
 
396
417
  def __repr__(self) -> str:
397
418
  kind = "EXECUTE SCRIPT" if self.is_execute_script else "INCLUDE"
execsql/script/control.py CHANGED
@@ -30,6 +30,8 @@ class BatchLevels:
30
30
  class Batch:
31
31
  def __init__(self) -> None:
32
32
  self.dbs_used: list[Any] = []
33
+ # Why this batch can no longer commit (an error inside it), or None.
34
+ self.failed: str | None = None
33
35
 
34
36
  def __init__(self) -> None:
35
37
  self.batchlevels: list[BatchLevels.Batch] = []
@@ -38,6 +40,25 @@ class BatchLevels:
38
40
  """Return True if execution is currently inside at least one BATCH block."""
39
41
  return len(self.batchlevels) > 0
40
42
 
43
+ def fail_enclosing(self, reason: str) -> None:
44
+ """Mark every open batch level as failed, so its END BATCH rolls back instead of committing."""
45
+ for b in self.batchlevels:
46
+ if b.failed is None:
47
+ b.failed = reason
48
+
49
+ def failure(self) -> str | None:
50
+ """Why the innermost batch can no longer commit, or None.
51
+
52
+ A batch inside a failed one cannot commit either: its commit would
53
+ commit the failed batch's later statements on a shared connection.
54
+ Nothing clears the mark, ROLLBACK BATCH included: once anything in a
55
+ batch has failed, the whole stack of batches rolls back.
56
+ """
57
+ for b in reversed(self.batchlevels):
58
+ if b.failed is not None:
59
+ return b.failed
60
+ return None
61
+
41
62
  def new_batch(self) -> None:
42
63
  """Push a new empty batch level onto the stack."""
43
64
  self.batchlevels.append(self.Batch())
@@ -61,7 +82,46 @@ class BatchLevels:
61
82
  db.rollback()
62
83
 
63
84
  def end_batch(self) -> None:
64
- """Commit all databases in the innermost batch level and pop the stack."""
85
+ """Commit all databases in the innermost batch level and pop the stack.
86
+
87
+ If a commit fails, :meth:`Database.commit` rolls that database back,
88
+ and the ones not yet committed are rolled back too (except one with
89
+ autocommit off, whose transaction is the script's) before the error
90
+ is raised: a commit that already succeeded
91
+ cannot be undone, but nothing else of the batch is left pending for
92
+ a later statement to commit.
93
+ """
94
+ b = self.batchlevels.pop()
95
+ for i, db in enumerate(b.dbs_used):
96
+ try:
97
+ db.commit()
98
+ except BaseException:
99
+ _roll_back(b.dbs_used[i + 1 :])
100
+ raise
101
+
102
+ def abandon_batch(self) -> None:
103
+ """Pop the innermost batch level without committing it.
104
+
105
+ Inside another batch, its databases join the enclosing level, which
106
+ commits or rolls them back. Otherwise they are rolled back, except
107
+ one with autocommit off: :meth:`end_batch` would not have committed
108
+ it either, so its transaction stays the script's to end.
109
+ """
65
110
  b = self.batchlevels.pop()
66
- for db in b.dbs_used:
67
- db.commit()
111
+ if self.batchlevels:
112
+ for db in b.dbs_used:
113
+ self.using_db(db)
114
+ return
115
+ _roll_back(b.dbs_used)
116
+
117
+ def abandon_all(self) -> None:
118
+ """Abandon every open batch level, innermost first."""
119
+ while self.batchlevels:
120
+ self.abandon_batch()
121
+
122
+
123
+ def _roll_back(dbs: list[Any]) -> None:
124
+ """Roll back each of *dbs* except one with autocommit off, whose transaction is the script's."""
125
+ for db in dbs:
126
+ if db.autocommit:
127
+ db.rollback()