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.
- 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 +351 -48
- 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.1.dist-info}/METADATA +18 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/RECORD +72 -72
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/WHEEL +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/entry_points.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/LICENSE.txt +0 -0
- {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
|
-
|
|
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)
|
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
|
|
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``
|
|
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,
|
|
598
|
-
assigned directly in ``_run()``
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|