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.
- execsql/api.py +16 -8
- execsql/cli/lint.py +54 -6
- execsql/cli/run.py +5 -10
- execsql/data/execsql.conf.template +10 -7
- execsql/db/base.py +12 -2
- execsql/db/duckdb.py +8 -1
- execsql/debug/repl.py +2 -2
- execsql/metacommands/connect.py +10 -9
- execsql/metacommands/io_fileops.py +6 -3
- execsql/metacommands/io_write.py +3 -1
- execsql/metacommands/script_ext.py +13 -10
- execsql/metacommands/system.py +13 -6
- execsql/script/ast.py +21 -0
- execsql/script/control.py +63 -3
- execsql/script/executor.py +369 -219
- execsql/script/parser.py +102 -52
- execsql/state.py +23 -0
- execsql/utils/errors.py +22 -4
- execsql/utils/sigterm.py +154 -0
- {execsql2-2.35.0.dist-info → execsql2-2.37.0.dist-info}/METADATA +2 -2
- {execsql2-2.35.0.dist-info → execsql2-2.37.0.dist-info}/RECORD +38 -37
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.35.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.35.0.dist-info → execsql2-2.37.0.dist-info}/WHEEL +0 -0
- {execsql2-2.35.0.dist-info → execsql2-2.37.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.35.0.dist-info → execsql2-2.37.0.dist-info}/licenses/LICENSE.txt +0 -0
- {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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
|
|
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()
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
875
|
-
import execsql.state as _state
|
|
874
|
+
from execsql.utils.sigterm import terminate
|
|
876
875
|
|
|
877
|
-
|
|
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
|
|
330
|
-
#
|
|
331
|
-
#
|
|
332
|
-
#
|
|
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
|
|
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
|
|
341
|
-
# Default: no
|
|
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
|
-
|
|
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
|
-
|
|
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``
|
|
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``
|
execsql/metacommands/connect.py
CHANGED
|
@@ -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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if
|
|
469
|
-
raise ErrInfo(
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
#
|
|
71
|
-
#
|
|
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
|
|
77
|
+
other_msg="An INCLUDE that a substitution variable supplies cannot run; write the INCLUDE in the script.",
|
|
75
78
|
)
|
|
76
79
|
|
|
77
80
|
|
execsql/metacommands/io_write.py
CHANGED
|
@@ -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"
|
|
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
|
|
15
|
-
/ ``RUN SCRIPT``
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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(
|
|
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
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
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
|
|
98
|
+
other_msg="An EXECUTE SCRIPT that a substitution variable supplies cannot run; write it in the script.",
|
|
96
99
|
)
|
execsql/metacommands/system.py
CHANGED
|
@@ -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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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=
|
|
70
|
+
other_msg=msg,
|
|
64
71
|
exception_msg=exception_desc(),
|
|
65
72
|
) from e
|
|
66
|
-
_state.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", str(
|
|
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
|
-
|
|
67
|
-
db.
|
|
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()
|