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.
Files changed (43) hide show
  1. execsql/api.py +17 -8
  2. execsql/cli/lint.py +54 -6
  3. execsql/cli/run.py +5 -10
  4. execsql/config.py +2 -0
  5. execsql/data/execsql.conf.template +17 -7
  6. execsql/db/base.py +12 -2
  7. execsql/db/duckdb.py +8 -1
  8. execsql/debug/repl.py +2 -2
  9. execsql/metacommands/connect.py +10 -9
  10. execsql/metacommands/data.py +7 -1
  11. execsql/metacommands/dispatch.py +7 -0
  12. execsql/metacommands/io_fileops.py +6 -3
  13. execsql/metacommands/io_write.py +3 -1
  14. execsql/metacommands/reference.py +1 -0
  15. execsql/metacommands/script_ext.py +13 -10
  16. execsql/metacommands/system.py +13 -6
  17. execsql/models.py +30 -4
  18. execsql/script/ast.py +21 -0
  19. execsql/script/control.py +63 -3
  20. execsql/script/executor.py +379 -229
  21. execsql/script/parser.py +102 -52
  22. execsql/state.py +23 -0
  23. execsql/utils/errors.py +24 -6
  24. execsql/utils/sigterm.py +154 -0
  25. {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/METADATA +2 -2
  26. {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/RECORD +43 -42
  27. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/README.md +0 -0
  28. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  29. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  30. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  31. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  32. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  33. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  34. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  35. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  36. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/script_template.sql +0 -0
  37. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  38. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  39. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  40. {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/WHEEL +0 -0
  41. {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/entry_points.txt +0 -0
  42. {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/licenses/LICENSE.txt +0 -0
  43. {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
- """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
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
- """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
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
- 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(
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 INCLUDE / EXECUTE SCRIPT targets must
323
- # resolve. When set, attempts to include files outside this root via
324
- # ../, absolute paths, drive letters, or UNC paths are rejected with
325
- # 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).
326
334
  #include_root=
327
335
 
328
336
  # Root directory under which SERVE targets must resolve. Same
329
- # 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.
330
339
  #serve_root=
331
340
 
332
341
  # Root directory under which Jinja2 / string.Template loader paths
333
- # must resolve. Same containment semantics as include_root.
334
- # 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.
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
- 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")
@@ -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")
@@ -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
- # 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")
@@ -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 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/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: