execsql2 2.15.8__py3-none-any.whl → 2.16.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/__init__.py +8 -3
- execsql/api.py +580 -0
- execsql/cli/__init__.py +123 -0
- execsql/cli/lint_ast.py +439 -0
- execsql/cli/run.py +113 -102
- execsql/config.py +29 -4
- execsql/db/access.py +1 -0
- execsql/db/base.py +4 -1
- execsql/db/dsn.py +3 -2
- execsql/db/duckdb.py +1 -1
- execsql/db/factory.py +3 -0
- execsql/db/firebird.py +2 -1
- execsql/db/mysql.py +2 -1
- execsql/db/oracle.py +2 -1
- execsql/db/postgres.py +2 -1
- execsql/db/sqlite.py +1 -1
- execsql/db/sqlserver.py +3 -2
- execsql/debug/repl.py +27 -10
- execsql/exporters/base.py +6 -4
- execsql/exporters/delimited.py +11 -3
- execsql/exporters/pretty.py +9 -12
- execsql/gui/tui.py +59 -2
- execsql/metacommands/__init__.py +3 -0
- execsql/metacommands/conditions.py +20 -2
- execsql/metacommands/connect.py +1 -1
- execsql/metacommands/control.py +8 -14
- execsql/metacommands/debug.py +6 -4
- execsql/metacommands/io_export.py +117 -315
- execsql/metacommands/io_fileops.py +7 -13
- execsql/metacommands/io_write.py +1 -1
- execsql/metacommands/script_ext.py +8 -5
- execsql/metacommands/upsert.py +40 -0
- execsql/models.py +8 -12
- execsql/plugins.py +414 -0
- execsql/script/__init__.py +36 -12
- execsql/script/ast.py +562 -0
- execsql/script/engine.py +59 -368
- execsql/script/executor.py +833 -0
- execsql/script/parser.py +663 -0
- execsql/script/variables.py +11 -0
- execsql/state.py +55 -2
- execsql/utils/crypto.py +14 -10
- execsql/utils/errors.py +31 -8
- execsql/utils/gui.py +139 -17
- execsql/utils/mail.py +15 -12
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/METADATA +59 -1
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/RECORD +66 -60
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.15.8.data → execsql2-2.16.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/WHEEL +0 -0
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.15.8.dist-info → execsql2-2.16.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/__init__.py
CHANGED
|
@@ -273,6 +273,11 @@ def main(
|
|
|
273
273
|
"--dump-keywords",
|
|
274
274
|
help="Dump all metacommand keywords as JSON and exit.",
|
|
275
275
|
),
|
|
276
|
+
list_plugins: bool = typer.Option(
|
|
277
|
+
False,
|
|
278
|
+
"--list-plugins",
|
|
279
|
+
help="List all discovered plugins (metacommands, exporters, importers) and exit.",
|
|
280
|
+
),
|
|
276
281
|
profile: bool = typer.Option(
|
|
277
282
|
False,
|
|
278
283
|
"--profile",
|
|
@@ -283,6 +288,24 @@ def main(
|
|
|
283
288
|
"--profile-limit",
|
|
284
289
|
help="Number of top statements to show in the --profile timing summary (default: 20).",
|
|
285
290
|
),
|
|
291
|
+
config_file: str | None = typer.Option(
|
|
292
|
+
None,
|
|
293
|
+
"--config",
|
|
294
|
+
metavar="FILE",
|
|
295
|
+
help=(
|
|
296
|
+
"Path to an execsql configuration file. "
|
|
297
|
+
"Loaded after the implicit search paths so its values take precedence. "
|
|
298
|
+
"The file may chain additional configs via its [cyan][config][/cyan] section."
|
|
299
|
+
),
|
|
300
|
+
),
|
|
301
|
+
parse_tree: bool = typer.Option(
|
|
302
|
+
False,
|
|
303
|
+
"--parse-tree",
|
|
304
|
+
help=(
|
|
305
|
+
"Parse the script into an abstract syntax tree and print the tree structure. "
|
|
306
|
+
"Does not connect to a database or execute anything."
|
|
307
|
+
),
|
|
308
|
+
),
|
|
286
309
|
debug: bool = typer.Option(
|
|
287
310
|
False,
|
|
288
311
|
"--debug",
|
|
@@ -370,12 +393,56 @@ def main(
|
|
|
370
393
|
_console.print_json(_json.dumps(data, indent=2))
|
|
371
394
|
raise typer.Exit()
|
|
372
395
|
|
|
396
|
+
if list_plugins:
|
|
397
|
+
from execsql.plugins import (
|
|
398
|
+
EXPORTER_GROUP,
|
|
399
|
+
IMPORTER_GROUP,
|
|
400
|
+
METACOMMAND_GROUP,
|
|
401
|
+
_load_entry_points,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
_console.print("\n[bold cyan]Installed plugins:[/bold cyan]\n")
|
|
405
|
+
|
|
406
|
+
mc_plugins = _load_entry_points(METACOMMAND_GROUP)
|
|
407
|
+
ex_plugins = _load_entry_points(EXPORTER_GROUP)
|
|
408
|
+
im_plugins = _load_entry_points(IMPORTER_GROUP)
|
|
409
|
+
|
|
410
|
+
if not mc_plugins and not ex_plugins and not im_plugins:
|
|
411
|
+
_console.print(" [dim]No plugins found.[/dim]")
|
|
412
|
+
_console.print()
|
|
413
|
+
_console.print(
|
|
414
|
+
" Plugins are discovered via Python entry points.\n"
|
|
415
|
+
" See the execsql documentation for how to create plugins.",
|
|
416
|
+
)
|
|
417
|
+
else:
|
|
418
|
+
if mc_plugins:
|
|
419
|
+
_console.print(f" [bold]Metacommands[/bold] ({len(mc_plugins)}):")
|
|
420
|
+
for name, _ in mc_plugins:
|
|
421
|
+
_console.print(f" - {name}")
|
|
422
|
+
if ex_plugins:
|
|
423
|
+
_console.print(f" [bold]Exporters[/bold] ({len(ex_plugins)}):")
|
|
424
|
+
for name, _ in ex_plugins:
|
|
425
|
+
_console.print(f" - {name}")
|
|
426
|
+
if im_plugins:
|
|
427
|
+
_console.print(f" [bold]Importers[/bold] ({len(im_plugins)}):")
|
|
428
|
+
for name, _ in im_plugins:
|
|
429
|
+
_console.print(f" - {name}")
|
|
430
|
+
|
|
431
|
+
_console.print()
|
|
432
|
+
raise typer.Exit()
|
|
433
|
+
|
|
373
434
|
if online_help:
|
|
374
435
|
import webbrowser
|
|
375
436
|
|
|
376
437
|
webbrowser.open("https://execsql2.readthedocs.io/en/latest/", new=2, autoraise=True)
|
|
377
438
|
raise typer.Exit()
|
|
378
439
|
|
|
440
|
+
if config_file and not Path(config_file).is_file():
|
|
441
|
+
_err_console.print(
|
|
442
|
+
f"[bold red]Error:[/bold red] Config file [cyan]{config_file!r}[/cyan] does not exist.",
|
|
443
|
+
)
|
|
444
|
+
raise typer.Exit(code=2)
|
|
445
|
+
|
|
379
446
|
positional = args or []
|
|
380
447
|
if command is not None:
|
|
381
448
|
script_name = None # inline mode — no script file
|
|
@@ -425,6 +492,61 @@ def main(
|
|
|
425
492
|
)
|
|
426
493
|
raise typer.Exit(code=2)
|
|
427
494
|
|
|
495
|
+
# ------------------------------------------------------------------
|
|
496
|
+
# Parse tree: parse script into AST and print tree structure
|
|
497
|
+
# ------------------------------------------------------------------
|
|
498
|
+
if parse_tree:
|
|
499
|
+
from execsql.script.ast import format_tree
|
|
500
|
+
from execsql.script.parser import parse_script, parse_string
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
if command is not None:
|
|
504
|
+
tree = parse_string(command.replace("\\n", "\n").replace("\\t", "\t"), "<inline>")
|
|
505
|
+
elif script_name is not None:
|
|
506
|
+
encoding = script_encoding or "utf-8"
|
|
507
|
+
tree = parse_script(script_name, encoding=encoding)
|
|
508
|
+
else:
|
|
509
|
+
_err_console.print(
|
|
510
|
+
"[bold red]Error:[/bold red] --parse-tree requires a script file or -c command.",
|
|
511
|
+
)
|
|
512
|
+
raise typer.Exit(code=1)
|
|
513
|
+
except ErrInfo as exc:
|
|
514
|
+
_err_console.print(f"[bold red]Parse error:[/bold red] {exc.errmsg()}")
|
|
515
|
+
raise typer.Exit(code=1) from exc
|
|
516
|
+
|
|
517
|
+
_console.print(format_tree(tree))
|
|
518
|
+
raise typer.Exit()
|
|
519
|
+
|
|
520
|
+
# ------------------------------------------------------------------
|
|
521
|
+
# Lint: AST-based static analysis (no DB connection needed)
|
|
522
|
+
# ------------------------------------------------------------------
|
|
523
|
+
if lint:
|
|
524
|
+
from execsql.cli.lint import _print_lint_results
|
|
525
|
+
from execsql.cli.lint_ast import lint_ast
|
|
526
|
+
from execsql.script.parser import parse_script, parse_string
|
|
527
|
+
|
|
528
|
+
label = script_name or "<inline>"
|
|
529
|
+
try:
|
|
530
|
+
if command is not None:
|
|
531
|
+
tree = parse_string(command.replace("\\n", "\n").replace("\\t", "\t"), "<inline>")
|
|
532
|
+
elif script_name is not None:
|
|
533
|
+
encoding = script_encoding or "utf-8"
|
|
534
|
+
tree = parse_script(script_name, encoding=encoding)
|
|
535
|
+
else:
|
|
536
|
+
_err_console.print(
|
|
537
|
+
"[bold red]Error:[/bold red] --lint requires a script file or -c command.",
|
|
538
|
+
)
|
|
539
|
+
raise typer.Exit(code=1)
|
|
540
|
+
except ErrInfo as exc:
|
|
541
|
+
# Parse failure IS a lint error — report it
|
|
542
|
+
issues = [("error", label, 0, f"Parse error: {exc.errmsg()}")]
|
|
543
|
+
exit_code = _print_lint_results(issues, label)
|
|
544
|
+
raise typer.Exit(code=exit_code) from exc
|
|
545
|
+
|
|
546
|
+
issues = lint_ast(tree, script_path=script_name)
|
|
547
|
+
exit_code = _print_lint_results(issues, label)
|
|
548
|
+
raise typer.Exit(code=exit_code)
|
|
549
|
+
|
|
428
550
|
# ------------------------------------------------------------------
|
|
429
551
|
# Delegate to the real main implementation
|
|
430
552
|
# ------------------------------------------------------------------
|
|
@@ -458,6 +580,7 @@ def main(
|
|
|
458
580
|
ping=ping,
|
|
459
581
|
lint=lint,
|
|
460
582
|
debug=debug,
|
|
583
|
+
config_file=config_file,
|
|
461
584
|
)
|
|
462
585
|
|
|
463
586
|
|
execsql/cli/lint_ast.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""AST-based static analysis (lint) for execsql scripts.
|
|
2
|
+
|
|
3
|
+
Performs the same checks as :mod:`execsql.cli.lint` but operates on the
|
|
4
|
+
:class:`~execsql.script.ast.Script` tree instead of a flat
|
|
5
|
+
:class:`~execsql.script.engine.CommandList`.
|
|
6
|
+
|
|
7
|
+
Advantages over the flat linter:
|
|
8
|
+
|
|
9
|
+
- **No runtime state required** — works with the AST parser alone, so it
|
|
10
|
+
can run as an early exit in the CLI without initialising ``_state``.
|
|
11
|
+
- **Structural validation is free** — the AST parser already rejects
|
|
12
|
+
unmatched IF/LOOP/BATCH/SCRIPT blocks at parse time with precise source
|
|
13
|
+
spans. This linter only needs to report variable and INCLUDE issues.
|
|
14
|
+
- **Script blocks are in the tree** — ``EXECUTE SCRIPT`` targets are
|
|
15
|
+
resolved by finding :class:`ScriptBlock` nodes, not by looking up
|
|
16
|
+
``_state.savedscripts``.
|
|
17
|
+
|
|
18
|
+
Checks performed
|
|
19
|
+
----------------
|
|
20
|
+
1. **Parse errors** — the AST parser rejects unmatched blocks, so any
|
|
21
|
+
parse failure is reported as an error with the parser's message.
|
|
22
|
+
2. **Potentially undefined variables** — same heuristic as the flat linter.
|
|
23
|
+
3. **EXECUTE SCRIPT target resolution** — warns when a target name does
|
|
24
|
+
not correspond to a :class:`ScriptBlock` in the same file.
|
|
25
|
+
4. **Missing INCLUDE files** — warns when the file does not exist on disk.
|
|
26
|
+
5. **Empty script** — warns when no nodes were parsed.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from execsql.script.ast import (
|
|
35
|
+
BatchBlock,
|
|
36
|
+
IfBlock,
|
|
37
|
+
IncludeDirective,
|
|
38
|
+
LoopBlock,
|
|
39
|
+
MetaCommandStatement,
|
|
40
|
+
Node,
|
|
41
|
+
Script,
|
|
42
|
+
ScriptBlock,
|
|
43
|
+
SqlBlock,
|
|
44
|
+
SqlStatement,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = ["lint_ast"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Variable-related patterns (shared with the flat linter)
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
_RX_SUB = re.compile(r"^\s*SUB\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
55
|
+
_RX_SUB_EMPTY = re.compile(r"^\s*SUB_EMPTY\s+(?P<name>[+~]?\w+)\s*$", re.I)
|
|
56
|
+
_RX_SUB_ADD = re.compile(r"^\s*SUB_ADD\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
57
|
+
_RX_SUB_APPEND = re.compile(r"^\s*SUB_APPEND\s+(?P<name>[+~]?\w+)\s", re.I)
|
|
58
|
+
_RX_SUBDATA = re.compile(r"^\s*SUBDATA\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
59
|
+
_RX_SUB_INI = re.compile(
|
|
60
|
+
r'^\s*SUB_INI\s+(?:FILE\s+)?(?:"(?P<qfile>[^"]+)"|(?P<file>\S+))'
|
|
61
|
+
r"(?:\s+SECTION)?\s+(?P<section>\w+)\s*$",
|
|
62
|
+
re.I,
|
|
63
|
+
)
|
|
64
|
+
_RX_SELECTSUB = re.compile(r"^\s*(?:SELECT_?SUB|PROMPT\s+SELECT_?SUB)\s+", re.I)
|
|
65
|
+
_RX_SUB_LOCAL = re.compile(r"^\s*SUB_LOCAL\s+(?P<name>\w+)\s+", re.I)
|
|
66
|
+
_RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name>\w+)\s", re.I)
|
|
67
|
+
_RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>\w+)\s+", re.I)
|
|
68
|
+
_RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>\w+)\s+", re.I)
|
|
69
|
+
_RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<name>\w+)\s+", re.I)
|
|
70
|
+
|
|
71
|
+
_RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Issue tuple helpers
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _error(source: str, line_no: int, message: str) -> _Issue:
|
|
82
|
+
return ("error", source, line_no, message)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _warning(source: str, line_no: int, message: str) -> _Issue:
|
|
86
|
+
return ("warning", source, line_no, message)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Built-in variable discovery (reuse from flat linter)
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _discover_builtin_vars() -> frozenset[str]:
|
|
95
|
+
"""Scan the execsql package source for ``$VARNAME`` system variables."""
|
|
96
|
+
import importlib.util
|
|
97
|
+
|
|
98
|
+
_rx_add_sub = re.compile(r'(?:(?<!\w)add_substitution|(?<!\w)sv)\s*\(\s*["\'](\$\w+)["\']')
|
|
99
|
+
_rx_lazy = re.compile(r'register_lazy\s*\(\s*["\'](\$\w+)["\']')
|
|
100
|
+
|
|
101
|
+
names: set[str] = set()
|
|
102
|
+
|
|
103
|
+
spec = importlib.util.find_spec("execsql")
|
|
104
|
+
if spec is None or spec.submodule_search_locations is None:
|
|
105
|
+
return frozenset(names)
|
|
106
|
+
|
|
107
|
+
pkg_dir = Path(spec.submodule_search_locations[0])
|
|
108
|
+
for src_file in pkg_dir.rglob("*.py"):
|
|
109
|
+
try:
|
|
110
|
+
text = src_file.read_text(encoding="utf-8")
|
|
111
|
+
except OSError:
|
|
112
|
+
continue
|
|
113
|
+
for m in _rx_add_sub.finditer(text):
|
|
114
|
+
names.add(m.group(1).lstrip("$").upper())
|
|
115
|
+
for m in _rx_lazy.finditer(text):
|
|
116
|
+
names.add(m.group(1).lstrip("$").upper())
|
|
117
|
+
|
|
118
|
+
return frozenset(names)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_BUILTIN_VARS: frozenset[str] | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_builtin_vars() -> frozenset[str]:
|
|
125
|
+
"""Return the cached set of built-in variable names, discovering on first call."""
|
|
126
|
+
global _BUILTIN_VARS
|
|
127
|
+
if _BUILTIN_VARS is None:
|
|
128
|
+
_BUILTIN_VARS = _discover_builtin_vars()
|
|
129
|
+
return _BUILTIN_VARS
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# AST walker helpers
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _collect_script_blocks(script: Script) -> dict[str, ScriptBlock]:
|
|
138
|
+
"""Build a name → ScriptBlock lookup from all ScriptBlock nodes in the tree."""
|
|
139
|
+
blocks: dict[str, ScriptBlock] = {}
|
|
140
|
+
for node in script.walk():
|
|
141
|
+
if isinstance(node, ScriptBlock):
|
|
142
|
+
blocks[node.name] = node
|
|
143
|
+
return blocks
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _collect_defined_vars_from_nodes(
|
|
147
|
+
nodes: list[Node],
|
|
148
|
+
script_blocks: dict[str, ScriptBlock],
|
|
149
|
+
script_dir: Path | None,
|
|
150
|
+
defined: set[str],
|
|
151
|
+
visited: set[str] | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Walk nodes and collect variable definitions into *defined*."""
|
|
154
|
+
if visited is None:
|
|
155
|
+
visited = set()
|
|
156
|
+
|
|
157
|
+
for node in nodes:
|
|
158
|
+
if isinstance(node, MetaCommandStatement):
|
|
159
|
+
_extract_var_definition(node.command, script_dir, defined)
|
|
160
|
+
|
|
161
|
+
elif isinstance(node, IncludeDirective) and node.is_execute_script:
|
|
162
|
+
target = node.target.lower()
|
|
163
|
+
if target in script_blocks and target not in visited:
|
|
164
|
+
visited.add(target)
|
|
165
|
+
_collect_defined_vars_from_nodes(
|
|
166
|
+
script_blocks[target].body,
|
|
167
|
+
script_blocks,
|
|
168
|
+
script_dir,
|
|
169
|
+
defined,
|
|
170
|
+
visited,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Recurse into block children
|
|
174
|
+
if isinstance(node, (IfBlock, LoopBlock, BatchBlock, ScriptBlock, SqlBlock)):
|
|
175
|
+
_collect_defined_vars_from_nodes(
|
|
176
|
+
list(node.children()),
|
|
177
|
+
script_blocks,
|
|
178
|
+
script_dir,
|
|
179
|
+
defined,
|
|
180
|
+
visited,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _extract_var_definition(
|
|
185
|
+
command: str,
|
|
186
|
+
script_dir: Path | None,
|
|
187
|
+
defined: set[str],
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Extract variable name from a SUB-family metacommand into *defined*."""
|
|
190
|
+
for rx in (
|
|
191
|
+
_RX_SUB,
|
|
192
|
+
_RX_SUB_EMPTY,
|
|
193
|
+
_RX_SUB_ADD,
|
|
194
|
+
_RX_SUB_APPEND,
|
|
195
|
+
_RX_SUBDATA,
|
|
196
|
+
_RX_SUB_LOCAL,
|
|
197
|
+
_RX_SUB_TEMPFILE,
|
|
198
|
+
_RX_SUB_DECRYPT,
|
|
199
|
+
_RX_SUB_ENCRYPT,
|
|
200
|
+
_RX_SUB_QUERYSTRING,
|
|
201
|
+
):
|
|
202
|
+
m = rx.match(command)
|
|
203
|
+
if m:
|
|
204
|
+
defined.add(m.group("name").lstrip("+~").upper())
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
# SUB_INI bulk-defines from INI file — read keys at lint time
|
|
208
|
+
ini_m = _RX_SUB_INI.match(command)
|
|
209
|
+
if ini_m:
|
|
210
|
+
ini_file = ini_m.group("qfile") or ini_m.group("file")
|
|
211
|
+
ini_section = ini_m.group("section")
|
|
212
|
+
if ini_file and not _RX_VAR_REF.search(ini_file):
|
|
213
|
+
_read_ini_vars(ini_file, ini_section, script_dir, defined)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _read_ini_vars(
|
|
217
|
+
ini_file: str,
|
|
218
|
+
section: str,
|
|
219
|
+
script_dir: Path | None,
|
|
220
|
+
defined_vars: set[str],
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Read an INI file and register its section keys as defined variables."""
|
|
223
|
+
from configparser import ConfigParser
|
|
224
|
+
|
|
225
|
+
p = Path(ini_file)
|
|
226
|
+
if not p.is_absolute() and script_dir is not None:
|
|
227
|
+
p = script_dir / p
|
|
228
|
+
|
|
229
|
+
if not p.exists():
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
cp = ConfigParser()
|
|
233
|
+
cp.read(p)
|
|
234
|
+
if cp.has_section(section):
|
|
235
|
+
for key, _value in cp.items(section):
|
|
236
|
+
defined_vars.add(key.upper())
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _check_var_ref(
|
|
240
|
+
raw_name: str,
|
|
241
|
+
source: str,
|
|
242
|
+
line_no: int,
|
|
243
|
+
defined_vars: set[str],
|
|
244
|
+
issues: list[_Issue],
|
|
245
|
+
) -> None:
|
|
246
|
+
"""Emit a warning if *raw_name* looks like an undefined user variable."""
|
|
247
|
+
if not raw_name:
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
sigil = raw_name[0] if raw_name[0] in ("$", "@", "&", "~", "#", "+") else ""
|
|
251
|
+
name = raw_name[len(sigil) :]
|
|
252
|
+
|
|
253
|
+
# Skip non-$ sigil prefixes — resolved at runtime
|
|
254
|
+
if sigil in ("@", "&", "~", "#", "+"):
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# $ARG_N is set via -a/--assign-arg at invocation time
|
|
258
|
+
if re.match(r"^ARG_\d+$", name, re.I):
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
# $COUNTER_N is managed by CounterVars
|
|
262
|
+
if re.match(r"^COUNTER_\d+$", name, re.I):
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# Built-in system variables
|
|
266
|
+
if name.upper() in _get_builtin_vars():
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
# User-defined via SUB
|
|
270
|
+
if name.upper() in defined_vars:
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
issues.append(
|
|
274
|
+
_warning(
|
|
275
|
+
source,
|
|
276
|
+
line_no,
|
|
277
|
+
f"Potentially undefined variable: !!{raw_name}!! "
|
|
278
|
+
"(not defined by a preceding SUB; may be set by a config file or -a arg)",
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _check_include_path(
|
|
284
|
+
raw_path: str,
|
|
285
|
+
script_dir: Path | None,
|
|
286
|
+
source: str,
|
|
287
|
+
line_no: int,
|
|
288
|
+
issues: list[_Issue],
|
|
289
|
+
) -> None:
|
|
290
|
+
"""Warn if the INCLUDE target does not exist on disk."""
|
|
291
|
+
p = Path(raw_path)
|
|
292
|
+
if not p.is_absolute() and script_dir is not None:
|
|
293
|
+
p = script_dir / p
|
|
294
|
+
|
|
295
|
+
if not p.exists():
|
|
296
|
+
issues.append(
|
|
297
|
+
_warning(source, line_no, f"INCLUDE target does not exist: {raw_path!r}"),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# Core lint walk
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _lint_nodes(
|
|
307
|
+
nodes: list[Node],
|
|
308
|
+
script_dir: Path | None,
|
|
309
|
+
defined_vars: set[str],
|
|
310
|
+
script_blocks: dict[str, ScriptBlock],
|
|
311
|
+
issues: list[_Issue],
|
|
312
|
+
*,
|
|
313
|
+
visited_scripts: set[str] | None = None,
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Walk a list of AST nodes and collect lint issues."""
|
|
316
|
+
if visited_scripts is None:
|
|
317
|
+
visited_scripts = set()
|
|
318
|
+
|
|
319
|
+
for node in nodes:
|
|
320
|
+
src = node.span.file
|
|
321
|
+
lno = node.span.start_line
|
|
322
|
+
|
|
323
|
+
# -- Variable references in SQL --
|
|
324
|
+
if isinstance(node, SqlStatement):
|
|
325
|
+
for m in _RX_VAR_REF.finditer(node.text):
|
|
326
|
+
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
327
|
+
|
|
328
|
+
# -- Metacommand checks --
|
|
329
|
+
elif isinstance(node, MetaCommandStatement):
|
|
330
|
+
for m in _RX_VAR_REF.finditer(node.command):
|
|
331
|
+
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
332
|
+
|
|
333
|
+
# -- IncludeDirective checks --
|
|
334
|
+
elif isinstance(node, IncludeDirective):
|
|
335
|
+
if node.is_execute_script:
|
|
336
|
+
target = node.target.lower()
|
|
337
|
+
if target not in script_blocks:
|
|
338
|
+
if not node.if_exists:
|
|
339
|
+
issues.append(
|
|
340
|
+
_warning(src, lno, f"EXECUTE SCRIPT target not found: '{target}'"),
|
|
341
|
+
)
|
|
342
|
+
elif target not in visited_scripts:
|
|
343
|
+
visited_scripts.add(target)
|
|
344
|
+
_lint_nodes(
|
|
345
|
+
script_blocks[target].body,
|
|
346
|
+
script_dir,
|
|
347
|
+
defined_vars,
|
|
348
|
+
script_blocks,
|
|
349
|
+
issues,
|
|
350
|
+
visited_scripts=visited_scripts,
|
|
351
|
+
)
|
|
352
|
+
else:
|
|
353
|
+
# INCLUDE file existence check
|
|
354
|
+
if not node.if_exists:
|
|
355
|
+
raw_path = node.target.strip().strip("\"'")
|
|
356
|
+
if not _RX_VAR_REF.search(raw_path):
|
|
357
|
+
_check_include_path(raw_path, script_dir, src, lno, issues)
|
|
358
|
+
|
|
359
|
+
# -- Recurse into block children --
|
|
360
|
+
if isinstance(node, IfBlock):
|
|
361
|
+
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
362
|
+
for clause in node.elseif_clauses:
|
|
363
|
+
_lint_nodes(
|
|
364
|
+
clause.body,
|
|
365
|
+
script_dir,
|
|
366
|
+
defined_vars,
|
|
367
|
+
script_blocks,
|
|
368
|
+
issues,
|
|
369
|
+
visited_scripts=visited_scripts,
|
|
370
|
+
)
|
|
371
|
+
_lint_nodes(
|
|
372
|
+
node.else_body,
|
|
373
|
+
script_dir,
|
|
374
|
+
defined_vars,
|
|
375
|
+
script_blocks,
|
|
376
|
+
issues,
|
|
377
|
+
visited_scripts=visited_scripts,
|
|
378
|
+
)
|
|
379
|
+
elif isinstance(node, (LoopBlock, BatchBlock, SqlBlock)):
|
|
380
|
+
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
381
|
+
elif isinstance(node, ScriptBlock):
|
|
382
|
+
# Lint script block body (structural errors already caught by parser)
|
|
383
|
+
if node.name not in visited_scripts:
|
|
384
|
+
visited_scripts.add(node.name)
|
|
385
|
+
sub_issues: list[_Issue] = []
|
|
386
|
+
_lint_nodes(
|
|
387
|
+
node.body,
|
|
388
|
+
script_dir,
|
|
389
|
+
defined_vars,
|
|
390
|
+
script_blocks,
|
|
391
|
+
sub_issues,
|
|
392
|
+
visited_scripts=visited_scripts,
|
|
393
|
+
)
|
|
394
|
+
for sev, ssrc, slno, msg in sub_issues:
|
|
395
|
+
issues.append((sev, ssrc, slno, f"[script '{node.name}'] {msg}"))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
# Public API
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def lint_ast(
|
|
404
|
+
script: Script,
|
|
405
|
+
script_path: str | None = None,
|
|
406
|
+
) -> list[_Issue]:
|
|
407
|
+
"""Perform static analysis on an AST-parsed script.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
script: The parsed :class:`Script` tree.
|
|
411
|
+
script_path: Path to the source file (for resolving relative
|
|
412
|
+
INCLUDE paths). ``None`` for inline scripts.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
List of ``(severity, source, line_no, message)`` issue tuples.
|
|
416
|
+
"""
|
|
417
|
+
issues: list[_Issue] = []
|
|
418
|
+
|
|
419
|
+
if not script.body:
|
|
420
|
+
issues.append(_warning("<script>", 0, "Script is empty — no commands found"))
|
|
421
|
+
return issues
|
|
422
|
+
|
|
423
|
+
script_dir = Path(script_path).resolve().parent if script_path else None
|
|
424
|
+
script_blocks = _collect_script_blocks(script)
|
|
425
|
+
|
|
426
|
+
# Pass 1: collect all variable definitions
|
|
427
|
+
all_defined: set[str] = set()
|
|
428
|
+
_collect_defined_vars_from_nodes(script.body, script_blocks, script_dir, all_defined)
|
|
429
|
+
|
|
430
|
+
# Pass 2: lint for variable and include issues
|
|
431
|
+
_lint_nodes(
|
|
432
|
+
script.body,
|
|
433
|
+
script_dir,
|
|
434
|
+
all_defined,
|
|
435
|
+
script_blocks,
|
|
436
|
+
issues,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
return issues
|