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.
- execsql/api.py +169 -160
- execsql/cli/__init__.py +2 -0
- execsql/cli/commands/lint.py +2 -10
- execsql/cli/commands/run.py +35 -27
- execsql/cli/dsn.py +11 -4
- execsql/cli/lint.py +271 -28
- execsql/cli/options.py +1 -1
- execsql/cli/run.py +56 -19
- execsql/config.py +5 -19
- execsql/data/execsql.conf.template +14 -4
- execsql/db/access.py +29 -8
- execsql/db/base.py +40 -8
- execsql/db/dsn.py +13 -4
- execsql/db/duckdb.py +3 -1
- execsql/db/factory.py +11 -2
- execsql/db/firebird.py +21 -7
- execsql/db/mysql.py +37 -16
- execsql/db/oracle.py +49 -16
- execsql/db/postgres.py +30 -6
- execsql/db/sqlite.py +7 -9
- execsql/db/sqlserver.py +25 -8
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +29 -2
- execsql/gui/desktop.py +1 -3
- execsql/gui/tui.py +2 -2
- execsql/importers/base.py +35 -1
- execsql/importers/csv.py +3 -1
- execsql/manifest.py +33 -22
- execsql/metacommands/conditions.py +6 -7
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/io_fileops.py +62 -37
- execsql/metacommands/io_import.py +0 -1
- execsql/metacommands/prompt.py +2 -0
- execsql/metacommands/upsert.py +5 -2
- execsql/models.py +3 -2
- execsql/parser.py +14 -5
- execsql/plugins.py +24 -0
- execsql/script/engine.py +40 -11
- execsql/script/executor.py +9 -5
- execsql/script/parser.py +167 -118
- execsql/state.py +23 -9
- execsql/state.pyi +8 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +71 -9
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/METADATA +18 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/RECORD +72 -72
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {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
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
177
|
-
for rx, raw in zip(regexes, raw_patterns):
|
|
206
|
+
for raw in raw_patterns:
|
|
178
207
|
mc = MetaCommand(
|
|
179
|
-
|
|
208
|
+
raw,
|
|
180
209
|
exec_func,
|
|
181
210
|
description,
|
|
182
211
|
set_error_flag,
|
execsql/script/executor.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if
|
|
701
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
if
|
|
738
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
if
|
|
771
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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)
|