execsql2 2.23.1__py3-none-any.whl → 2.25.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 (92) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +19 -586
  3. execsql/cli/application.py +309 -0
  4. execsql/cli/commands/__init__.py +13 -0
  5. execsql/cli/commands/config.py +205 -0
  6. execsql/cli/commands/format.py +100 -0
  7. execsql/cli/commands/init.py +135 -0
  8. execsql/cli/commands/lint.py +225 -0
  9. execsql/cli/commands/listing.py +81 -0
  10. execsql/cli/commands/run.py +459 -0
  11. execsql/cli/dispatch.py +66 -0
  12. execsql/cli/dsn.py +11 -4
  13. execsql/cli/help.py +175 -24
  14. execsql/cli/lint.py +780 -100
  15. execsql/cli/options.py +108 -0
  16. execsql/cli/run.py +96 -32
  17. execsql/config.py +256 -173
  18. execsql/config_validation.py +282 -0
  19. execsql/data/execsql.conf.template +38 -5
  20. execsql/data/script_header.sql +23 -0
  21. execsql/db/access.py +29 -8
  22. execsql/db/base.py +40 -8
  23. execsql/db/dsn.py +13 -4
  24. execsql/db/duckdb.py +3 -1
  25. execsql/db/factory.py +13 -4
  26. execsql/db/firebird.py +21 -7
  27. execsql/db/mysql.py +37 -16
  28. execsql/db/oracle.py +49 -16
  29. execsql/db/postgres.py +30 -6
  30. execsql/db/sqlite.py +7 -9
  31. execsql/db/sqlserver.py +25 -8
  32. execsql/debug/repl.py +51 -193
  33. execsql/exceptions.py +9 -0
  34. execsql/exporters/delimited.py +87 -28
  35. execsql/exporters/xlsx.py +24 -9
  36. execsql/exporters/zip.py +1 -2
  37. execsql/format.py +115 -76
  38. execsql/gui/desktop.py +1 -3
  39. execsql/gui/tui.py +2 -2
  40. execsql/importers/base.py +35 -1
  41. execsql/importers/csv.py +3 -1
  42. execsql/interactive.py +232 -0
  43. execsql/manifest.py +200 -0
  44. execsql/metacommands/conditions.py +7 -8
  45. execsql/metacommands/control.py +2 -3
  46. execsql/metacommands/data.py +4 -1
  47. execsql/metacommands/debug.py +2 -4
  48. execsql/metacommands/effects.py +124 -0
  49. execsql/metacommands/io_fileops.py +62 -37
  50. execsql/metacommands/io_import.py +0 -1
  51. execsql/metacommands/prompt.py +2 -0
  52. execsql/metacommands/upsert.py +5 -2
  53. execsql/models.py +3 -2
  54. execsql/parser.py +14 -5
  55. execsql/plugins.py +24 -0
  56. execsql/script/__init__.py +0 -9
  57. execsql/script/ast.py +1 -1
  58. execsql/script/engine.py +46 -104
  59. execsql/script/executor.py +162 -39
  60. execsql/script/parser.py +167 -118
  61. execsql/state.py +34 -11
  62. execsql/state.pyi +11 -0
  63. execsql/types.py +22 -13
  64. execsql/utils/auth.py +169 -24
  65. execsql/utils/color.py +18 -0
  66. execsql/utils/datetime.py +55 -17
  67. execsql/utils/errors.py +82 -10
  68. execsql/utils/fileio.py +404 -117
  69. execsql/utils/gui.py +3 -0
  70. execsql/utils/mail.py +37 -12
  71. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
  72. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
  73. execsql2-2.25.0.dist-info/RECORD +140 -0
  74. execsql2-2.25.0.dist-info/entry_points.txt +2 -0
  75. execsql2-2.23.1.dist-info/RECORD +0 -124
  76. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  77. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  78. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  79. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  80. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  81. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  82. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  83. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  84. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  85. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  86. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  87. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  88. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  89. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  90. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  91. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  92. {execsql2-2.23.1.dist-info → execsql2-2.25.0.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
@@ -33,6 +33,7 @@ import threading
33
33
  import types
34
34
  from dataclasses import dataclass
35
35
  from typing import TYPE_CHECKING, Any
36
+ from collections.abc import Callable
36
37
 
37
38
 
38
39
  @dataclass
@@ -96,10 +97,12 @@ if TYPE_CHECKING:
96
97
  from execsql.script import (
97
98
  CounterVars,
98
99
  MetaCommandList,
99
- ScriptCmd,
100
100
  ScriptExecSpec,
101
101
  SubVarSet,
102
102
  )
103
+ from execsql.script.executor import ExecutingStatement
104
+ from execsql.exceptions import ErrInfo
105
+ from execsql.manifest import RunManifest
103
106
  from execsql.utils.fileio import FileWriter, Logger, TempFileMgr
104
107
  from execsql.utils.mail import MailSpec
105
108
  from execsql.utils.timer import Timer
@@ -143,6 +146,7 @@ __all__ = [
143
146
  "profile_data",
144
147
  # Debug REPL
145
148
  "step_mode",
149
+ "prompt_input",
146
150
  # Version
147
151
  "primary_vno",
148
152
  "secondary_vno",
@@ -223,6 +227,9 @@ _CONTEXT_ATTRS: frozenset[str] = frozenset(
223
227
  "output",
224
228
  "filewriter",
225
229
  "tier_notices_shown",
230
+ "manifest",
231
+ "halt_error",
232
+ "halt_output_error",
226
233
  # Lazy singletons
227
234
  "counters",
228
235
  "timer",
@@ -239,6 +246,7 @@ _CONTEXT_ATTRS: frozenset[str] = frozenset(
239
246
  "profile_data",
240
247
  # Debug REPL
241
248
  "step_mode",
249
+ "prompt_input",
242
250
  # AST executor
243
251
  "ast_scripts",
244
252
  "include_chain",
@@ -277,6 +285,9 @@ class RuntimeContext:
277
285
  "output",
278
286
  "filewriter",
279
287
  "tier_notices_shown",
288
+ "manifest",
289
+ "halt_error",
290
+ "halt_output_error",
280
291
  # Lazy singletons
281
292
  "counters",
282
293
  "timer",
@@ -293,6 +304,7 @@ class RuntimeContext:
293
304
  "profile_data",
294
305
  # Debug REPL
295
306
  "step_mode",
307
+ "prompt_input",
296
308
  # AST executor
297
309
  "ast_scripts",
298
310
  "include_chain",
@@ -305,7 +317,7 @@ class RuntimeContext:
305
317
  self.logfile_encoding: str = "utf8"
306
318
 
307
319
  # Runtime flags
308
- self.last_command: ScriptCmd | None = None
320
+ self.last_command: ExecutingStatement | None = None
309
321
  self.upass: str | None = None
310
322
  self.err_halt_writespec: WriteSpec | None = None
311
323
  self.err_halt_email: MailSpec | None = None
@@ -326,6 +338,12 @@ class RuntimeContext:
326
338
  # DBMS names whose best-effort support notice has already been shown,
327
339
  # so repeated connections to the same backend announce it only once.
328
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
329
347
 
330
348
  # Lazy singletons
331
349
  self.counters: CounterVars | None = None
@@ -347,6 +365,11 @@ class RuntimeContext:
347
365
 
348
366
  # Debug REPL — True after a ``next`` command; engine re-enters REPL after next statement.
349
367
  self.step_mode: bool = False
368
+ # Set while input typed at a BREAKPOINT prompt runs:
369
+ # called with each SQL statement's result, as ``(column_names, rows)``
370
+ # or ``None``. An error that would halt the run instead returns to the
371
+ # prompt (see ``exit_now``).
372
+ self.prompt_input: Callable[[tuple[list[str], list] | None], None] | None = None
350
373
 
351
374
  # AST executor — script block registry and include-chain tracking.
352
375
  self.ast_scripts: dict = {}
@@ -355,9 +378,7 @@ class RuntimeContext:
355
378
  # nesting construct: top-level script, EXECUTE SCRIPT calls, INCLUDE'd
356
379
  # files, IF/ELSEIF/ELSE branches, LOOP iterations, BATCH blocks. See
357
380
  # :class:`ExecFrame` for frame structure. Read by the debug REPL's
358
- # ``.stack`` command and ``DEBUG WRITE COMMANDLISTSTACK`` for genuine
359
- # execution context — the legacy ``commandliststack`` only records
360
- # SCRIPT call frames and is therefore insufficient for the debugger.
381
+ # ``.stack`` command and ``DEBUG WRITE COMMANDLISTSTACK``.
361
382
  self.ast_exec_stack: list[ExecFrame] = []
362
383
 
363
384
  # -----------------------------------------------------------------
@@ -543,7 +564,7 @@ def reset() -> None:
543
564
  """Reset all mutable state to initial values.
544
565
 
545
566
  Intended for use in tests. Creates a fresh :class:`RuntimeContext`,
546
- preserving only the ``filewriter`` subprocess (which is ``atexit``-managed
567
+ preserving only the ``filewriter`` thread (which is ``atexit``-managed
547
568
  and must not be discarded while alive).
548
569
  """
549
570
  ctx = _get_ctx()
@@ -585,8 +606,9 @@ def initialize(
585
606
  Note:
586
607
  ``subvars``, ``status``, ``output``, ``filewriter``, and ``exec_log``
587
608
  are **not** set here because they require CLI-specific arguments
588
- (script path, subprocess queues, local class definitions). Those are
589
- 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.
590
612
  """
591
613
  import execsql.db.base as _db_base
592
614
  import execsql.exporters.base as _exporters_base
@@ -605,10 +627,11 @@ def initialize(
605
627
  ctx.conditionallist = conditional_table
606
628
 
607
629
  # Discover and register metacommand plugins via entry points.
608
- # Runs here (not at import time) to avoid I/O side effects during import.
609
- 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
610
633
 
611
- discover_metacommand_plugins(dispatch_table)
634
+ register_metacommand_plugins_once(dispatch_table)
612
635
 
613
636
 
614
637
  # ---------------------------------------------------------------------------
execsql/state.pyi CHANGED
@@ -5,11 +5,14 @@ import threading
5
5
  from contextlib import AbstractContextManager
6
6
  from dataclasses import dataclass
7
7
  from typing import Any
8
+ from collections.abc import Callable
8
9
 
9
10
  from execsql.config import ConfigData, StatObj
10
11
  from execsql.db.base import DatabasePool
11
12
  from execsql.exporters.base import ExportMetadata, WriteSpec
12
13
  from execsql.script import CounterVars, MetaCommandList, ScriptExecSpec, SubVarSet
14
+ from execsql.exceptions import ErrInfo
15
+ from execsql.manifest import RunManifest
13
16
  from execsql.utils.fileio import FileWriter, Logger, TempFileMgr
14
17
  from execsql.utils.mail import MailSpec
15
18
  from execsql.utils.timer import Timer
@@ -53,6 +56,9 @@ status: StatObj
53
56
  output: Any
54
57
  tier_notices_shown: set[str]
55
58
  filewriter: FileWriter | None
59
+ manifest: RunManifest | None
60
+ halt_error: ErrInfo | None
61
+ halt_output_error: ErrInfo | None
56
62
  counters: CounterVars
57
63
  timer: Timer
58
64
  dbs: DatabasePool
@@ -65,6 +71,7 @@ gui_manager_queue: Any
65
71
  gui_manager_thread: threading.Thread | None
66
72
  profile_data: list[tuple[Any, ...]] | None
67
73
  step_mode: bool
74
+ prompt_input: Callable[[tuple[list[str], list[Any]] | None], None] | None
68
75
  ast_scripts: dict[str, Any]
69
76
  include_chain: list[str]
70
77
  ast_exec_stack: list[ExecFrame]
@@ -87,6 +94,9 @@ class RuntimeContext:
87
94
  output: Any
88
95
  tier_notices_shown: set[str]
89
96
  filewriter: FileWriter | None
97
+ manifest: RunManifest | None
98
+ halt_error: ErrInfo | None
99
+ halt_output_error: ErrInfo | None
90
100
  counters: CounterVars | None
91
101
  timer: Timer | None
92
102
  dbs: DatabasePool | None
@@ -99,6 +109,7 @@ class RuntimeContext:
99
109
  gui_manager_thread: threading.Thread | None
100
110
  profile_data: list[tuple[Any, ...]] | None
101
111
  step_mode: bool
112
+ prompt_input: Callable[[tuple[list[str], list[Any]] | None], None] | None
102
113
  ast_scripts: dict[str, Any]
103
114
  include_chain: list[str]
104
115
  ast_exec_stack: list[ExecFrame]