execsql2 2.24.0__py3-none-any.whl → 2.25.1__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 (72) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +2 -0
  3. execsql/cli/commands/lint.py +2 -10
  4. execsql/cli/commands/run.py +35 -27
  5. execsql/cli/dsn.py +11 -4
  6. execsql/cli/lint.py +351 -48
  7. execsql/cli/options.py +1 -1
  8. execsql/cli/run.py +56 -19
  9. execsql/config.py +5 -19
  10. execsql/data/execsql.conf.template +14 -4
  11. execsql/db/access.py +29 -8
  12. execsql/db/base.py +40 -8
  13. execsql/db/dsn.py +13 -4
  14. execsql/db/duckdb.py +3 -1
  15. execsql/db/factory.py +11 -2
  16. execsql/db/firebird.py +21 -7
  17. execsql/db/mysql.py +37 -16
  18. execsql/db/oracle.py +49 -16
  19. execsql/db/postgres.py +30 -6
  20. execsql/db/sqlite.py +7 -9
  21. execsql/db/sqlserver.py +25 -8
  22. execsql/exporters/delimited.py +87 -28
  23. execsql/exporters/xlsx.py +24 -9
  24. execsql/exporters/zip.py +1 -2
  25. execsql/format.py +29 -2
  26. execsql/gui/desktop.py +1 -3
  27. execsql/gui/tui.py +2 -2
  28. execsql/importers/base.py +35 -1
  29. execsql/importers/csv.py +3 -1
  30. execsql/manifest.py +33 -22
  31. execsql/metacommands/conditions.py +6 -7
  32. execsql/metacommands/data.py +4 -1
  33. execsql/metacommands/debug.py +2 -4
  34. execsql/metacommands/io_fileops.py +62 -37
  35. execsql/metacommands/io_import.py +0 -1
  36. execsql/metacommands/prompt.py +2 -0
  37. execsql/metacommands/upsert.py +5 -2
  38. execsql/models.py +3 -2
  39. execsql/parser.py +14 -5
  40. execsql/plugins.py +24 -0
  41. execsql/script/engine.py +40 -11
  42. execsql/script/executor.py +9 -5
  43. execsql/script/parser.py +167 -118
  44. execsql/state.py +23 -9
  45. execsql/state.pyi +8 -0
  46. execsql/types.py +22 -13
  47. execsql/utils/auth.py +169 -24
  48. execsql/utils/datetime.py +55 -17
  49. execsql/utils/errors.py +71 -9
  50. execsql/utils/fileio.py +404 -117
  51. execsql/utils/gui.py +3 -0
  52. execsql/utils/mail.py +37 -12
  53. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/METADATA +18 -12
  54. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/RECORD +72 -72
  55. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/README.md +0 -0
  56. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  57. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  58. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  59. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_compare.sql +0 -0
  60. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
  61. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
  62. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
  63. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  64. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  65. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/script_template.sql +0 -0
  66. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
  67. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  68. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  69. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/WHEEL +0 -0
  70. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/entry_points.txt +0 -0
  71. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/LICENSE.txt +0 -0
  72. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/NOTICE +0 -0
execsql/script/parser.py CHANGED
@@ -257,8 +257,15 @@ class _BlockFrame:
257
257
  # ---------------------------------------------------------------------------
258
258
 
259
259
 
260
- def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
261
- """Core parsing logic: convert an iterable of lines into a Script AST."""
260
+ def _parse_lines(lines: Iterable[str], source_name: str, errors: list[ErrInfo] | None = None) -> Script:
261
+ """Core parsing logic: convert an iterable of lines into a Script AST.
262
+
263
+ A block-structure error raises :class:`ErrInfo`, unless *errors* is a
264
+ list: then each error is appended to it and parsing recovers (a closing
265
+ keyword with nothing to close is skipped, one that skips past an open
266
+ block closes that block too, and blocks still open at the end are
267
+ closed), so the whole script is parsed.
268
+ """
262
269
  body: list[Node] = []
263
270
  block_stack: list[_BlockFrame] = []
264
271
  in_block_comment = False
@@ -288,6 +295,38 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
288
295
  return node.body
289
296
  return body # pragma: no cover — defensive fallback
290
297
 
