execsql2 2.18.0__py3-none-any.whl → 2.19.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 +3 -5
- execsql/cli/lint.py +433 -18
- execsql/cli/run.py +46 -17
- execsql/data/execsql.conf.template +34 -2
- execsql/db/access.py +0 -6
- execsql/db/base.py +0 -13
- execsql/db/mysql.py +0 -6
- execsql/db/oracle.py +0 -6
- execsql/db/sqlserver.py +0 -6
- execsql/debug/repl.py +117 -35
- execsql/exporters/feather.py +10 -9
- execsql/format.py +23 -4
- execsql/importers/base.py +3 -4
- execsql/importers/xls.py +6 -1
- execsql/metacommands/__init__.py +2 -2
- execsql/metacommands/data.py +1 -0
- execsql/metacommands/dispatch.py +5 -10
- execsql/metacommands/script_ext.py +8 -7
- execsql/script/engine.py +1 -12
- execsql/script/executor.py +2 -6
- execsql/script/parser.py +49 -12
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/example_config_prompt.sql +1 -1
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/execsql.conf +34 -2
- {execsql2-2.18.0.dist-info → execsql2-2.19.0.dist-info}/METADATA +54 -43
- {execsql2-2.18.0.dist-info → execsql2-2.19.0.dist-info}/RECORD +42 -43
- {execsql2-2.18.0.dist-info → execsql2-2.19.0.dist-info}/WHEEL +1 -1
- execsql/cli/lint_ast.py +0 -439
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.18.0.dist-info → execsql2-2.19.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.18.0.dist-info → execsql2-2.19.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.18.0.dist-info → execsql2-2.19.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/__init__.py
CHANGED
|
@@ -8,8 +8,7 @@ Submodules:
|
|
|
8
8
|
- :mod:`execsql.cli.help` — Rich-formatted help output & console objects
|
|
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
|
-
- :mod:`execsql.cli.
|
|
12
|
-
- :mod:`execsql.cli.lint` — Shared lint result printing (``_print_lint_results``) used by the AST linter
|
|
11
|
+
- :mod:`execsql.cli.lint` — AST-based ``--lint`` static analyser and Rich result printer
|
|
13
12
|
"""
|
|
14
13
|
|
|
15
14
|
from __future__ import annotations
|
|
@@ -554,8 +553,7 @@ def main(
|
|
|
554
553
|
# Lint: AST-based static analysis (no DB connection needed)
|
|
555
554
|
# ------------------------------------------------------------------
|
|
556
555
|
if lint:
|
|
557
|
-
from execsql.cli.lint import _print_lint_results
|
|
558
|
-
from execsql.cli.lint_ast import lint_ast
|
|
556
|
+
from execsql.cli.lint import _print_lint_results, lint as _lint_script
|
|
559
557
|
from execsql.script.parser import parse_script, parse_string
|
|
560
558
|
|
|
561
559
|
label = script_name or "<inline>"
|
|
@@ -576,7 +574,7 @@ def main(
|
|
|
576
574
|
exit_code = _print_lint_results(issues, label)
|
|
577
575
|
raise typer.Exit(code=exit_code) from exc
|
|
578
576
|
|
|
579
|
-
issues =
|
|
577
|
+
issues = _lint_script(tree, script_path=script_name)
|
|
580
578
|
exit_code = _print_lint_results(issues, label)
|
|
581
579
|
raise typer.Exit(code=exit_code)
|
|
582
580
|
|
execsql/cli/lint.py
CHANGED
|
@@ -1,30 +1,63 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""AST-based static analysis (``--lint``) for execsql scripts.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Operates on the :class:`~execsql.script.ast.Script` tree produced by
|
|
4
|
+
:func:`execsql.script.parser.parse_script` / ``parse_string``. Runs as
|
|
5
|
+
an early CLI exit — no DB connection and no ``_state`` initialisation
|
|
6
|
+
required.
|
|
7
7
|
|
|
8
|
-
|
|
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``).
|
|
8
|
+
Checks performed:
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
1. **Parse errors** — the AST parser rejects unmatched IF / LOOP /
|
|
11
|
+
BATCH / SCRIPT blocks at parse time with precise source spans;
|
|
12
|
+
``cli/__init__.py`` reports any parse failure as a lint error before
|
|
13
|
+
:func:`lint` is even called.
|
|
14
|
+
2. **Empty scripts** — warns when no nodes were parsed.
|
|
15
|
+
3. **Potentially undefined variables** — flags ``!!$VAR!!`` references
|
|
16
|
+
with no preceding ``SUB``-family definition, ignoring built-in
|
|
17
|
+
``$VAR`` names discovered from the package source and the
|
|
18
|
+
non-``$`` sigils (``~``, ``#``, ``+``, ``@``, ``&``) that resolve at
|
|
19
|
+
runtime.
|
|
20
|
+
4. **Missing INCLUDE files** — warns when the resolved target does not
|
|
21
|
+
exist on disk (skipped when ``IF EXISTS`` is present).
|
|
22
|
+
5. **EXECUTE SCRIPT target resolution** — warns when a target name does
|
|
23
|
+
not correspond to a :class:`ScriptBlock` in the same file (skipped
|
|
24
|
+
when ``IF EXISTS`` is present).
|
|
18
25
|
|
|
19
|
-
|
|
26
|
+
Public surface:
|
|
20
27
|
|
|
21
|
-
-
|
|
22
|
-
|
|
28
|
+
- :func:`lint` — entry point; returns a list of
|
|
29
|
+
``(severity, source, line_no, message)`` tuples.
|
|
30
|
+
- :func:`_print_lint_results` — Rich console formatter for those
|
|
31
|
+
tuples; returns the ``--lint`` process exit code (``1`` when any
|
|
32
|
+
error-severity issue is present, ``0`` otherwise).
|
|
33
|
+
- :data:`_Issue`, :func:`_error`, :func:`_warning` — tuple type alias
|
|
34
|
+
and constructors used by the walker and the formatter.
|
|
23
35
|
"""
|
|
24
36
|
|
|
25
37
|
from __future__ import annotations
|
|
26
38
|
|
|
27
|
-
|
|
39
|
+
import re
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from execsql.script.ast import (
|
|
43
|
+
BatchBlock,
|
|
44
|
+
IfBlock,
|
|
45
|
+
IncludeDirective,
|
|
46
|
+
LoopBlock,
|
|
47
|
+
MetaCommandStatement,
|
|
48
|
+
Node,
|
|
49
|
+
Script,
|
|
50
|
+
ScriptBlock,
|
|
51
|
+
SqlBlock,
|
|
52
|
+
SqlStatement,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = ["_Issue", "_error", "_print_lint_results", "_warning", "lint"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Issue tuple type and constructors
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
28
61
|
|
|
29
62
|
|
|
30
63
|
_Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
|
|
@@ -38,6 +71,388 @@ def _warning(source: str, line_no: int, message: str) -> _Issue:
|
|
|
38
71
|
return ("warning", source, line_no, message)
|
|
39
72
|
|
|
40
73
|
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Variable-related patterns
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_RX_SUB = re.compile(r"^\s*SUB\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
79
|
+
_RX_SUB_EMPTY = re.compile(r"^\s*SUB_EMPTY\s+(?P<name>[+~]?\w+)\s*$", re.I)
|
|
80
|
+
_RX_SUB_ADD = re.compile(r"^\s*SUB_ADD\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
81
|
+
_RX_SUB_APPEND = re.compile(r"^\s*SUB_APPEND\s+(?P<name>[+~]?\w+)\s", re.I)
|
|
82
|
+
_RX_SUBDATA = re.compile(r"^\s*SUBDATA\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
83
|
+
_RX_SUB_INI = re.compile(
|
|
84
|
+
r'^\s*SUB_INI\s+(?:FILE\s+)?(?:"(?P<qfile>[^"]+)"|(?P<file>\S+))'
|
|
85
|
+
r"(?:\s+SECTION)?\s+(?P<section>\w+)\s*$",
|
|
86
|
+
re.I,
|
|
87
|
+
)
|
|
88
|
+
_RX_SELECTSUB = re.compile(r"^\s*(?:SELECT_?SUB|PROMPT\s+SELECT_?SUB)\s+", re.I)
|
|
89
|
+
_RX_SUB_LOCAL = re.compile(r"^\s*SUB_LOCAL\s+(?P<name>\w+)\s+", re.I)
|
|
90
|
+
_RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name>\w+)\s", re.I)
|
|
91
|
+
_RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>\w+)\s+", re.I)
|
|
92
|
+
_RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>\w+)\s+", re.I)
|
|
93
|
+
_RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<name>\w+)\s+", re.I)
|
|
94
|
+
|
|
95
|
+
_RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Built-in variable discovery
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _discover_builtin_vars() -> frozenset[str]:
|
|
104
|
+
"""Scan the execsql package source for ``$VARNAME`` system variables."""
|
|
105
|
+
import importlib.util
|
|
106
|
+
|
|
107
|
+
_rx_add_sub = re.compile(r'(?:(?<!\w)add_substitution|(?<!\w)sv)\s*\(\s*["\'](\$\w+)["\']')
|
|
108
|
+
_rx_lazy = re.compile(r'register_lazy\s*\(\s*["\'](\$\w+)["\']')
|
|
109
|
+
|
|
110
|
+
names: set[str] = set()
|
|
111
|
+
|
|
112
|
+
spec = importlib.util.find_spec("execsql")
|
|
113
|
+
if spec is None or spec.submodule_search_locations is None:
|
|
114
|
+
return frozenset(names)
|
|
115
|
+
|
|
116
|
+
pkg_dir = Path(spec.submodule_search_locations[0])
|
|
117
|
+
for src_file in pkg_dir.rglob("*.py"):
|
|
118
|
+
try:
|
|
119
|
+
text = src_file.read_text(encoding="utf-8")
|
|
120
|
+
except OSError:
|
|
121
|
+
continue
|
|
122
|
+
for m in _rx_add_sub.finditer(text):
|
|
123
|
+
names.add(m.group(1).lstrip("$").upper())
|
|
124
|
+
for m in _rx_lazy.finditer(text):
|
|
125
|
+
names.add(m.group(1).lstrip("$").upper())
|
|
126
|
+
|
|
127
|
+
return frozenset(names)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_BUILTIN_VARS: frozenset[str] | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _get_builtin_vars() -> frozenset[str]:
|
|
134
|
+
"""Return the cached set of built-in variable names, discovering on first call."""
|
|
135
|
+
global _BUILTIN_VARS
|
|
136
|
+
if _BUILTIN_VARS is None:
|
|
137
|
+
_BUILTIN_VARS = _discover_builtin_vars()
|
|
138
|
+
return _BUILTIN_VARS
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# AST walker helpers
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _collect_script_blocks(script: Script) -> dict[str, ScriptBlock]:
|
|
147
|
+
"""Build a name → ScriptBlock lookup from all ScriptBlock nodes in the tree."""
|
|
148
|
+
blocks: dict[str, ScriptBlock] = {}
|
|
149
|
+
for node in script.walk():
|
|
150
|
+
if isinstance(node, ScriptBlock):
|
|
151
|
+
blocks[node.name] = node
|
|
152
|
+
return blocks
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _collect_defined_vars_from_nodes(
|
|
156
|
+
nodes: list[Node],
|
|
157
|
+
script_blocks: dict[str, ScriptBlock],
|
|
158
|
+
script_dir: Path | None,
|
|
159
|
+
defined: set[str],
|
|
160
|
+
visited: set[str] | None = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Walk nodes and collect variable definitions into *defined*."""
|
|
163
|
+
if visited is None:
|
|
164
|
+
visited = set()
|
|
165
|
+
|
|
166
|
+
for node in nodes:
|
|
167
|
+
if isinstance(node, MetaCommandStatement):
|
|
168
|
+
_extract_var_definition(node.command, script_dir, defined)
|
|
169
|
+
|
|
170
|
+
elif isinstance(node, IncludeDirective) and node.is_execute_script:
|
|
171
|
+
target = node.target.lower()
|
|
172
|
+
if target in script_blocks and target not in visited:
|
|
173
|
+
visited.add(target)
|
|
174
|
+
_collect_defined_vars_from_nodes(
|
|
175
|
+
script_blocks[target].body,
|
|
176
|
+
script_blocks,
|
|
177
|
+
script_dir,
|
|
178
|
+
defined,
|
|
179
|
+
visited,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Recurse into block children
|
|
183
|
+
if isinstance(node, (IfBlock, LoopBlock, BatchBlock, ScriptBlock, SqlBlock)):
|
|
184
|
+
_collect_defined_vars_from_nodes(
|
|
185
|
+
list(node.children()),
|
|
186
|
+
script_blocks,
|
|
187
|
+
script_dir,
|
|
188
|
+
defined,
|
|
189
|
+
visited,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _extract_var_definition(
|
|
194
|
+
command: str,
|
|
195
|
+
script_dir: Path | None,
|
|
196
|
+
defined: set[str],
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Extract variable name from a SUB-family metacommand into *defined*."""
|
|
199
|
+
for rx in (
|
|
200
|
+
_RX_SUB,
|
|
201
|
+
_RX_SUB_EMPTY,
|
|
202
|
+
_RX_SUB_ADD,
|
|
203
|
+
_RX_SUB_APPEND,
|
|
204
|
+
_RX_SUBDATA,
|
|
205
|
+
_RX_SUB_LOCAL,
|
|
206
|
+
_RX_SUB_TEMPFILE,
|
|
207
|
+
_RX_SUB_DECRYPT,
|
|
208
|
+
_RX_SUB_ENCRYPT,
|
|
209
|
+
_RX_SUB_QUERYSTRING,
|
|
210
|
+
):
|
|
211
|
+
m = rx.match(command)
|
|
212
|
+
if m:
|
|
213
|
+
defined.add(m.group("name").lstrip("+~").upper())
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
# SUB_INI bulk-defines from INI file — read keys at lint time
|
|
217
|
+
ini_m = _RX_SUB_INI.match(command)
|
|
218
|
+
if ini_m:
|
|
219
|
+
ini_file = ini_m.group("qfile") or ini_m.group("file")
|
|
220
|
+
ini_section = ini_m.group("section")
|
|
221
|
+
if ini_file and not _RX_VAR_REF.search(ini_file):
|
|
222
|
+
_read_ini_vars(ini_file, ini_section, script_dir, defined)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _read_ini_vars(
|
|
226
|
+
ini_file: str,
|
|
227
|
+
section: str,
|
|
228
|
+
script_dir: Path | None,
|
|
229
|
+
defined_vars: set[str],
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Read an INI file and register its section keys as defined variables."""
|
|
232
|
+
from configparser import ConfigParser
|
|
233
|
+
|
|
234
|
+
p = Path(ini_file)
|
|
235
|
+
if not p.is_absolute() and script_dir is not None:
|
|
236
|
+
p = script_dir / p
|
|
237
|
+
|
|
238
|
+
if not p.exists():
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
cp = ConfigParser()
|
|
242
|
+
cp.read(p)
|
|
243
|
+
if cp.has_section(section):
|
|
244
|
+
for key, _value in cp.items(section):
|
|
245
|
+
defined_vars.add(key.upper())
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _check_var_ref(
|
|
249
|
+
raw_name: str,
|
|
250
|
+
source: str,
|
|
251
|
+
line_no: int,
|
|
252
|
+
defined_vars: set[str],
|
|
253
|
+
issues: list[_Issue],
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Emit a warning if *raw_name* looks like an undefined user variable."""
|
|
256
|
+
if not raw_name:
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
sigil = raw_name[0] if raw_name[0] in ("$", "@", "&", "~", "#", "+") else ""
|
|
260
|
+
name = raw_name[len(sigil) :]
|
|
261
|
+
|
|
262
|
+
# Skip non-$ sigil prefixes — resolved at runtime
|
|
263
|
+
if sigil in ("@", "&", "~", "#", "+"):
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# $ARG_N is set via -a/--assign-arg at invocation time
|
|
267
|
+
if re.match(r"^ARG_\d+$", name, re.I):
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
# $COUNTER_N is managed by CounterVars
|
|
271
|
+
if re.match(r"^COUNTER_\d+$", name, re.I):
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
# Built-in system variables
|
|
275
|
+
if name.upper() in _get_builtin_vars():
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# User-defined via SUB
|
|
279
|
+
if name.upper() in defined_vars:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
issues.append(
|
|
283
|
+
_warning(
|
|
284
|
+
source,
|
|
285
|
+
line_no,
|
|
286
|
+
f"Potentially undefined variable: !!{raw_name}!! "
|
|
287
|
+
"(not defined by a preceding SUB; may be set by a config file or -a arg)",
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _check_include_path(
|
|
293
|
+
raw_path: str,
|
|
294
|
+
script_dir: Path | None,
|
|
295
|
+
source: str,
|
|
296
|
+
line_no: int,
|
|
297
|
+
issues: list[_Issue],
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Warn if the INCLUDE target does not exist on disk."""
|
|
300
|
+
p = Path(raw_path)
|
|
301
|
+
if not p.is_absolute() and script_dir is not None:
|
|
302
|
+
p = script_dir / p
|
|
303
|
+
|
|
304
|
+
if not p.exists():
|
|
305
|
+
issues.append(
|
|
306
|
+
_warning(source, line_no, f"INCLUDE target does not exist: {raw_path!r}"),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Core lint walk
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _lint_nodes(
|
|
316
|
+
nodes: list[Node],
|
|
317
|
+
script_dir: Path | None,
|
|
318
|
+
defined_vars: set[str],
|
|
319
|
+
script_blocks: dict[str, ScriptBlock],
|
|
320
|
+
issues: list[_Issue],
|
|
321
|
+
*,
|
|
322
|
+
visited_scripts: set[str] | None = None,
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Walk a list of AST nodes and collect lint issues."""
|
|
325
|
+
if visited_scripts is None:
|
|
326
|
+
visited_scripts = set()
|
|
327
|
+
|
|
328
|
+
for node in nodes:
|
|
329
|
+
src = node.span.file
|
|
330
|
+
lno = node.span.start_line
|
|
331
|
+
|
|
332
|
+
# -- Variable references in SQL --
|
|
333
|
+
if isinstance(node, SqlStatement):
|
|
334
|
+
for m in _RX_VAR_REF.finditer(node.text):
|
|
335
|
+
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
336
|
+
|
|
337
|
+
# -- Metacommand checks --
|
|
338
|
+
elif isinstance(node, MetaCommandStatement):
|
|
339
|
+
for m in _RX_VAR_REF.finditer(node.command):
|
|
340
|
+
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
341
|
+
|
|
342
|
+
# -- IncludeDirective checks --
|
|
343
|
+
elif isinstance(node, IncludeDirective):
|
|
344
|
+
if node.is_execute_script:
|
|
345
|
+
target = node.target.lower()
|
|
346
|
+
if target not in script_blocks:
|
|
347
|
+
if not node.if_exists:
|
|
348
|
+
issues.append(
|
|
349
|
+
_warning(src, lno, f"EXECUTE SCRIPT target not found: '{target}'"),
|
|
350
|
+
)
|
|
351
|
+
elif target not in visited_scripts:
|
|
352
|
+
visited_scripts.add(target)
|
|
353
|
+
_lint_nodes(
|
|
354
|
+
script_blocks[target].body,
|
|
355
|
+
script_dir,
|
|
356
|
+
defined_vars,
|
|
357
|
+
script_blocks,
|
|
358
|
+
issues,
|
|
359
|
+
visited_scripts=visited_scripts,
|
|
360
|
+
)
|
|
361
|
+
else:
|
|
362
|
+
# INCLUDE file existence check
|
|
363
|
+
if not node.if_exists:
|
|
364
|
+
raw_path = node.target.strip().strip("\"'")
|
|
365
|
+
if not _RX_VAR_REF.search(raw_path):
|
|
366
|
+
_check_include_path(raw_path, script_dir, src, lno, issues)
|
|
367
|
+
|
|
368
|
+
# -- Recurse into block children --
|
|
369
|
+
if isinstance(node, IfBlock):
|
|
370
|
+
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
371
|
+
for clause in node.elseif_clauses:
|
|
372
|
+
_lint_nodes(
|
|
373
|
+
clause.body,
|
|
374
|
+
script_dir,
|
|
375
|
+
defined_vars,
|
|
376
|
+
script_blocks,
|
|
377
|
+
issues,
|
|
378
|
+
visited_scripts=visited_scripts,
|
|
379
|
+
)
|
|
380
|
+
_lint_nodes(
|
|
381
|
+
node.else_body,
|
|
382
|
+
script_dir,
|
|
383
|
+
defined_vars,
|
|
384
|
+
script_blocks,
|
|
385
|
+
issues,
|
|
386
|
+
visited_scripts=visited_scripts,
|
|
387
|
+
)
|
|
388
|
+
elif isinstance(node, (LoopBlock, BatchBlock, SqlBlock)):
|
|
389
|
+
_lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
|
|
390
|
+
elif isinstance(node, ScriptBlock):
|
|
391
|
+
# Lint script block body (structural errors already caught by parser)
|
|
392
|
+
if node.name not in visited_scripts:
|
|
393
|
+
visited_scripts.add(node.name)
|
|
394
|
+
sub_issues: list[_Issue] = []
|
|
395
|
+
_lint_nodes(
|
|
396
|
+
node.body,
|
|
397
|
+
script_dir,
|
|
398
|
+
defined_vars,
|
|
399
|
+
script_blocks,
|
|
400
|
+
sub_issues,
|
|
401
|
+
visited_scripts=visited_scripts,
|
|
402
|
+
)
|
|
403
|
+
for sev, ssrc, slno, msg in sub_issues:
|
|
404
|
+
issues.append((sev, ssrc, slno, f"[script '{node.name}'] {msg}"))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
# Public entry point
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def lint(
|
|
413
|
+
script: Script,
|
|
414
|
+
script_path: str | None = None,
|
|
415
|
+
) -> list[_Issue]:
|
|
416
|
+
"""Perform static analysis on an AST-parsed script.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
script: The parsed :class:`Script` tree.
|
|
420
|
+
script_path: Path to the source file (for resolving relative
|
|
421
|
+
INCLUDE paths). ``None`` for inline scripts.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
List of ``(severity, source, line_no, message)`` issue tuples.
|
|
425
|
+
"""
|
|
426
|
+
issues: list[_Issue] = []
|
|
427
|
+
|
|
428
|
+
if not script.body:
|
|
429
|
+
issues.append(_warning("<script>", 0, "Script is empty — no commands found"))
|
|
430
|
+
return issues
|
|
431
|
+
|
|
432
|
+
script_dir = Path(script_path).resolve().parent if script_path else None
|
|
433
|
+
script_blocks = _collect_script_blocks(script)
|
|
434
|
+
|
|
435
|
+
# Pass 1: collect all variable definitions
|
|
436
|
+
all_defined: set[str] = set()
|
|
437
|
+
_collect_defined_vars_from_nodes(script.body, script_blocks, script_dir, all_defined)
|
|
438
|
+
|
|
439
|
+
# Pass 2: lint for variable and include issues
|
|
440
|
+
_lint_nodes(
|
|
441
|
+
script.body,
|
|
442
|
+
script_dir,
|
|
443
|
+
all_defined,
|
|
444
|
+
script_blocks,
|
|
445
|
+
issues,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return issues
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
# Result printing
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
|
|
41
456
|
def _print_lint_results(issues: list[_Issue], script_label: str) -> int:
|
|
42
457
|
"""Print lint issues to the console using Rich formatting.
|
|
43
458
|
|
execsql/cli/run.py
CHANGED
|
@@ -65,12 +65,21 @@ def _print_dry_run(tree: object) -> None:
|
|
|
65
65
|
else:
|
|
66
66
|
ctype = "METACMD"
|
|
67
67
|
raw = "-- !x! " + node.command
|
|
68
|
-
|
|
68
|
+
file_loc = f"{node.span.file}:{node.span.start_line}"
|
|
69
69
|
try:
|
|
70
70
|
expanded = substitute_vars(raw)
|
|
71
71
|
except Exception:
|
|
72
72
|
expanded = raw
|
|
73
|
-
|
|
73
|
+
lines = expanded.splitlines() or [""]
|
|
74
|
+
first, *continuations = lines
|
|
75
|
+
_console.print(
|
|
76
|
+
f" [dim]{i:>4}[/dim] [bold green]{ctype}[/bold green] [dim]{file_loc}[/dim] {first}",
|
|
77
|
+
)
|
|
78
|
+
if continuations:
|
|
79
|
+
prefix_width = 2 + 4 + 2 + 7 + 2 + len(file_loc) + 2
|
|
80
|
+
pad = " " * prefix_width
|
|
81
|
+
for cont in continuations:
|
|
82
|
+
_console.print(f"{pad}{cont}")
|
|
74
83
|
|
|
75
84
|
|
|
76
85
|
# ---------------------------------------------------------------------------
|
|
@@ -411,6 +420,12 @@ def _route_positionals(
|
|
|
411
420
|
ping: bool,
|
|
412
421
|
) -> None:
|
|
413
422
|
"""Apply remaining positional CLI arguments to *conf* as server/db/db_file."""
|
|
423
|
+
# When --ping is set, the script-file positional is ignored (ping has no
|
|
424
|
+
# script). Users who add --ping to an existing invocation often leave the
|
|
425
|
+
# script in place — drop it from positional routing so the connection args
|
|
426
|
+
# behind it are interpreted correctly.
|
|
427
|
+
if ping and positional and Path(positional[0]).is_file() and positional[0].lower().endswith(".sql"):
|
|
428
|
+
positional = positional[1:]
|
|
414
429
|
off = 0 if (command is not None or ping) else 1
|
|
415
430
|
if len(positional) == off + 1:
|
|
416
431
|
if conf.db_type in ("a", "l", "k"):
|
|
@@ -648,6 +663,33 @@ def _run(
|
|
|
648
663
|
|
|
649
664
|
_state.output = WriteHooks()
|
|
650
665
|
|
|
666
|
+
# ------------------------------------------------------------------
|
|
667
|
+
# Early exits — modes that touch nothing besides parsing/connecting.
|
|
668
|
+
# Keep these before FileWriter / log / atexit so --dry-run and --ping
|
|
669
|
+
# honor their "no side effects" contracts.
|
|
670
|
+
# ------------------------------------------------------------------
|
|
671
|
+
if dry_run:
|
|
672
|
+
# Seed -a assign-args so substitute_vars in the dry-run print can
|
|
673
|
+
# expand them; the normal path does this inside _setup_logging,
|
|
674
|
+
# which we are skipping.
|
|
675
|
+
if sub_vars:
|
|
676
|
+
for n, repl in enumerate(sub_vars):
|
|
677
|
+
_state.subvars.add_substitution(f"$ARG_{n + 1}", repl)
|
|
678
|
+
_ast_tree = _load_script(command, script_name, conf.script_encoding)
|
|
679
|
+
_print_dry_run(_ast_tree)
|
|
680
|
+
raise SystemExit(0)
|
|
681
|
+
|
|
682
|
+
if ping:
|
|
683
|
+
if conf.server is None and conf.db is None and conf.db_file is None:
|
|
684
|
+
from execsql.utils.errors import fatal_error
|
|
685
|
+
|
|
686
|
+
fatal_error(
|
|
687
|
+
"Database not specified for --ping in configuration files or command-line arguments.",
|
|
688
|
+
)
|
|
689
|
+
db = _connect_initial_db(conf)
|
|
690
|
+
_state.dbs.add("initial", db)
|
|
691
|
+
_ping_db(db) # raises SystemExit
|
|
692
|
+
|
|
651
693
|
import execsql.utils.fileio as _fileio
|
|
652
694
|
|
|
653
695
|
if _state.filewriter is None or not _state.filewriter.is_alive():
|
|
@@ -696,16 +738,9 @@ def _run(
|
|
|
696
738
|
)
|
|
697
739
|
|
|
698
740
|
# ------------------------------------------------------------------
|
|
699
|
-
# Load the SQL script (
|
|
741
|
+
# Load the SQL script (--dry-run / --ping already exited above)
|
|
700
742
|
# ------------------------------------------------------------------
|
|
701
|
-
_ast_tree =
|
|
702
|
-
|
|
703
|
-
# ------------------------------------------------------------------
|
|
704
|
-
# Dry-run: print command list and exit without connecting to DB
|
|
705
|
-
# ------------------------------------------------------------------
|
|
706
|
-
if dry_run:
|
|
707
|
-
_print_dry_run(_ast_tree)
|
|
708
|
-
raise SystemExit(0)
|
|
743
|
+
_ast_tree = _load_script(command, script_name, conf.script_encoding)
|
|
709
744
|
|
|
710
745
|
# ------------------------------------------------------------------
|
|
711
746
|
# NOTE: --lint is handled as an early exit in cli/__init__.py (AST
|
|
@@ -741,12 +776,6 @@ def _run(
|
|
|
741
776
|
_state.subvars.add_substitution("$DB_SERVER", db.server_name)
|
|
742
777
|
_state.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", "0")
|
|
743
778
|
|
|
744
|
-
# ------------------------------------------------------------------
|
|
745
|
-
# --ping: report connection details and exit (no script executed)
|
|
746
|
-
# ------------------------------------------------------------------
|
|
747
|
-
if ping:
|
|
748
|
-
_ping_db(db) # raises SystemExit(0) on success
|
|
749
|
-
|
|
750
779
|
# ------------------------------------------------------------------
|
|
751
780
|
# Execute the script
|
|
752
781
|
# ------------------------------------------------------------------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# execsql.conf — Configuration file for execsql
|
|
2
2
|
#
|
|
3
|
-
# Documentation: https://execsql2.readthedocs.io/reference/configuration/
|
|
3
|
+
# Documentation: https://execsql2.readthedocs.io/en/latest/reference/configuration/
|
|
4
4
|
#
|
|
5
5
|
# This file uses INI format. Section names are case-sensitive (lowercase).
|
|
6
6
|
# Property names are not case-sensitive. Lines starting with # are comments.
|
|
@@ -263,7 +263,7 @@
|
|
|
263
263
|
#message_css=
|
|
264
264
|
|
|
265
265
|
# Obfuscated password (XOR, not cryptographically secure — use keyring instead).
|
|
266
|
-
# See: https://execsql2.readthedocs.io/reference/security/#credentials
|
|
266
|
+
# See: https://execsql2.readthedocs.io/en/latest/reference/security/#credentials
|
|
267
267
|
#enc_password=
|
|
268
268
|
|
|
269
269
|
|
|
@@ -279,6 +279,38 @@
|
|
|
279
279
|
# Values: Yes or No. Default: Yes.
|
|
280
280
|
#allow_system_cmd=Yes
|
|
281
281
|
|
|
282
|
+
# Whether to allow the RM_FILE metacommand (which deletes a file).
|
|
283
|
+
# Set to No to prevent scripts from deleting files.
|
|
284
|
+
# Also controllable via --no-rm-file CLI flag.
|
|
285
|
+
# Values: Yes or No. Default: Yes.
|
|
286
|
+
#allow_rm_file=Yes
|
|
287
|
+
|
|
288
|
+
# Whether to allow the SERVE metacommand (which opens an HTTP server on
|
|
289
|
+
# a local port to serve a single file). Set to No to disable.
|
|
290
|
+
# Also controllable via --no-serve CLI flag.
|
|
291
|
+
# Values: Yes or No. Default: Yes.
|
|
292
|
+
#allow_serve=Yes
|
|
293
|
+
|
|
294
|
+
# Root directory under which INCLUDE / EXECUTE SCRIPT targets must
|
|
295
|
+
# resolve. When set, attempts to include files outside this root via
|
|
296
|
+
# ../, absolute paths, drive letters, or UNC paths are rejected with
|
|
297
|
+
# an error. Default: no containment (any readable path is permitted).
|
|
298
|
+
#include_root=
|
|
299
|
+
|
|
300
|
+
# Root directory under which SERVE targets must resolve. Same
|
|
301
|
+
# containment semantics as include_root. Default: no containment.
|
|
302
|
+
#serve_root=
|
|
303
|
+
|
|
304
|
+
# Root directory under which Jinja2 / string.Template loader paths
|
|
305
|
+
# must resolve. Same containment semantics as include_root.
|
|
306
|
+
# Default: no containment.
|
|
307
|
+
#template_root=
|
|
308
|
+
|
|
309
|
+
# Maximum byte size of any single substitution-variable expansion,
|
|
310
|
+
# enforced by the substitute_vars() engine to defeat exponential-
|
|
311
|
+
# expansion bombs. Default: 10 MB (10485760).
|
|
312
|
+
#max_substitution_bytes=10485760
|
|
313
|
+
|
|
282
314
|
# Whether to log all data variable assignments.
|
|
283
315
|
# Values: Yes or No. Default: Yes.
|
|
284
316
|
#log_datavars=Yes
|
execsql/db/access.py
CHANGED
|
@@ -88,12 +88,6 @@ class AccessDatabase(Database):
|
|
|
88
88
|
def __repr__(self) -> str:
|
|
89
89
|
return f"AccessDatabase({self.db_name}, {self.encoding})"
|
|
90
90
|
|
|
91
|
-
def auto_commits_ddl(self) -> bool:
|
|
92
|
-
"""MS Access (via Jet/ACE on pyodbc) implicitly commits DDL —
|
|
93
|
-
``rollback()`` is a silent no-op for any transaction whose
|
|
94
|
-
boundary the DDL crossed."""
|
|
95
|
-
return True
|
|
96
|
-
|
|
97
91
|
def open_db(self) -> None:
|
|
98
92
|
"""Open an ODBC connection to the Access database."""
|
|
99
93
|
# Open an ODBC connection.
|
execsql/db/base.py
CHANGED
|
@@ -253,19 +253,6 @@ class Database(ABC):
|
|
|
253
253
|
"""
|
|
254
254
|
return False
|
|
255
255
|
|
|
256
|
-
def auto_commits_ddl(self) -> bool:
|
|
257
|
-
"""Return True if this adapter's driver implicitly commits DDL.
|
|
258
|
-
|
|
259
|
-
Oracle, MySQL, SQL Server, and MS Access all auto-commit DDL —
|
|
260
|
-
``rollback()`` is a silent no-op for any transaction whose
|
|
261
|
-
boundary the DDL crossed. Callers that wrap DDL inside an
|
|
262
|
-
explicit ``BEGIN BATCH … END BATCH`` block on these adapters
|
|
263
|
-
get weaker rollback guarantees than on PostgreSQL / SQLite, and
|
|
264
|
-
should be aware of the asymmetry. See
|
|
265
|
-
``docs/about/divergence.md`` for the full per-DBMS matrix.
|
|
266
|
-
"""
|
|
267
|
-
return False
|
|
268
|
-
|
|
269
256
|
def schema_qualified_table_name(self, schema_name: str | None, table_name: str) -> str:
|
|
270
257
|
"""Return the quoted, optionally schema-qualified form of *table_name*."""
|
|
271
258
|
table_name = self.type.quoted(table_name)
|