execsql2 2.24.0__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 (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 +271 -28
  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.0.dist-info}/METADATA +18 -12
  54. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/RECORD +72 -72
  55. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +0 -0
  56. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  57. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  58. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  59. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  60. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  61. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  62. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  63. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  64. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  65. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  66. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  67. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  68. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  69. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  70. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/entry_points.txt +0 -0
  71. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  72. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
execsql/plugins.py CHANGED
@@ -61,6 +61,8 @@ adds entries::
61
61
  from __future__ import annotations
62
62
 
63
63
  import logging
64
+ import threading
65
+ import weakref
64
66
  from collections.abc import Callable
65
67
  from importlib.metadata import entry_points
66
68
  from typing import Any
@@ -71,6 +73,7 @@ __all__ = [
71
73
  "ImporterEntry",
72
74
  "ImporterRegistry",
73
75
  "discover_metacommand_plugins",
76
+ "register_metacommand_plugins_once",
74
77
  "discover_exporter_plugins",
75
78
  "discover_importer_plugins",
76
79
  "discover_all_plugins",
@@ -344,6 +347,27 @@ def discover_metacommand_plugins(mcl: Any) -> int:
344
347
  return loaded
345
348
 
346
349
 
350
+ _registered_tables: weakref.WeakSet[Any] = weakref.WeakSet()
351
+ _registration_lock = threading.Lock()
352
+
353
+
354
+ def register_metacommand_plugins_once(mcl: Any) -> int:
355
+ """Register metacommand plugins into *mcl* unless that was already done.
356
+
357
+ Every run in a process shares one dispatch table, so registering on each
358
+ run would prepend another copy of every plugin command each time, while
359
+ other threads may be dispatching from the table.
360
+
361
+ Returns:
362
+ Number of plugins loaded by this call (0 if *mcl* already had them).
363
+ """
364
+ with _registration_lock:
365
+ if mcl in _registered_tables:
366
+ return 0
367
+ _registered_tables.add(mcl)
368
+ return discover_metacommand_plugins(mcl)
369
+
370
+
347
371
  def discover_exporter_plugins(registry: ExporterRegistry | None = None) -> int:
348
372
  """Discover and register exporter plugins.
349
373
 
execsql/script/engine.py CHANGED
@@ -50,12 +50,16 @@ __all__ = [
50
50
  class MetaCommand:
51
51
  """A single entry in the metacommand dispatch table.
52
52
 
53
- Holds a compiled regex, a handler function, and execution-control flags.
54
- Call :meth:`run` with a raw command string to attempt a match and invoke
55
- the handler.
53
+ Holds a regex, a handler function, and execution-control flags. Call
54
+ :meth:`run` with a raw command string to attempt a match and invoke the
55
+ handler.
56
+
57
+ *rx* is a compiled pattern, or a pattern string that is compiled
58
+ (case-insensitively) the first time :attr:`rx` is read. The dispatch
59
+ table registers some 1,700 patterns and a script uses a few dozen, so
60
+ compiling them all up front cost most of a second on every run.
56
61
  """
57
62
 
58
- # A compiled metacommand that can be run if it matches a metacommand command string.
59
63
  def __init__(
60
64
  self,
61
65
  rx: Any,
@@ -64,6 +68,8 @@ class MetaCommand:
64
68
  set_error_flag: bool = True,
65
69
  category: str | None = None,
66
70
  ) -> None:
71
+ self._pattern: str
72
+ self._rx: Any = None
67
73
  self.rx = rx
68
74
  self.exec_fn = exec_func
69
75
  self.description = description
@@ -71,8 +77,32 @@ class MetaCommand:
71
77
  self.category = category
72
78
  self.hitcount = 0
73
79
 
80
+ @property
81
+ def rx(self) -> Any:
82
+ """The compiled pattern, compiled on first use."""
83
+ if self._rx is None:
84
+ self._rx = re.compile(self._pattern, re.I)
85
+ return self._rx
86
+
87
+ @rx.setter
88
+ def rx(self, value: Any) -> None:
89
+ if isinstance(value, str):
90
+ self._pattern, self._rx = value, None
91
+ else:
92
+ self._pattern, self._rx = value.pattern, value
93
+
94
+ @property
95
+ def pattern(self) -> str:
96
+ """The pattern string, without compiling it."""
97
+ return self._pattern
98
+
99
+ @property
100
+ def compiled(self) -> bool:
101
+ """Whether :attr:`rx` has been compiled yet."""
102
+ return self._rx is not None
103
+
74
104
  def __repr__(self) -> str:
75
- return f"MetaCommand({self.rx.pattern!r}, {self.exec_fn!r}, {self.description!r})"
105
+ return f"MetaCommand({self.pattern!r}, {self.exec_fn!r}, {self.description!r})"
76
106
 
77
107
  def run(self, cmd_str: str) -> tuple:
78
108
  """Match *cmd_str* against this entry's regex and, if it matches, invoke the handler.
@@ -165,18 +195,17 @@ class MetaCommandList:
165
195
  """Register one or more regex patterns as a new :class:`MetaCommand` entry.
166
196
 
167
197
  *matching_regexes* may be a single pattern string or a list/tuple of
168
- patterns; each compiles into a separate :class:`MetaCommand` prepended to
169
- the dispatch list so that later registrations take priority.
198
+ patterns; each becomes a separate :class:`MetaCommand` prepended to
199
+ the dispatch list so that later registrations take priority. The
200
+ patterns are compiled when first matched, not here.
170
201
  """
171
202
  if isinstance(matching_regexes, (tuple, list)):
172
203
  raw_patterns = list(matching_regexes)
173
- regexes = [re.compile(rx, re.I) for rx in raw_patterns]
174
204
  else:
175
205
  raw_patterns = [matching_regexes]
176
- regexes = [re.compile(matching_regexes, re.I)]
177
- for rx, raw in zip(regexes, raw_patterns):
206
+ for raw in raw_patterns:
178
207
  mc = MetaCommand(
179
- rx,
208
+ raw,
180
209
  exec_func,
181
210
  description,
182
211
  set_error_flag,
@@ -69,7 +69,7 @@ from execsql import manifest as _manifest
69
69
  from execsql.script.engine import set_dynamic_system_vars, set_static_system_vars, substitute_vars
70
70
  from execsql.script.variables import SubVarSet
71
71
  from execsql.state import ExecFrame, RuntimeContext, active_context, get_context, xcmd_test
72
- from execsql.utils.errors import exception_desc, exit_now, stamp_errinfo
72
+ from execsql.utils.errors import exception_desc, exit_now, logged_error, stamp_errinfo
73
73
 
74
74
  __all__ = ["execute", "execute_input"]
75
75
 
@@ -254,8 +254,10 @@ def _exec_sql(
254
254
  effective_locals = _stack_localvars(ctx) or localvars
255
255
  cmd = substitute_vars(text, effective_locals, ctx=ctx)
256
256
  if _VARLIKE.search(cmd):
257
+ from execsql.utils.fileio import redact
258
+
257
259
  output.write(
258
- f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
260
+ f"Warning: There is a potential un-substituted variable in the command\n {redact(cmd)}\n",
259
261
  )
260
262
  e = None
261
263
  result = None
@@ -282,7 +284,7 @@ def _exec_sql(
282
284
  subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
283
285
  status.sql_error = True
284
286
  if ctx.exec_log is not None:
285
- ctx.exec_log.log_status_info(f"SQL error: {e.errmsg()}")
287
+ ctx.exec_log.log_status_info(f"SQL error: {logged_error(e)}")
286
288
  if status.halt_on_err:
287
289
  exit_now(1, e)
288
290
  status.error_history.append((source, line_no, cmd, e.errmsg()))
@@ -321,8 +323,10 @@ def _exec_metacommand(
321
323
  assert subvars is not None
322
324
 
323
325
  if _VARLIKE.search(cmd):
326
+ from execsql.utils.fileio import redact
327
+
324
328
  output.write(
325
- f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
329
+ f"Warning: There is a potential un-substituted variable in the command\n {redact(cmd)}\n",
326
330
  )
327
331
  e = None
328
332
  try:
@@ -344,7 +348,7 @@ def _exec_metacommand(
344
348
  subvars.add_substitution("$LAST_ERROR", cmd)
345
349
  subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
346
350
  if ctx.exec_log is not None:
347
- ctx.exec_log.log_status_info(f"Metacommand error: {e.errmsg()}")
351
+ ctx.exec_log.log_status_info(f"Metacommand error: {logged_error(e)}")
348
352
  if status.halt_on_metacommand_err:
349
353
  raise e
350
354
  status.error_history.append((source, line_no, cmd, e.errmsg()))
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)