298
+ def _structural_error(exc: ErrInfo) -> None:
299
+ """Raise *exc*, or record it when the caller collects errors and lets parsing recover."""
300
+ if errors is None:
301
+ raise exc
302
+ errors.append(exc)
303
+
304
+ def _close_block(frame: _BlockFrame, line_no: int) -> None:
305
+ """Give a popped block its end line and add it to the enclosing body."""
306
+ frame.node.span = SourceSpan(source_name, frame.start_line, line_no)
307
+ _current_body().append(frame.node)
308
+
309
+ def _pop_block(kind: str, line: str, line_no: int, no_match_msg: str) -> _BlockFrame | None:
310
+ """Pop the open block of *kind* that the closing keyword on *line* ends.
311
+
312
+ ``None`` (after reporting the error) when no such block is open. A
313
+ block of another kind opened inside it and left open is an error of
314
+ its own; when recovering, it is closed here too.
315
+ """
316
+ if block_stack and block_stack[-1].kind == kind:
317
+ return block_stack.pop()
318
+ if errors is None:
319
+ msg = _unclosed_block_msg(block_stack[-1]) if block_stack else no_match_msg
320
+ raise ErrInfo(type="cmd", command_text=line, other_msg=msg)
321
+ if not any(frame.kind == kind for frame in block_stack):
322
+ errors.append(ErrInfo(type="cmd", command_text=line, other_msg=no_match_msg))
323
+ return None
324
+ while block_stack[-1].kind != kind:
325
+ frame = block_stack.pop()
326
+ errors.append(ErrInfo(type="cmd", command_text=line, other_msg=_unclosed_block_msg(frame)))
327
+ _close_block(frame, line_no)
328
+ return block_stack.pop()
329
+
291
330
  def _flush_sql(line_no: int) -> None:
292
331
  """If there's accumulated SQL text, emit a SqlStatement node."""
293
332
  nonlocal sql_accum, sql_start_line
@@ -322,6 +361,7 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
322
361
  if doc_text and isinstance(frame.node, ScriptBlock):
323
362
  frame.node.doc = doc_text
324
363
 
364
+ file_lineno = 0
325
365
  for file_lineno, raw_line in enumerate(lines, 1):
326
366
  line = raw_line.rstrip()
327
367
 
@@ -485,14 +525,15 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
485
525
  _flush_sql(file_lineno)
486
526
  in_sql_block = False
487
527
  if not block_stack or block_stack[-1].kind != "sqlblock":
