execsql2 2.34.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 +17 -8
- execsql/cli/lint.py +54 -6
- execsql/cli/run.py +5 -10
- execsql/config.py +2 -0
- execsql/data/execsql.conf.template +17 -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/data.py +7 -1
- execsql/metacommands/dispatch.py +7 -0
- execsql/metacommands/io_fileops.py +6 -3
- execsql/metacommands/io_write.py +3 -1
- execsql/metacommands/reference.py +1 -0
- execsql/metacommands/script_ext.py +13 -10
- execsql/metacommands/system.py +13 -6
- execsql/models.py +30 -4
- execsql/script/ast.py +21 -0
- execsql/script/control.py +63 -3
- execsql/script/executor.py +379 -229
- execsql/script/parser.py +102 -52
- execsql/state.py +23 -0
- execsql/utils/errors.py +24 -6
- execsql/utils/sigterm.py +154 -0
- {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/METADATA +2 -2
- {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/RECORD +43 -42
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/WHEEL +0 -0
- {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/licenses/NOTICE +0 -0
execsql/api.py
CHANGED
|
@@ -178,6 +178,7 @@ class _LibraryConfig:
|
|
|
178
178
|
self.boolean_int = True
|
|
179
179
|
self.boolean_words = False
|
|
180
180
|
self.empty_strings = True
|
|
181
|
+
self.exact_decimals = False
|
|
181
182
|
self.only_strings = False
|
|
182
183
|
self.empty_rows = True
|
|
183
184
|
self.del_empty_cols = False
|
|
@@ -539,8 +540,8 @@ def run(
|
|
|
539
540
|
errors.append(
|
|
540
541
|
ScriptError(
|
|
541
542
|
message=exc.errmsg(),
|
|
542
|
-
source=_last_source(ctx),
|
|
543
|
-
line=_last_line(ctx),
|
|
543
|
+
source=_last_source(ctx, exc),
|
|
544
|
+
line=_last_line(ctx, exc),
|
|
544
545
|
sql=getattr(exc, "command_text", None),
|
|
545
546
|
),
|
|
546
547
|
)
|
|
@@ -654,8 +655,8 @@ def _capture_errors(ctx: RuntimeContext, errors: list[ScriptError], exit_code: A
|
|
|
654
655
|
errors.append(
|
|
655
656
|
ScriptError(
|
|
656
657
|
message=msg,
|
|
657
|
-
source=_last_source(ctx),
|
|
658
|
-
line=_last_line(ctx),
|
|
658
|
+
source=_last_source(ctx, err),
|
|
659
|
+
line=_last_line(ctx, err),
|
|
659
660
|
sql=sql,
|
|
660
661
|
),
|
|
661
662
|
)
|
|
@@ -679,16 +680,24 @@ def _error_text(exc: BaseException) -> str:
|
|
|
679
680
|
return exc.errmsg() if isinstance(exc, ErrInfo) else str(exc)
|
|
680
681
|
|
|
681
682
|
|
|
682
|
-
def _last_source(ctx: RuntimeContext) -> str:
|
|
683
|
-
"""
|
|
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
|
|
684
691
|
lc = ctx.last_command
|
|
685
692
|
if lc is not None and hasattr(lc, "source"):
|
|
686
693
|
return cast(str, lc.source)
|
|
687
694
|
return "<unknown>"
|
|
688
695
|
|
|
689
696
|
|
|
690
|
-
def _last_line(ctx: RuntimeContext) -> int | None:
|
|
691
|
-
"""
|
|
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
|
|
692
701
|
lc = ctx.last_command
|
|
693
702
|
if lc is not None and hasattr(lc, "line_no"):
|
|
694
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(
|
execsql/config.py
CHANGED
|
@@ -300,6 +300,7 @@ class ConfigData:
|
|
|
300
300
|
self._get_bool(cp, self._INPUT_SECTION, "boolean_int", "boolean_int")
|
|
301
301
|
self._get_bool(cp, self._INPUT_SECTION, "boolean_words", "boolean_words")
|
|
302
302
|
self._get_bool(cp, self._INPUT_SECTION, "empty_strings", "empty_strings")
|
|
303
|
+
self._get_bool(cp, self._INPUT_SECTION, "exact_decimals", "exact_decimals")
|
|
303
304
|
self._get_bool(cp, self._INPUT_SECTION, "only_strings", "only_strings")
|
|
304
305
|
self._get_bool(cp, self._INPUT_SECTION, "empty_rows", "empty_rows")
|
|
305
306
|
self._get_bool(cp, self._INPUT_SECTION, "delete_empty_columns", "del_empty_cols")
|
|
@@ -503,6 +504,7 @@ class ConfigData:
|
|
|
503
504
|
self.boolean_int = True
|
|
504
505
|
self.boolean_words = False
|
|
505
506
|
self.empty_strings = True
|
|
507
|
+
self.exact_decimals = False
|
|
506
508
|
self.only_strings = False
|
|
507
509
|
self.empty_rows = True
|
|
508
510
|
self.del_empty_cols = False
|
|
@@ -115,6 +115,13 @@
|
|
|
115
115
|
# Values: Yes or No. Default: Yes.
|
|
116
116
|
#empty_strings=Yes
|
|
117
117
|
|
|
118
|
+
# Whether a column of decimal numbers with different numbers of decimal places
|
|
119
|
+
# (1.5, 2.25) gets an exact DECIMAL(precision, scale) type when IMPORT or COPY
|
|
120
|
+
# creates a table. With No it gets a floating-point type, unless a value has
|
|
121
|
+
# more than 15 significant digits (then it is DECIMAL, so no digit is lost).
|
|
122
|
+
# Values: Yes or No. Default: No.
|
|
123
|
+
#exact_decimals=No
|
|
124
|
+
|
|
118
125
|
# Whether to fold column headers to lowercase, uppercase, or leave unchanged.
|
|
119
126
|
# Values: No, Lower, or Upper. Default: No.
|
|
120
127
|
#fold_column_headers=No
|
|
@@ -319,19 +326,22 @@
|
|
|
319
326
|
# Values: Yes or No. Default: Yes.
|
|
320
327
|
#allow_serve=Yes
|
|
321
328
|
|
|
322
|
-
# Root directory under which
|
|
323
|
-
#
|
|
324
|
-
#
|
|
325
|
-
#
|
|
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).
|
|
326
334
|
#include_root=
|
|
327
335
|
|
|
328
336
|
# Root directory under which SERVE targets must resolve. Same
|
|
329
|
-
# containment
|
|
337
|
+
# containment checks as include_root, but a refused path is an
|
|
338
|
+
# ordinary metacommand error. Default: no containment.
|
|
330
339
|
#serve_root=
|
|
331
340
|
|
|
332
341
|
# Root directory under which Jinja2 / string.Template loader paths
|
|
333
|
-
# must resolve. Same containment
|
|
334
|
-
# 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.
|
|
335
345
|
#template_root=
|
|
336
346
|
|
|
337
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")
|
execsql/metacommands/data.py
CHANGED
|
@@ -21,7 +21,7 @@ What's actually here:
|
|
|
21
21
|
``x_set_counter``.
|
|
22
22
|
- IMPORT / scanning configuration metacommands (the ``CONFIG …`` form):
|
|
23
23
|
``x_empty_strings``, ``x_trim_strings``, ``x_replace_newlines``,
|
|
24
|
-
``x_empty_rows``, ``x_only_strings``, ``x_boolean_int``,
|
|
24
|
+
``x_empty_rows``, ``x_exact_decimals``, ``x_only_strings``, ``x_boolean_int``,
|
|
25
25
|
``x_boolean_words``, ``x_fold_col_hdrs``, ``x_trim_col_hdrs``,
|
|
26
26
|
``x_clean_col_hdrs``, ``x_del_empty_cols``, ``x_create_col_hdrs``,
|
|
27
27
|
``x_dedup_col_hdrs``, ``x_import_common_cols_only``, ``x_max_int``.
|
|
@@ -301,6 +301,12 @@ def x_only_strings(**kwargs: Any) -> None:
|
|
|
301
301
|
return None
|
|
302
302
|
|
|
303
303
|
|
|
304
|
+
def x_exact_decimals(**kwargs: Any) -> None:
|
|
305
|
+
flag = kwargs["yesno"].lower()
|
|
306
|
+
_state.conf.exact_decimals = flag in ("yes", "on", "true", "1")
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
304
310
|
def x_boolean_int(**kwargs: Any) -> None:
|
|
305
311
|
flag = kwargs["yesno"].lower()
|
|
306
312
|
_state.conf.boolean_int = flag in ("yes", "on", "true", "1")
|
execsql/metacommands/dispatch.py
CHANGED
|
@@ -64,6 +64,7 @@ from execsql.metacommands.data import (
|
|
|
64
64
|
x_dedup_col_hdrs,
|
|
65
65
|
x_empty_rows,
|
|
66
66
|
x_empty_strings,
|
|
67
|
+
x_exact_decimals,
|
|
67
68
|
x_fold_col_hdrs,
|
|
68
69
|
x_import_common_cols_only,
|
|
69
70
|
x_max_int,
|
|
@@ -768,6 +769,12 @@ def build_dispatch_table() -> MetaCommandList:
|
|
|
768
769
|
description="EMPTY_ROWS",
|
|
769
770
|
category="config_option",
|
|
770
771
|
)
|
|
772
|
+
mcl.add(
|
|
773
|
+
r"^\s*CONFIG\s+EXACT_DECIMALS\s+(?P<yesno>YES|NO|ON|OFF|TRUE|FALSE|0|1)\s*$",
|
|
774
|
+
x_exact_decimals,
|
|
775
|
+
description="EXACT_DECIMALS",
|
|
776
|
+
category="config_option",
|
|
777
|
+
)
|
|
771
778
|
mcl.add(
|
|
772
779
|
r"^\s*CONFIG\s+ONLY_STRINGS\s+(?P<yesno>YES|NO|ON|OFF|TRUE|FALSE|0|1)\s*$",
|
|
773
780
|
x_only_strings,
|
|
@@ -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")
|
|
@@ -254,6 +254,7 @@ _METACOMMANDS: tuple[Metacommand, ...] = (
|
|
|
254
254
|
"CONFIG DELETE_EMPTY_COLUMNS YES|NO",
|
|
255
255
|
"CONFIG EMPTY_ROWS YES|NO",
|
|
256
256
|
"CONFIG EMPTY_STRINGS YES|NO",
|
|
257
|
+
"CONFIG EXACT_DECIMALS YES|NO",
|
|
257
258
|
"CONFIG EXPORT_ROW_BUFFER <n>",
|
|
258
259
|
"CONFIG FOLD_COLUMN_HEADERS NO|LOWER|UPPER",
|
|
259
260
|
"CONFIG GUI_LEVEL <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/models.py
CHANGED
|
@@ -49,6 +49,10 @@ __all__ = [
|
|
|
49
49
|
|
|
50
50
|
ColumnType = tuple[str, type[DataType], int | None, bool, int | None, int | None]
|
|
51
51
|
|
|
52
|
+
# Every decimal of up to this many significant digits reads back unchanged
|
|
53
|
+
# from an IEEE double (DBL_DIG).
|
|
54
|
+
_DOUBLE_EXACT_DIGITS = 15
|
|
55
|
+
|
|
52
56
|
|
|
53
57
|
def _text_length(value: Any) -> int:
|
|
54
58
|
"""Return the length of *value* written as text (a column may turn out to be text)."""
|
|
@@ -71,9 +75,14 @@ class Column:
|
|
|
71
75
|
"""Accumulate match counts and length statistics for a single data type."""
|
|
72
76
|
|
|
73
77
|
# Accumulates the count of matches for each data type, plus the maximum length if appropriate.
|
|
74
|
-
def __init__(self, data_type_obj: DataType) -> None:
|
|
75
|
-
"""Initialise the accumulator for the given data type.
|
|
78
|
+
def __init__(self, data_type_obj: DataType, *, exact_decimals: bool = False) -> None:
|
|
79
|
+
"""Initialise the accumulator for the given data type.
|
|
80
|
+
|
|
81
|
+
*exact_decimals*: the ``exact_decimals`` setting; see
|
|
82
|
+
:meth:`chooses_float`.
|
|
83
|
+
"""
|
|
76
84
|
self.dt = data_type_obj
|
|
85
|
+
self.exact_decimals = exact_decimals
|
|
77
86
|
self.failed = False
|
|
78
87
|
self.count = 0
|
|
79
88
|
self.maxlen = 0
|
|
@@ -127,6 +136,23 @@ class Column:
|
|
|
127
136
|
self.maxscale = max(self.dt.scale, self.maxscale)
|
|
128
137
|
self.max_int_digits = max(int_digits, self.max_int_digits)
|
|
129
138
|
|
|
139
|
+
def chooses_float(self) -> bool:
|
|
140
|
+
"""Whether a decimal column gives way to a float column.
|
|
141
|
+
|
|
142
|
+
A column of decimals with different scales (``1.50``, ``2``,
|
|
143
|
+
``-3.25``) is a float unless the ``exact_decimals`` setting is on,
|
|
144
|
+
or a value has more digits than a double holds exactly: every
|
|
145
|
+
decimal of up to 15 significant digits reads back unchanged from
|
|
146
|
+
a double, so such a column loses nothing, and a longer one
|
|
147
|
+
(``1234567890.1234567891``) stays ``DECIMAL(p, s)``.
|
|
148
|
+
"""
|
|
149
|
+
return (
|
|
150
|
+
self.varscale
|
|
151
|
+
and not self.exact_decimals
|
|
152
|
+
and self.maxprecision is not None
|
|
153
|
+
and self.maxprecision <= _DOUBLE_EXACT_DIGITS
|
|
154
|
+
)
|
|
155
|
+
|
|
130
156
|
def __init__(self, colname: str, infer_strings: bool = True) -> None:
|
|
131
157
|
"""Create a column characteriser for the named column.
|
|
132
158
|
|
|
@@ -169,7 +195,7 @@ class Column:
|
|
|
169
195
|
self.Accum(DT_Boolean()),
|
|
170
196
|
self.Accum(DT_Integer()),
|
|
171
197
|
self.Accum(DT_Long()),
|
|
172
|
-
self.Accum(DT_Decimal()),
|
|
198
|
+
self.Accum(DT_Decimal(), exact_decimals=conf.exact_decimals),
|
|
173
199
|
self.Accum(DT_Float()),
|
|
174
200
|
self.Accum(DT_Character()),
|
|
175
201
|
self.Accum(DT_Varchar()),
|
|
@@ -262,7 +288,7 @@ class Column:
|
|
|
262
288
|
break
|
|
263
289
|
else:
|
|
264
290
|
if ac.dt.precspec:
|
|
265
|
-
if ac.dt.precision is not None and ac.dt.scale is not None:
|
|
291
|
+
if ac.dt.precision is not None and ac.dt.scale is not None and not ac.chooses_float():
|
|
266
292
|
sel_type = ac
|
|
267
293
|
break
|
|
268
294
|
else:
|