execsql2 2.17.3__py3-none-any.whl → 2.18.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/cli/__init__.py +13 -1
- execsql/cli/lint.py +16 -565
- execsql/cli/run.py +29 -2
- execsql/config.py +20 -0
- execsql/db/access.py +6 -0
- execsql/db/base.py +57 -1
- execsql/db/dsn.py +19 -9
- execsql/db/firebird.py +6 -0
- execsql/db/mysql.py +81 -0
- execsql/db/oracle.py +6 -0
- execsql/db/sqlite.py +37 -18
- execsql/db/sqlserver.py +31 -6
- execsql/exporters/base.py +1 -1
- execsql/exporters/duckdb.py +8 -4
- execsql/exporters/ods.py +11 -0
- execsql/exporters/sqlite.py +10 -3
- execsql/exporters/templates.py +10 -0
- execsql/exporters/xls.py +4 -0
- execsql/exporters/xlsx.py +9 -0
- execsql/importers/json.py +49 -32
- execsql/metacommands/conditions.py +7 -2
- execsql/metacommands/io_export.py +21 -26
- execsql/metacommands/io_fileops.py +21 -3
- execsql/metacommands/io_import.py +23 -3
- execsql/script/ast.py +8 -0
- execsql/script/engine.py +32 -0
- execsql/script/executor.py +12 -0
- execsql/script/variables.py +41 -15
- execsql/utils/auth.py +49 -1
- execsql/utils/fileio.py +120 -0
- execsql/utils/gui.py +11 -1
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/md_compare.sql +12 -12
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/md_glossary.sql +5 -5
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/md_upsert.sql +13 -13
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/pg_compare.sql +24 -24
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/pg_glossary.sql +5 -5
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/pg_upsert.sql +29 -29
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/script_template.sql +2 -2
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/ss_compare.sql +24 -24
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/ss_glossary.sql +6 -6
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/ss_upsert.sql +2917 -2917
- {execsql2-2.17.3.dist-info → execsql2-2.18.0.dist-info}/METADATA +6 -1
- {execsql2-2.17.3.dist-info → execsql2-2.18.0.dist-info}/RECORD +52 -52
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.17.3.data → execsql2-2.18.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.17.3.dist-info → execsql2-2.18.0.dist-info}/WHEEL +0 -0
- {execsql2-2.17.3.dist-info → execsql2-2.18.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.17.3.dist-info → execsql2-2.18.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.17.3.dist-info → execsql2-2.18.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/__init__.py
CHANGED
|
@@ -9,7 +9,7 @@ Submodules:
|
|
|
9
9
|
- :mod:`execsql.cli.dsn` — Connection-string (DSN URL) parser
|
|
10
10
|
- :mod:`execsql.cli.run` — Core execution logic (``_run``, ``_connect_initial_db``, ``_ping_db``, ``_print_dry_run``, ``_print_profile``)
|
|
11
11
|
- :mod:`execsql.cli.lint_ast` — AST-based ``--lint`` static analyser
|
|
12
|
-
- :mod:`execsql.cli.lint` —
|
|
12
|
+
- :mod:`execsql.cli.lint` — Shared lint result printing (``_print_lint_results``) used by the AST linter
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
@@ -235,6 +235,16 @@ def main(
|
|
|
235
235
|
"--no-system-cmd",
|
|
236
236
|
help="Disable the SYSTEM_CMD (SHELL) metacommand. Scripts that use SHELL will fail with an error.",
|
|
237
237
|
),
|
|
238
|
+
no_rm_file: bool = typer.Option(
|
|
239
|
+
False,
|
|
240
|
+
"--no-rm-file",
|
|
241
|
+
help="Disable the RM_FILE metacommand. Scripts that try to delete files will fail with an error.",
|
|
242
|
+
),
|
|
243
|
+
no_serve: bool = typer.Option(
|
|
244
|
+
False,
|
|
245
|
+
"--no-serve",
|
|
246
|
+
help="Disable the SERVE metacommand. Scripts that try to stream a file to stdout will fail with an error.",
|
|
247
|
+
),
|
|
238
248
|
profile: bool = typer.Option(
|
|
239
249
|
False,
|
|
240
250
|
"--profile",
|
|
@@ -604,6 +614,8 @@ def main(
|
|
|
604
614
|
lint=lint,
|
|
605
615
|
debug=debug,
|
|
606
616
|
no_system_cmd=no_system_cmd,
|
|
617
|
+
no_rm_file=no_rm_file,
|
|
618
|
+
no_serve=no_serve,
|
|
607
619
|
config_file=config_file,
|
|
608
620
|
)
|
|
609
621
|
|
execsql/cli/lint.py
CHANGED
|
@@ -1,30 +1,22 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Shared Rich-formatted output for ``--lint`` results.
|
|
2
2
|
|
|
3
|
-
The active
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
unreached from the CLI now and is retained for reference / potential
|
|
8
|
-
reuse only.
|
|
3
|
+
The active linter is :mod:`execsql.cli.lint_ast`, which produces
|
|
4
|
+
``(severity, source, line_no, message)`` tuples. This module owns the
|
|
5
|
+
small surface that converts those tuples into the user-facing console
|
|
6
|
+
output:
|
|
9
7
|
|
|
10
|
-
|
|
8
|
+
- :class:`_Issue` — the issue tuple type alias.
|
|
9
|
+
- :func:`_error` / :func:`_warning` — issue constructors.
|
|
10
|
+
- :func:`_print_lint_results` — Rich console formatter; returns the
|
|
11
|
+
``--lint`` exit code (``1`` if any error, else ``0``).
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
— used by both linters.
|
|
13
|
+
This module previously hosted a flat-CommandList walker
|
|
14
|
+
(``_lint_script`` / ``_lint_cmdlist`` and friends) that pre-dated the
|
|
15
|
+
AST. The walker was removed once the CLI fully migrated to the AST
|
|
16
|
+
linter; the rename of ``_state.savedscripts`` → ``_state.ast_scripts``
|
|
17
|
+
had silently turned its EXECUTE-SCRIPT flow analysis into a no-op.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
``_discover_builtin_vars`` helpers below covered the same checks the
|
|
21
|
-
AST linter now performs (unmatched IF/LOOP/BATCH, undefined ``!!$VAR!!``
|
|
22
|
-
references, missing INCLUDE files, EXECUTE SCRIPT flow analysis, empty
|
|
23
|
-
script). They operate on the legacy
|
|
24
|
-
:class:`~execsql.script.engine.CommandList`; no CLI path constructs
|
|
25
|
-
that representation any more.
|
|
26
|
-
|
|
27
|
-
Exit-code contract (still honoured by the AST linter):
|
|
19
|
+
Exit-code contract (honoured by the AST linter):
|
|
28
20
|
|
|
29
21
|
- ``1`` when at least one error-severity issue is found.
|
|
30
22
|
- ``0`` when only warnings (or nothing) are found.
|
|
@@ -32,118 +24,8 @@ Exit-code contract (still honoured by the AST linter):
|
|
|
32
24
|
|
|
33
25
|
from __future__ import annotations
|
|
34
26
|
|
|
35
|
-
|
|
36
|
-
from pathlib import Path
|
|
37
|
-
from typing import TYPE_CHECKING
|
|
38
|
-
|
|
39
|
-
if TYPE_CHECKING:
|
|
40
|
-
from execsql.script.engine import CommandList
|
|
41
|
-
|
|
42
|
-
__all__ = ["_lint_script"]
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
# ---------------------------------------------------------------------------
|
|
46
|
-
# Compiled patterns for metacommand recognition
|
|
47
|
-
# ---------------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
# IF block — "IF(...)" block form (single-command, no ENDIF needed)
|
|
50
|
-
_RX_IF_INLINE = re.compile(
|
|
51
|
-
r"^\s*IF\s*\(\s*.+\s*\)\s*\{.+\}\s*$",
|
|
52
|
-
re.I,
|
|
53
|
-
)
|
|
54
|
-
# IF block form that opens a block requiring ENDIF
|
|
55
|
-
_RX_IF_BLOCK = re.compile(r"^\s*IF\s*\(\s*.+\s*\)\s*$", re.I)
|
|
56
|
-
_RX_ENDIF = re.compile(r"^\s*ENDIF\s*$", re.I)
|
|
57
|
-
_RX_ELSE = re.compile(r"^\s*ELSE\s*$", re.I)
|
|
58
|
-
_RX_ELSEIF = re.compile(r"^\s*ELSEIF\s*\(\s*.+\s*\)\s*$", re.I)
|
|
59
|
-
_RX_ANDIF = re.compile(r"^\s*ANDIF\s*\(\s*.+\s*\)\s*$", re.I)
|
|
60
|
-
_RX_ORIF = re.compile(r"^\s*ORIF\s*\(\s*.+\s*\)\s*$", re.I)
|
|
61
|
-
|
|
62
|
-
# LOOP … END LOOP
|
|
63
|
-
_RX_LOOP = re.compile(r"^\s*LOOP\s+(?:WHILE|UNTIL)\s*\(", re.I)
|
|
64
|
-
_RX_END_LOOP = re.compile(r"^\s*END\s+LOOP\s*$", re.I)
|
|
65
|
-
|
|
66
|
-
# BEGIN BATCH … END BATCH
|
|
67
|
-
_RX_BEGIN_BATCH = re.compile(r"^\s*BEGIN\s+BATCH\s*$", re.I)
|
|
68
|
-
_RX_END_BATCH = re.compile(r"^\s*END\s+BATCH\s*$", re.I)
|
|
69
|
-
|
|
70
|
-
# SUB <varname> <value> — defines a substitution variable
|
|
71
|
-
_RX_SUB = re.compile(r"^\s*SUB\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
72
|
-
|
|
73
|
-
# SUB_EMPTY <varname> — defines a variable with empty string
|
|
74
|
-
_RX_SUB_EMPTY = re.compile(r"^\s*SUB_EMPTY\s+(?P<name>[+~]?\w+)\s*$", re.I)
|
|
75
|
-
|
|
76
|
-
# SUB_ADD <varname> <expr> — increments a variable (implies it exists)
|
|
77
|
-
_RX_SUB_ADD = re.compile(r"^\s*SUB_ADD\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
78
|
-
|
|
79
|
-
# SUB_APPEND <varname> <text> — appends to a variable (implies it exists)
|
|
80
|
-
_RX_SUB_APPEND = re.compile(r"^\s*SUB_APPEND\s+(?P<name>[+~]?\w+)\s", re.I)
|
|
81
|
-
|
|
82
|
-
# SUBDATA <varname> <datasource> — defines a variable from a query result
|
|
83
|
-
_RX_SUBDATA = re.compile(r"^\s*SUBDATA\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
84
|
-
|
|
85
|
-
# SUB_INI [FILE] <filename> [SECTION] <section> — bulk-defines variables from INI file
|
|
86
|
-
_RX_SUB_INI = re.compile(
|
|
87
|
-
r'^\s*SUB_INI\s+(?:FILE\s+)?(?:"(?P<qfile>[^"]+)"|(?P<file>\S+))'
|
|
88
|
-
r"(?:\s+SECTION)?\s+(?P<section>\w+)\s*$",
|
|
89
|
-
re.I,
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
# EXECUTE SCRIPT / EXEC SCRIPT / RUN SCRIPT
|
|
93
|
-
_RX_EXEC_SCRIPT = re.compile(
|
|
94
|
-
r"^\s*(?:EXEC(?:UTE)?|RUN)\s+SCRIPT(?:\s+IF\s+EXISTS)?\s+(?P<script_id>\w+)",
|
|
95
|
-
re.I,
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
# INCLUDE <file>
|
|
99
|
-
_RX_INCLUDE = re.compile(
|
|
100
|
-
r"^\s*INCLUDE(?:\s+IF\s+EXISTS?)?\s+(?P<path>\S+.*?)\s*$",
|
|
101
|
-
re.I,
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
# Variable reference — !!name!! where name may start with $, @, &, ~, #, +
|
|
105
|
-
_RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
|
|
106
|
-
|
|
107
|
-
# Built-in system variables — extracted automatically from the installed
|
|
108
|
-
# ``execsql`` source by scanning for ``add_substitution("$NAME", ...)`` and
|
|
109
|
-
# ``register_lazy("$NAME", ...)`` calls. This avoids maintaining a hand-
|
|
110
|
-
# curated list that drifts out of sync when new system variables are added.
|
|
111
|
-
# Variable names are stored upper-case without the leading ``$``.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _discover_builtin_vars() -> frozenset[str]:
|
|
115
|
-
"""Scan the execsql package source for ``$VARNAME`` system variables."""
|
|
116
|
-
import importlib.util
|
|
117
|
-
|
|
118
|
-
_rx_add_sub = re.compile(r'(?:(?<!\w)add_substitution|(?<!\w)sv)\s*\(\s*["\'](\$\w+)["\']')
|
|
119
|
-
_rx_lazy = re.compile(r'register_lazy\s*\(\s*["\'](\$\w+)["\']')
|
|
120
|
-
|
|
121
|
-
names: set[str] = set()
|
|
122
|
-
|
|
123
|
-
spec = importlib.util.find_spec("execsql")
|
|
124
|
-
if spec is None or spec.submodule_search_locations is None:
|
|
125
|
-
return frozenset(names)
|
|
126
|
-
|
|
127
|
-
pkg_dir = Path(spec.submodule_search_locations[0])
|
|
128
|
-
for src_file in pkg_dir.rglob("*.py"):
|
|
129
|
-
try:
|
|
130
|
-
text = src_file.read_text(encoding="utf-8")
|
|
131
|
-
except OSError:
|
|
132
|
-
continue
|
|
133
|
-
for m in _rx_add_sub.finditer(text):
|
|
134
|
-
names.add(m.group(1).lstrip("$").upper())
|
|
135
|
-
for m in _rx_lazy.finditer(text):
|
|
136
|
-
names.add(m.group(1).lstrip("$").upper())
|
|
137
|
-
|
|
138
|
-
return frozenset(names)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
_BUILTIN_VARS: frozenset[str] = _discover_builtin_vars()
|
|
142
|
-
|
|
27
|
+
__all__ = ["_Issue", "_error", "_print_lint_results", "_warning"]
|
|
143
28
|
|
|
144
|
-
# ---------------------------------------------------------------------------
|
|
145
|
-
# Issue tuple helpers
|
|
146
|
-
# ---------------------------------------------------------------------------
|
|
147
29
|
|
|
148
30
|
_Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
|
|
149
31
|
|
|
@@ -156,437 +38,6 @@ def _warning(source: str, line_no: int, message: str) -> _Issue:
|
|
|
156
38
|
return ("warning", source, line_no, message)
|
|
157
39
|
|
|
158
40
|
|
|
159
|
-
# ---------------------------------------------------------------------------
|
|
160
|
-
# Core lint implementation
|
|
161
|
-
# ---------------------------------------------------------------------------
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def _collect_defined_vars(
|
|
165
|
-
cmdlist: CommandList,
|
|
166
|
-
script_dir: Path | None,
|
|
167
|
-
defined_vars: set[str],
|
|
168
|
-
*,
|
|
169
|
-
_savedscripts: dict | None = None,
|
|
170
|
-
_visited_scripts: set[str] | None = None,
|
|
171
|
-
) -> None:
|
|
172
|
-
"""Pass 1: walk *cmdlist* and collect all variable definitions into *defined_vars*.
|
|
173
|
-
|
|
174
|
-
This populates the set with every variable name that could be defined at
|
|
175
|
-
runtime — ``SUB``, ``SUB_EMPTY``, ``SUB_ADD``, ``SUB_APPEND``,
|
|
176
|
-
``SUBDATA``, and ``SUB_INI`` (by reading the INI file on disk). It also
|
|
177
|
-
descends into ``EXECUTE SCRIPT`` targets to collect their definitions.
|
|
178
|
-
|
|
179
|
-
No issues are reported; structural checks and variable-reference validation
|
|
180
|
-
happen in pass 2 (:func:`_lint_cmdlist`).
|
|
181
|
-
"""
|
|
182
|
-
visited = _visited_scripts if _visited_scripts is not None else set()
|
|
183
|
-
|
|
184
|
-
for cmd in cmdlist.cmdlist:
|
|
185
|
-
if cmd.command_type == "sql":
|
|
186
|
-
continue
|
|
187
|
-
stmt = cmd.command.statement
|
|
188
|
-
|
|
189
|
-
# SUB <name> <value>
|
|
190
|
-
sub_m = _RX_SUB.match(stmt)
|
|
191
|
-
if sub_m:
|
|
192
|
-
defined_vars.add(sub_m.group("name").lstrip("+~").upper())
|
|
193
|
-
|
|
194
|
-
# SUB_EMPTY / SUB_ADD / SUB_APPEND / SUBDATA
|
|
195
|
-
for rx in (_RX_SUB_EMPTY, _RX_SUB_ADD, _RX_SUB_APPEND, _RX_SUBDATA):
|
|
196
|
-
m = rx.match(stmt)
|
|
197
|
-
if m:
|
|
198
|
-
defined_vars.add(m.group("name").lstrip("+~").upper())
|
|
199
|
-
break
|
|
200
|
-
|
|
201
|
-
# SUB_INI — read INI file keys
|
|
202
|
-
ini_m = _RX_SUB_INI.match(stmt)
|
|
203
|
-
if ini_m:
|
|
204
|
-
ini_file = ini_m.group("qfile") or ini_m.group("file")
|
|
205
|
-
ini_section = ini_m.group("section")
|
|
206
|
-
if ini_file and not _RX_VAR_REF.search(ini_file):
|
|
207
|
-
_read_ini_vars(ini_file, ini_section, script_dir, defined_vars)
|
|
208
|
-
|
|
209
|
-
# EXECUTE SCRIPT — descend into named script block
|
|
210
|
-
exec_m = _RX_EXEC_SCRIPT.match(stmt)
|
|
211
|
-
if exec_m and _savedscripts is not None:
|
|
212
|
-
script_id = exec_m.group("script_id").lower()
|
|
213
|
-
if script_id in _savedscripts and script_id not in visited:
|
|
214
|
-
visited.add(script_id)
|
|
215
|
-
_collect_defined_vars(
|
|
216
|
-
_savedscripts[script_id],
|
|
217
|
-
script_dir,
|
|
218
|
-
defined_vars,
|
|
219
|
-
_savedscripts=_savedscripts,
|
|
220
|
-
_visited_scripts=visited,
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def _lint_cmdlist(
|
|
225
|
-
cmdlist: CommandList,
|
|
226
|
-
script_dir: Path | None,
|
|
227
|
-
defined_vars: set[str],
|
|
228
|
-
*,
|
|
229
|
-
_savedscripts: dict | None = None,
|
|
230
|
-
_visited_scripts: set[str] | None = None,
|
|
231
|
-
) -> list[_Issue]:
|
|
232
|
-
"""Pass 2: lint a :class:`CommandList` for structural and variable issues.
|
|
233
|
-
|
|
234
|
-
Args:
|
|
235
|
-
cmdlist: The parsed command list to analyse.
|
|
236
|
-
script_dir: Directory of the top-level script file, used for resolving
|
|
237
|
-
relative INCLUDE paths. ``None`` for inline (``-c``) scripts.
|
|
238
|
-
defined_vars: Set of variable names (without sigil) that have been
|
|
239
|
-
pre-collected by :func:`_collect_defined_vars`. This includes
|
|
240
|
-
*all* top-level and script-block definitions so that ordering
|
|
241
|
-
does not matter.
|
|
242
|
-
_savedscripts: Dictionary of named script blocks (from
|
|
243
|
-
``_state.savedscripts``). Passed explicitly so the function can
|
|
244
|
-
descend into EXECUTE SCRIPT targets.
|
|
245
|
-
_visited_scripts: Set of script IDs already descended into, shared
|
|
246
|
-
across recursive calls to prevent infinite recursion from circular
|
|
247
|
-
EXECUTE SCRIPT references.
|
|
248
|
-
|
|
249
|
-
Returns:
|
|
250
|
-
List of ``(severity, source, line_no, message)`` issue tuples.
|
|
251
|
-
"""
|
|
252
|
-
issues: list[_Issue] = []
|
|
253
|
-
|
|
254
|
-
if_depth = 0
|
|
255
|
-
if_open_locs: list[tuple[str, int]] = [] # (source, line_no) of unmatched IF
|
|
256
|
-
|
|
257
|
-
loop_depth = 0
|
|
258
|
-
loop_open_locs: list[tuple[str, int]] = []
|
|
259
|
-
|
|
260
|
-
batch_depth = 0
|
|
261
|
-
batch_open_locs: list[tuple[str, int]] = []
|
|
262
|
-
|
|
263
|
-
# Track which EXECUTE SCRIPT targets we've already descended into to
|
|
264
|
-
# prevent infinite recursion from circular script references.
|
|
265
|
-
visited_scripts: set[str] = _visited_scripts if _visited_scripts is not None else set()
|
|
266
|
-
|
|
267
|
-
for cmd in cmdlist.cmdlist:
|
|
268
|
-
src = cmd.source
|
|
269
|
-
lno = cmd.line_no
|
|
270
|
-
stmt = cmd.command.statement
|
|
271
|
-
|
|
272
|
-
if cmd.command_type == "sql":
|
|
273
|
-
# SQL statements: check for variable references only
|
|
274
|
-
for m in _RX_VAR_REF.finditer(stmt):
|
|
275
|
-
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
276
|
-
continue
|
|
277
|
-
|
|
278
|
-
# Metacommand checks — variable references
|
|
279
|
-
for m in _RX_VAR_REF.finditer(stmt):
|
|
280
|
-
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
281
|
-
|
|
282
|
-
# -- IF block (opens a block requiring ENDIF) --
|
|
283
|
-
if _RX_IF_BLOCK.match(stmt) and not _RX_IF_INLINE.match(stmt):
|
|
284
|
-
if_depth += 1
|
|
285
|
-
if_open_locs.append((src, lno))
|
|
286
|
-
|
|
287
|
-
elif _RX_ENDIF.match(stmt):
|
|
288
|
-
if if_depth == 0:
|
|
289
|
-
issues.append(_error(src, lno, "ENDIF without a matching preceding IF"))
|
|
290
|
-
else:
|
|
291
|
-
if_depth -= 1
|
|
292
|
-
if_open_locs.pop()
|
|
293
|
-
|
|
294
|
-
elif _RX_ELSEIF.match(stmt) or _RX_ELSE.match(stmt) or _RX_ANDIF.match(stmt) or _RX_ORIF.match(stmt):
|
|
295
|
-
if if_depth == 0:
|
|
296
|
-
kw = stmt.strip().split(None, 1)[0].upper()
|
|
297
|
-
issues.append(_error(src, lno, f"{kw} without a matching preceding IF"))
|
|
298
|
-
|
|
299
|
-
# -- LOOP --
|
|
300
|
-
elif _RX_LOOP.match(stmt):
|
|
301
|
-
loop_depth += 1
|
|
302
|
-
loop_open_locs.append((src, lno))
|
|
303
|
-
|
|
304
|
-
elif _RX_END_LOOP.match(stmt):
|
|
305
|
-
if loop_depth == 0:
|
|
306
|
-
issues.append(_error(src, lno, "END LOOP without a matching preceding LOOP"))
|
|
307
|
-
else:
|
|
308
|
-
loop_depth -= 1
|
|
309
|
-
loop_open_locs.pop()
|
|
310
|
-
|
|
311
|
-
# -- BATCH --
|
|
312
|
-
elif _RX_BEGIN_BATCH.match(stmt):
|
|
313
|
-
batch_depth += 1
|
|
314
|
-
batch_open_locs.append((src, lno))
|
|
315
|
-
|
|
316
|
-
elif _RX_END_BATCH.match(stmt):
|
|
317
|
-
if batch_depth == 0:
|
|
318
|
-
issues.append(_error(src, lno, "END BATCH without a matching preceding BEGIN BATCH"))
|
|
319
|
-
else:
|
|
320
|
-
batch_depth -= 1
|
|
321
|
-
batch_open_locs.pop()
|
|
322
|
-
|
|
323
|
-
# -- EXECUTE SCRIPT — descend into named script block --
|
|
324
|
-
exec_m = _RX_EXEC_SCRIPT.match(stmt)
|
|
325
|
-
if exec_m and _savedscripts is not None:
|
|
326
|
-
script_id = exec_m.group("script_id").lower()
|
|
327
|
-
if script_id not in _savedscripts:
|
|
328
|
-
# Warn unless it's EXECUTE SCRIPT IF EXISTS
|
|
329
|
-
if not re.search(r"\bIF\s+EXISTS\b", stmt, re.I):
|
|
330
|
-
issues.append(
|
|
331
|
-
_warning(src, lno, f"EXECUTE SCRIPT target not found: '{script_id}'"),
|
|
332
|
-
)
|
|
333
|
-
elif script_id not in visited_scripts:
|
|
334
|
-
visited_scripts.add(script_id)
|
|
335
|
-
sub_issues = _lint_cmdlist(
|
|
336
|
-
_savedscripts[script_id],
|
|
337
|
-
script_dir,
|
|
338
|
-
defined_vars,
|
|
339
|
-
_savedscripts=_savedscripts,
|
|
340
|
-
_visited_scripts=visited_scripts,
|
|
341
|
-
)
|
|
342
|
-
for sev, ssrc, slno, msg in sub_issues:
|
|
343
|
-
issues.append((sev, ssrc, slno, f"[script '{script_id}'] {msg}"))
|
|
344
|
-
|
|
345
|
-
# -- INCLUDE file existence --
|
|
346
|
-
inc_m = _RX_INCLUDE.match(stmt)
|
|
347
|
-
if inc_m:
|
|
348
|
-
raw_path = inc_m.group("path").strip().strip("\"'")
|
|
349
|
-
# Only check if no substitution variables are in the path
|
|
350
|
-
if not _RX_VAR_REF.search(raw_path):
|
|
351
|
-
_check_include_path(raw_path, script_dir, src, lno, stmt, issues)
|
|
352
|
-
|
|
353
|
-
# Report unclosed blocks at end of command list
|
|
354
|
-
for osrc, olno in if_open_locs:
|
|
355
|
-
issues.append(_error(osrc, olno, "IF without a matching ENDIF"))
|
|
356
|
-
for osrc, olno in loop_open_locs:
|
|
357
|
-
issues.append(_error(osrc, olno, "LOOP without a matching END LOOP"))
|
|
358
|
-
for osrc, olno in batch_open_locs:
|
|
359
|
-
issues.append(_error(osrc, olno, "BEGIN BATCH without a matching END BATCH"))
|
|
360
|
-
|
|
361
|
-
return issues
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
def _check_var_ref(
|
|
365
|
-
raw_name: str,
|
|
366
|
-
source: str,
|
|
367
|
-
line_no: int,
|
|
368
|
-
defined_vars: set[str],
|
|
369
|
-
issues: list[_Issue],
|
|
370
|
-
) -> None:
|
|
371
|
-
"""Emit a warning if *raw_name* looks like an undefined user variable.
|
|
372
|
-
|
|
373
|
-
Built-in system variables, environment-variable references (``&``-prefix),
|
|
374
|
-
column variables (``@``-prefix), counter variables (``@@``), parameter
|
|
375
|
-
variables (``#``-prefix), and ``$ARG_N`` are excluded from the check.
|
|
376
|
-
|
|
377
|
-
Args:
|
|
378
|
-
raw_name: Variable name token as captured from ``!!name!!`` (with sigil).
|
|
379
|
-
source: Source file name for the issue location.
|
|
380
|
-
line_no: Line number of the command containing the reference.
|
|
381
|
-
defined_vars: Set of variable names (upper-case, no sigil) that have
|
|
382
|
-
been defined by preceding SUB metacommands.
|
|
383
|
-
issues: Issue list to append to.
|
|
384
|
-
"""
|
|
385
|
-
if not raw_name:
|
|
386
|
-
return
|
|
387
|
-
|
|
388
|
-
sigil = raw_name[0] if raw_name[0] in ("$", "@", "&", "~", "#", "+") else ""
|
|
389
|
-
name = raw_name[len(sigil) :]
|
|
390
|
-
|
|
391
|
-
# Skip non-$ sigil prefixes — these are always resolved at runtime
|
|
392
|
-
if sigil in ("@", "&", "~", "#", "+"):
|
|
393
|
-
return
|
|
394
|
-
|
|
395
|
-
# $ARG_N is set via -a/--assign-arg at invocation time
|
|
396
|
-
if re.match(r"^ARG_\d+$", name, re.I):
|
|
397
|
-
return
|
|
398
|
-
|
|
399
|
-
# $COUNTER_N is managed by CounterVars (@@counter metacommands)
|
|
400
|
-
if re.match(r"^COUNTER_\d+$", name, re.I):
|
|
401
|
-
return
|
|
402
|
-
|
|
403
|
-
# Built-in system variables
|
|
404
|
-
if name.upper() in _BUILTIN_VARS:
|
|
405
|
-
return
|
|
406
|
-
|
|
407
|
-
# User-defined via SUB
|
|
408
|
-
if name.upper() in defined_vars:
|
|
409
|
-
return
|
|
410
|
-
|
|
411
|
-
issues.append(
|
|
412
|
-
_warning(
|
|
413
|
-
source,
|
|
414
|
-
line_no,
|
|
415
|
-
f"Potentially undefined variable: !!{raw_name}!! "
|
|
416
|
-
"(not defined by a preceding SUB; may be set by a config file or -a arg)",
|
|
417
|
-
),
|
|
418
|
-
)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
def _read_ini_vars(
|
|
422
|
-
ini_file: str,
|
|
423
|
-
section: str,
|
|
424
|
-
script_dir: Path | None,
|
|
425
|
-
defined_vars: set[str],
|
|
426
|
-
) -> None:
|
|
427
|
-
"""Read an INI file and register its section keys as defined variables.
|
|
428
|
-
|
|
429
|
-
Mirrors what ``SUB_INI`` does at runtime: reads a
|
|
430
|
-
:class:`~configparser.ConfigParser` section and defines each key as a
|
|
431
|
-
substitution variable. If the file does not exist or the section is
|
|
432
|
-
missing, silently does nothing (the runtime handler behaves the same way).
|
|
433
|
-
"""
|
|
434
|
-
from configparser import ConfigParser
|
|
435
|
-
|
|
436
|
-
p = Path(ini_file)
|
|
437
|
-
if not p.is_absolute() and script_dir is not None:
|
|
438
|
-
p = script_dir / p
|
|
439
|
-
|
|
440
|
-
if not p.exists():
|
|
441
|
-
return
|
|
442
|
-
|
|
443
|
-
cp = ConfigParser()
|
|
444
|
-
cp.read(p)
|
|
445
|
-
if cp.has_section(section):
|
|
446
|
-
for key, _value in cp.items(section):
|
|
447
|
-
defined_vars.add(key.upper())
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
def _check_include_path(
|
|
451
|
-
raw_path: str,
|
|
452
|
-
script_dir: Path | None,
|
|
453
|
-
source: str,
|
|
454
|
-
line_no: int,
|
|
455
|
-
stmt: str,
|
|
456
|
-
issues: list[_Issue],
|
|
457
|
-
) -> None:
|
|
458
|
-
"""Warn if the INCLUDE target does not exist on disk.
|
|
459
|
-
|
|
460
|
-
Args:
|
|
461
|
-
raw_path: Unquoted file path string from the INCLUDE metacommand.
|
|
462
|
-
script_dir: Directory of the top-level script file; used for relative
|
|
463
|
-
path resolution. ``None`` for inline scripts.
|
|
464
|
-
source: Source file name for issue location.
|
|
465
|
-
line_no: Line number of the INCLUDE command.
|
|
466
|
-
stmt: Full metacommand statement text (for the IF EXISTS variant).
|
|
467
|
-
issues: Issue list to append to.
|
|
468
|
-
"""
|
|
469
|
-
# IF EXISTS variant — missing file is intentional; skip
|
|
470
|
-
if re.match(r"^\s*INCLUDE\s+IF\s+EXISTS?", stmt, re.I):
|
|
471
|
-
return
|
|
472
|
-
|
|
473
|
-
p = Path(raw_path)
|
|
474
|
-
if not p.is_absolute() and script_dir is not None:
|
|
475
|
-
p = script_dir / p
|
|
476
|
-
|
|
477
|
-
if not p.exists():
|
|
478
|
-
issues.append(
|
|
479
|
-
_warning(
|
|
480
|
-
source,
|
|
481
|
-
line_no,
|
|
482
|
-
f"INCLUDE target does not exist: {raw_path!r}",
|
|
483
|
-
),
|
|
484
|
-
)
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
def _lint_script(
|
|
488
|
-
cmdlist: CommandList | None,
|
|
489
|
-
script_path: str | None = None,
|
|
490
|
-
) -> list[_Issue]:
|
|
491
|
-
"""Perform static analysis on a parsed command list.
|
|
492
|
-
|
|
493
|
-
Walks every :class:`~execsql.script.ScriptCmd` in *cmdlist* and any named
|
|
494
|
-
scripts accumulated in ``_state.savedscripts`` (those defined with
|
|
495
|
-
``BEGIN SCRIPT … END SCRIPT`` in the same source file).
|
|
496
|
-
|
|
497
|
-
Args:
|
|
498
|
-
cmdlist: The top-level :class:`~execsql.script.CommandList` returned by
|
|
499
|
-
``read_sqlfile()`` / ``read_sqlstring()``. If ``None`` or empty,
|
|
500
|
-
a single "empty script" warning is returned.
|
|
501
|
-
script_path: Absolute or relative path to the SQL script file. Used
|
|
502
|
-
to resolve relative INCLUDE paths. Pass ``None`` for inline
|
|
503
|
-
(``-c``) scripts.
|
|
504
|
-
|
|
505
|
-
Returns:
|
|
506
|
-
List of ``(severity, source, line_no, message)`` tuples, one per issue
|
|
507
|
-
found. An empty list means the script is clean.
|
|
508
|
-
"""
|
|
509
|
-
import execsql.state as _state
|
|
510
|
-
|
|
511
|
-
issues: list[_Issue] = []
|
|
512
|
-
|
|
513
|
-
if cmdlist is None or not cmdlist.cmdlist:
|
|
514
|
-
issues.append(_warning("<script>", 0, "Script is empty — no commands found"))
|
|
515
|
-
return issues
|
|
516
|
-
|
|
517
|
-
script_dir = Path(script_path).resolve().parent if script_path else None
|
|
518
|
-
savedscripts: dict = getattr(_state, "savedscripts", {})
|
|
519
|
-
|
|
520
|
-
# ------------------------------------------------------------------
|
|
521
|
-
# Pass 1: collect all variable definitions from the top-level script
|
|
522
|
-
# and all reachable script blocks. This ensures definition order does
|
|
523
|
-
# not matter — a script block executed early can reference variables
|
|
524
|
-
# defined later in the top-level script.
|
|
525
|
-
# ------------------------------------------------------------------
|
|
526
|
-
all_defined: set[str] = set()
|
|
527
|
-
collect_visited: set[str] = set()
|
|
528
|
-
_collect_defined_vars(
|
|
529
|
-
cmdlist,
|
|
530
|
-
script_dir,
|
|
531
|
-
all_defined,
|
|
532
|
-
_savedscripts=savedscripts,
|
|
533
|
-
_visited_scripts=collect_visited,
|
|
534
|
-
)
|
|
535
|
-
# Also collect from every saved script block (they may define vars
|
|
536
|
-
# referenced by other blocks). Share the visited set so each block
|
|
537
|
-
# is only traversed once (O(N) instead of O(N²)).
|
|
538
|
-
for saved_cl in savedscripts.values():
|
|
539
|
-
_collect_defined_vars(
|
|
540
|
-
saved_cl,
|
|
541
|
-
script_dir,
|
|
542
|
-
all_defined,
|
|
543
|
-
_savedscripts=savedscripts,
|
|
544
|
-
_visited_scripts=collect_visited,
|
|
545
|
-
)
|
|
546
|
-
|
|
547
|
-
# ------------------------------------------------------------------
|
|
548
|
-
# Pass 2: lint for structural issues and undefined-variable warnings
|
|
549
|
-
# using the complete variable set from pass 1.
|
|
550
|
-
# ------------------------------------------------------------------
|
|
551
|
-
# Shared visited-scripts tracker — prevents duplicate lint warnings
|
|
552
|
-
# when the same script block is reached via multiple paths.
|
|
553
|
-
visited: set[str] = set()
|
|
554
|
-
|
|
555
|
-
issues.extend(
|
|
556
|
-
_lint_cmdlist(
|
|
557
|
-
cmdlist,
|
|
558
|
-
script_dir,
|
|
559
|
-
all_defined,
|
|
560
|
-
_savedscripts=savedscripts,
|
|
561
|
-
_visited_scripts=visited,
|
|
562
|
-
),
|
|
563
|
-
)
|
|
564
|
-
|
|
565
|
-
# Analyse each named SCRIPT block that was NOT already visited via
|
|
566
|
-
# EXECUTE SCRIPT (standalone analysis catches structural issues like
|
|
567
|
-
# unmatched IF/ENDIF in script blocks that are never executed).
|
|
568
|
-
for script_name, saved_cl in savedscripts.items():
|
|
569
|
-
if script_name in visited:
|
|
570
|
-
continue
|
|
571
|
-
visited.add(script_name)
|
|
572
|
-
saved_issues = _lint_cmdlist(
|
|
573
|
-
saved_cl,
|
|
574
|
-
script_dir,
|
|
575
|
-
set(all_defined),
|
|
576
|
-
_savedscripts=savedscripts,
|
|
577
|
-
_visited_scripts=visited,
|
|
578
|
-
)
|
|
579
|
-
for sev, src, lno, msg in saved_issues:
|
|
580
|
-
issues.append((sev, src, lno, f"[script '{script_name}'] {msg}"))
|
|
581
|
-
|
|
582
|
-
return issues
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
# ---------------------------------------------------------------------------
|
|
586
|
-
# Rich output helper
|
|
587
|
-
# ---------------------------------------------------------------------------
|
|
588
|
-
|
|
589
|
-
|
|
590
41
|
def _print_lint_results(issues: list[_Issue], script_label: str) -> int:
|
|
591
42
|
"""Print lint issues to the console using Rich formatting.
|
|
592
43
|
|