488
- raise ErrInfo(
489
- type="cmd",
490
- command_text=line,
491
- other_msg=f"Unmatched END SQL on line {file_lineno} of {source_name}.",
528
+ _structural_error(
529
+ ErrInfo(
530
+ type="cmd",
531
+ command_text=line,
532
+ other_msg=f"Unmatched END SQL on line {file_lineno} of {source_name}.",
533
+ ),
492
534
  )
493
- frame = block_stack.pop()
494
- frame.node.span = SourceSpan(source_name, frame.start_line, file_lineno)
495
- _current_body().append(frame.node)
535
+ continue
536
+ _close_block(block_stack.pop(), file_lineno)
496
537
  continue
497
538
 
498
539
  # Inside a SQL block, non-END SQL metacommands are silently
@@ -517,12 +558,18 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
517
558
  if paramexpr:
518
559
  wp = _WITH_PARAMS_RX.match(paramexpr)
519
560
  if not wp:
520
- raise ErrInfo(
521
- type="cmd",
522
- command_text=line,
523
- other_msg=f"Invalid BEGIN SCRIPT metacommand on line {file_lineno} of file {source_name}.",
561
+ _structural_error(
562
+ ErrInfo(
563
+ type="cmd",
564
+ command_text=line,
565
+ other_msg=f"Invalid BEGIN SCRIPT metacommand on line {file_lineno} of file {source_name}.",
566
+ ),
524
567
  )
525
- param_defs = _parse_param_defs(wp.group("params"), file_lineno, source_name)
568
+ else:
569
+ try:
570
+ param_defs = _parse_param_defs(wp.group("params"), file_lineno, source_name)
571
+ except ErrInfo as exc:
572
+ _structural_error(exc)
526
573
  block_stack.append(
527
574
  _BlockFrame(
528
575
  ScriptBlock(
@@ -543,38 +590,36 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
543
590
  end_name = m.group("name")
544
591
  if end_name is not None:
545
592
  end_name = end_name.lower()
546
- if not block_stack:
547
- raise ErrInfo(
548
- type="cmd",
549
- command_text=line,
550
- other_msg=f"END SCRIPT on line {file_lineno} of {source_name} has no matching BEGIN SCRIPT.",
551
- )
552
- if block_stack[-1].kind != "script":
553
- raise ErrInfo(
554
- type="cmd",
555
- command_text=line,
556
- other_msg=_unclosed_block_msg(block_stack[-1]),
557
- )
558
- frame = block_stack[-1]
559
- script_node = cast(ScriptBlock, frame.node)
560
- if end_name is not None and end_name != script_node.name: # type: ignore[union-attr]
561
- raise ErrInfo(
562
- type="cmd",
563
- command_text=line,
564
- other_msg=f"Mismatched script name in the END SCRIPT metacommand on line {file_lineno} of file {source_name}.",
593
+ closed = _pop_block(
594
+ "script",
595
+ line,
596
+ file_lineno,
597
+ f"END SCRIPT on line {file_lineno} of {source_name} has no matching BEGIN SCRIPT.",
598
+ )
599
+ if closed is None:
600
+ continue
601
+ script_node = cast(ScriptBlock, closed.node)
602
+ if end_name is not None and end_name != script_node.name:
603
+ _structural_error(
604
+ ErrInfo(
605
+ type="cmd",
606
+ command_text=line,
607
+ other_msg=f"Mismatched script name in the END SCRIPT metacommand on line {file_lineno} of file {source_name}.",
608
+ ),
565
609
  )
566
610
  if sql_accum:
567
- raise ErrInfo(
568
- type="cmd",
569
- command_text=line,
570
- other_msg=(
571
- f"Incomplete SQL statement\n ({sql_accum})\n"
572
- f"at END SCRIPT metacommand on line {file_lineno} of file {source_name}."
611
+ _structural_error(
612
+ ErrInfo(
613
+ type="cmd",
614
+ command_text=line,
615
+ other_msg=(
616
+ f"Incomplete SQL statement\n ({sql_accum})\n"
617
+ f"at END SCRIPT metacommand on line {file_lineno} of file {source_name}."
618
+ ),
573
619
  ),
574
620
  )
575
- frame = block_stack.pop()
576
- frame.node.span = SourceSpan(source_name, frame.start_line, file_lineno)
577
- _current_body().append(frame.node)
621
+ _flush_sql(file_lineno)
622
+ _close_block(closed, file_lineno)
578
623
  continue
579
624
 
580
625
  # -- IF (block form) --
@@ -612,11 +657,14 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
612
657
  m = _ELSEIF_RX.match(cmd_text)
613
658
  if m:
614
659
  if not block_stack or block_stack[-1].kind != "if":
615
- raise ErrInfo(
616
- type="cmd",
617
- command_text=line,
618
- other_msg=f"ELSEIF on line {file_lineno} of {source_name} has no matching IF.",
660
+ _structural_error(
661
+ ErrInfo(
662
+ type="cmd",
663
+ command_text=line,
664
+ other_msg=f"ELSEIF on line {file_lineno} of {source_name} has no matching IF.",
665
+ ),
619
666
  )
667
+ continue
620
668
  frame = block_stack[-1]
621
669
  frame._in_else = False
622
670
  frame._in_elseif = True
@@ -633,11 +681,14 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
633
681
  m = _ANDIF_RX.match(cmd_text)
634
682
  if m:
635
683
  if not block_stack or block_stack[-1].kind != "if":
636
- raise ErrInfo(
637
- type="cmd",
638
- command_text=line,
639
- other_msg=f"ANDIF on line {file_lineno} of {source_name} has no matching IF.",
684
+ _structural_error(
685
+ ErrInfo(
686
+ type="cmd",
687
+ command_text=line,
688
+ other_msg=f"ANDIF on line {file_lineno} of {source_name} has no matching IF.",
689
+ ),
640
690
  )
691
+ continue
641
692
  modifier = ConditionModifier(
642
693
  kind="AND",
643
694
  condition=m.group("cond").strip(),
@@ -655,11 +706,14 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
655
706
  m = _ORIF_RX.match(cmd_text)
656
707
  if m:
657
708
  if not block_stack or block_stack[-1].kind != "if":
658
- raise ErrInfo(
659
- type="cmd",
660
- command_text=line,
661
- other_msg=f"ORIF on line {file_lineno} of {source_name} has no matching IF.",
709
+ _structural_error(
710
+ ErrInfo(
711
+ type="cmd",
712
+ command_text=line,
713
+ other_msg=f"ORIF on line {file_lineno} of {source_name} has no matching IF.",
714
+ ),
662
715
  )
716
+ continue
663
717
  modifier = ConditionModifier(
664
718
  kind="OR",
665
719
  condition=m.group("cond").strip(),
@@ -677,11 +731,14 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
677
731
  m = _ELSE_RX.match(cmd_text)
678
732
  if m:
679
733
  if not block_stack or block_stack[-1].kind != "if":
680
- raise ErrInfo(
681
- type="cmd",
682
- command_text=line,
683
- other_msg=f"ELSE on line {file_lineno} of {source_name} has no matching IF.",
734
+ _structural_error(
735
+ ErrInfo(
736
+ type="cmd",
737
+ command_text=line,
738
+ other_msg=f"ELSE on line {file_lineno} of {source_name} has no matching IF.",
739
+ ),
684
740
  )
741
+ continue
685
742
  frame = block_stack[-1]
686
743
  frame._in_else = True
687
744
  frame._in_elseif = False
@@ -691,21 +748,14 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
691
748
  # -- ENDIF --
692
749
  m = _ENDIF_RX.match(cmd_text)
693
750
  if m:
694
- if not block_stack:
695
- raise ErrInfo(
696
- type="cmd",
697
- command_text=line,
698
- other_msg=f"ENDIF on line {file_lineno} of {source_name} has no matching IF.",
699
- )
700
- if block_stack[-1].kind != "if":
701
- raise ErrInfo(
702
- type="cmd",
703
- command_text=line,
704
- other_msg=_unclosed_block_msg(block_stack[-1]),
705
- )
706
- frame = block_stack.pop()
707
- frame.node.span = SourceSpan(source_name, frame.start_line, file_lineno)
708
- _current_body().append(frame.node)
751
+ closed = _pop_block(
752
+ "if",
753
+ line,
754
+ file_lineno,
755
+ f"ENDIF on line {file_lineno} of {source_name} has no matching IF.",
756
+ )
757
+ if closed is not None:
758
+ _close_block(closed, file_lineno)
709
759
  continue
710
760
 
711
761
  # -- LOOP --
@@ -728,21 +778,14 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
728
778
  # -- ENDLOOP --
729
779
  m = _ENDLOOP_RX.match(cmd_text)
730
780
  if m:
731
- if not block_stack:
732
- raise ErrInfo(
733
- type="cmd",
734
- command_text=line,
735
- other_msg=f"ENDLOOP on line {file_lineno} of {source_name} has no matching LOOP.",
736
- )
737
- if block_stack[-1].kind != "loop":
738
- raise ErrInfo(
739
- type="cmd",
740
- command_text=line,
741
- other_msg=_unclosed_block_msg(block_stack[-1]),
742
- )
743
- frame = block_stack.pop()
744
- frame.node.span = SourceSpan(source_name, frame.start_line, file_lineno)
745
- _current_body().append(frame.node)
781
+ closed = _pop_block(
782
+ "loop",
783
+ line,
784
+ file_lineno,
785
+ f"ENDLOOP on line {file_lineno} of {source_name} has no matching LOOP.",
786
+ )
787
+ if closed is not None:
788
+ _close_block(closed, file_lineno)
746
789
  continue
747
790
 
748
791
  # -- BEGIN BATCH --
@@ -761,21 +804,14 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
761
804
  # -- END BATCH --
762
805
  m = _END_BATCH_RX.match(cmd_text)
763
806
  if m:
764
- if not block_stack:
765
- raise ErrInfo(
766
- type="cmd",
767
- command_text=line,
768
- other_msg=f"END BATCH on line {file_lineno} of {source_name} has no matching BEGIN BATCH.",
769
- )
770
- if block_stack[-1].kind != "batch":
771
- raise ErrInfo(
772
- type="cmd",
773
- command_text=line,
774
- other_msg=_unclosed_block_msg(block_stack[-1]),
775
- )
776
- frame = block_stack.pop()
777
- frame.node.span = SourceSpan(source_name, frame.start_line, file_lineno)
778
- _current_body().append(frame.node)
807
+ closed = _pop_block(
808
+ "batch",
809
+ line,
810
+ file_lineno,
811
+ f"END BATCH on line {file_lineno} of {source_name} has no matching BEGIN BATCH.",
812
+ )
813
+ if closed is not None:
814
+ _close_block(closed, file_lineno)
779
815
  continue
780
816
 
781
817
  # -- INCLUDE --
@@ -846,24 +882,33 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
846
882
  # Flush any trailing consecutive -- comments
847
883
  _flush_line_comments()
848
884
 
849
- # Unclosed blocks
850
- if block_stack:
851
- frame = block_stack[-1]
852
- raise ErrInfo(
853
- type="error",
854
- other_msg=f"Unmatched {frame.kind.upper()} block starting on line {frame.start_line} at end of file {source_name}.",
885
+ # Unclosed blocks, innermost first
886
+ for frame in reversed(block_stack):
887
+ _structural_error(
888
+ ErrInfo(
889
+ type="error",
890
+ other_msg=f"Unmatched {frame.kind.upper()} block starting on line {frame.start_line} at end of file {source_name}.",
891
+ ),
855
892
  )
856
893
 
857
894
  # Trailing SQL without semicolon
858
895
  if sql_accum:
859
- raise ErrInfo(
860
- type="error",
861
- other_msg=(
862
- f"Incomplete SQL statement starting on line {sql_start_line} at end of file {source_name}."
863
- + (" Metacommands must be prefixed with '-- !x!'." if source_name == "<inline>" else "")
896
+ _structural_error(
897
+ ErrInfo(
898
+ type="error",
899
+ other_msg=(
900
+ f"Incomplete SQL statement starting on line {sql_start_line} at end of file {source_name}."
901
+ + (" Metacommands must be prefixed with '-- !x!'." if source_name == "<inline>" else "")
902
+ ),
864
903
  ),
865
904
  )
866
905
 
906
+ # When recovering, keep the trailing SQL and close what is still open;
907
+ # otherwise both are already empty here.
908
+ _flush_sql(file_lineno)
909
+ while block_stack:
910
+ _close_block(block_stack.pop(), file_lineno)
911
+
867
912
  return Script(source=source_name, body=body)
868
913
 
869
914
 
@@ -872,7 +917,7 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
872
917
  # ---------------------------------------------------------------------------
873
918
 
874
919
 
875
- def parse_script(filename: str, encoding: str = "utf-8") -> Script:
920
+ def parse_script(filename: str, encoding: str = "utf-8", *, errors: list[ErrInfo] | None = None) -> Script:
876
921
  """Parse a ``.sql`` file and return a :class:`Script` AST.
877
922
 
878
923
  Reads the file directly (no dependency on runtime state) so it can be
@@ -881,22 +926,26 @@ def parse_script(filename: str, encoding: str = "utf-8") -> Script:
881
926
  Args:
882
927
  filename: Path to the SQL script file.
883
928
  encoding: File encoding (default ``utf-8``).
929
+ errors: When a list, block-structure errors are appended to it
930
+ and parsing recovers instead of raising (see ``_parse_lines``).
884
931
 
885
932
  Returns:
886
933
  A :class:`Script` tree representing the parsed file.
887
934
  """
888
935
  text = Path(filename).read_text(encoding=encoding)
889
- return _parse_lines(text.splitlines(), filename)
936
+ return _parse_lines(text.splitlines(), filename, errors)
890
937
 
891
938
 
892
- def parse_string(content: str, source_name: str = "<inline>") -> Script:
939
+ def parse_string(content: str, source_name: str = "<inline>", *, errors: list[ErrInfo] | None = None) -> Script:
893
940
  """Parse an inline script string and return a :class:`Script` AST.
894
941
 
895
942
  Args:
896
943
  content: The script content as a string.
897
944
  source_name: Name to use in source spans (default ``"<inline>"``).
945
+ errors: When a list, block-structure errors are appended to it
946
+ and parsing recovers instead of raising (see ``_parse_lines``).
898
947
 
899
948
  Returns:
900
949
  A :class:`Script` tree representing the parsed content.
901
950
  """
902
- return _parse_lines(content.splitlines(), source_name)
951
+ return _parse_lines(content.splitlines(), source_name, errors)
execsql/state.py CHANGED
@@ -101,6 +101,8 @@ if TYPE_CHECKING:
101
101
  SubVarSet,
102
102
  )
103
103
  from execsql.script.executor import ExecutingStatement
104
+ from execsql.exceptions import ErrInfo
105
+ from execsql.manifest import RunManifest
104
106
  from execsql.utils.fileio import FileWriter, Logger, TempFileMgr
105
107
  from execsql.utils.mail import MailSpec
106
108
  from execsql.utils.timer import Timer
@@ -225,6 +227,9 @@ _CONTEXT_ATTRS: frozenset[str] = frozenset(
225
227
  "output",
226
228
  "filewriter",
227
229
  "tier_notices_shown",
230
+ "manifest",
231
+ "halt_error",
232
+ "halt_output_error",
228
233
  # Lazy singletons
229
234
  "counters",
230
235
  "timer",
@@ -280,6 +285,9 @@ class RuntimeContext:
280
285
  "output",
281
286
  "filewriter",
282
287
  "tier_notices_shown",
288
+ "manifest",
289
+ "halt_error",
290
+ "halt_output_error",
283
291
  # Lazy singletons
284
292
  "counters",
285
293
  "timer",
@@ -330,6 +338,12 @@ class RuntimeContext:
330
338
  # DBMS names whose best-effort support notice has already been shown,
331
339
  # so repeated connections to the same backend announce it only once.
332
340
  self.tier_notices_shown: set[str] = set()
341
+ # ``--manifest`` recorder for this run, or None.
342
+ self.manifest: RunManifest | None = None
343
+ # The error that halted this run (set by ``exit_now``), or None.
344
+ self.halt_error: ErrInfo | None = None
345
+ # File output lost when the run halted (set by ``exit_now``), or None.
346
+ self.halt_output_error: ErrInfo | None = None
333
347
 
334
348
  # Lazy singletons
335
349
  self.counters: CounterVars | None = None
@@ -364,9 +378,7 @@ class RuntimeContext:
364
378
  # nesting construct: top-level script, EXECUTE SCRIPT calls, INCLUDE'd
365
379
  # files, IF/ELSEIF/ELSE branches, LOOP iterations, BATCH blocks. See
366
380
  # :class:`ExecFrame` for frame structure. Read by the debug REPL's
367
- # ``.stack`` command and ``DEBUG WRITE COMMANDLISTSTACK`` for genuine
368
- # execution context — the legacy ``commandliststack`` only records
369
- # SCRIPT call frames and is therefore insufficient for the debugger.
381
+ # ``.stack`` command and ``DEBUG WRITE COMMANDLISTSTACK``.
370
382
  self.ast_exec_stack: list[ExecFrame] = []
371
383
 
372
384
  # -----------------------------------------------------------------
@@ -552,7 +564,7 @@ def reset() -> None:
552
564
  """Reset all mutable state to initial values.
553
565
 
554
566
  Intended for use in tests. Creates a fresh :class:`RuntimeContext`,
555
- preserving only the ``filewriter`` subprocess (which is ``atexit``-managed
567
+ preserving only the ``filewriter`` thread (which is ``atexit``-managed
556
568
  and must not be discarded while alive).
557
569
  """
558
570
  ctx = _get_ctx()
@@ -594,8 +606,9 @@ def initialize(
594
606
  Note:
595
607
  ``subvars``, ``status``, ``output``, ``filewriter``, and ``exec_log``
596
608
  are **not** set here because they require CLI-specific arguments
597
- (script path, subprocess queues, local class definitions). Those are
598
- assigned directly in ``_run()`` before and after this call.
609
+ (script path, output settings, local class definitions). Those are
610
+ assigned directly in ``_run()`` (or :func:`execsql.api.run`) before
611
+ and after this call.
599
612
  """
600
613
  import execsql.db.base as _db_base
601
614
  import execsql.exporters.base as _exporters_base
@@ -614,10 +627,11 @@ def initialize(
614
627
  ctx.conditionallist = conditional_table
615
628
 
616
629
  # Discover and register metacommand plugins via entry points.
617
- # Runs here (not at import time) to avoid I/O side effects during import.
618
- from execsql.plugins import discover_metacommand_plugins
630
+ # Runs here (not at import time) to avoid I/O side effects during import,
631
+ # and only once per dispatch table: every run shares the table.
632
+ from execsql.plugins import register_metacommand_plugins_once
619
633
 
620
- discover_metacommand_plugins(dispatch_table)
634
+ register_metacommand_plugins_once(dispatch_table)
621
635
 
622
636
 
623
637
  # ---------------------------------------------------------------------------
execsql/state.pyi CHANGED
@@ -11,6 +11,8 @@ from execsql.config import ConfigData, StatObj
11
11
  from execsql.db.base import DatabasePool
12
12
  from execsql.exporters.base import ExportMetadata, WriteSpec
13
13
  from execsql.script import CounterVars, MetaCommandList, ScriptExecSpec, SubVarSet
14
+ from execsql.exceptions import ErrInfo
15
+ from execsql.manifest import RunManifest
14
16
  from execsql.utils.fileio import FileWriter, Logger, TempFileMgr
15
17
  from execsql.utils.mail import MailSpec
16
18
  from execsql.utils.timer import Timer
@@ -54,6 +56,9 @@ status: StatObj
54
56
  output: Any
55
57
  tier_notices_shown: set[str]
56
58
  filewriter: FileWriter | None
59
+ manifest: RunManifest | None
60
+ halt_error: ErrInfo | None
61
+ halt_output_error: ErrInfo | None
57
62
  counters: CounterVars
58
63
  timer: Timer
59
64
  dbs: DatabasePool
@@ -89,6 +94,9 @@ class RuntimeContext:
89
94
  output: Any
90
95
  tier_notices_shown: set[str]
91
96
  filewriter: FileWriter | None
97
+ manifest: RunManifest | None
98
+ halt_error: ErrInfo | None
99
+ halt_output_error: ErrInfo | None
92
100
  counters: CounterVars | None
93
101
  timer: Timer | None
94
102
  dbs: DatabasePool | None
execsql/types.py CHANGED
@@ -180,6 +180,10 @@ class DT_Timestamp(DataType):
180
180
  dt = parse_datetime(data)
181
181
  if not dt:
182
182
  raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
183
+ # A written UTC offset makes it a timestamptz: stored in a timestamp
184
+ # column, the driver would shift it by the client's time zone.
185
+ if isinstance(data, str) and dt.tzinfo is not None:
186
+ raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
183
187
  return dt
184
188
 
185
189
 
@@ -675,18 +679,6 @@ class DbType:
675
679
  # conv_mod_fn is a function that modifies the result of data_type().from_data(x).
676
680
  self.dialect[data_type] = (dbms_name, length_required, casting_name, conv_mod_fn, precision, scale)
677
681
 
678
- def datatype_name(self, data_type: type[DataType]) -> str:
679
- """Return the DBMS-specific SQL type name for the given DataType class."""
680
- # A convenience function to simplify access to data type names.
681
- try:
682
- return self.dialect[data_type][0]
683
- except Exception as e:
684
- raise DbTypeError(
685
- self.dbms_id,
686
- data_type,
687
- f"{self.dbms_id} DBMS type has no specification for data type {data_type.data_type_name}",
688
- ) from e
689
-
690
682
  def quoted(self, dbms_object: str) -> str:
691
683
  """Quote a database identifier if it contains non-word characters."""
692
684
  if re.search(r"\W", dbms_object):
@@ -799,7 +791,24 @@ dbt_sqlserver.name_datatype(DT_Varchar, "varchar", True)
799
791
  dbt_sqlserver.name_datatype(DT_Text, "varchar(max)")
800
792
  dbt_sqlserver.name_datatype(DT_Binary, "varbinary(max)")
801
793
 
802
- dbt_access = DbType("Access")
794
+
795
+ class _AccessDbType(DbType):
796
+ """Access dialect: decimal data becomes DOUBLE unless ``access_use_numeric`` is set.
797
+
798
+ Read from the run's config at use time, not applied to the shared
799
+ ``dt_xlate``, so one run's setting does not carry over to the next
800
+ ``execsql.run()`` in the same process.
801
+ """
802
+
803
+ def spec_type(self, data_type: type[DataType]) -> type[DataType]:
804
+ import execsql.state as _state
805
+
806
+ if data_type is DT_Decimal and getattr(_state.conf, "access_use_numeric", False):
807
+ return DT_Decimal
808
+ return super().spec_type(data_type)
809
+
810
+
811
+ dbt_access = _AccessDbType("Access")
803
812
  dbt_access.name_datatype(DT_TimestampTZ, "VARCHAR", True)
804
813
  dbt_access.name_datatype(DT_Timestamp, "VARCHAR", True)
805
814
  dbt_access.name_datatype(DT_Date, "VARCHAR", True)