execsql2 2.17.2__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.
Files changed (52) hide show
  1. execsql/cli/__init__.py +13 -1
  2. execsql/cli/lint.py +16 -565
  3. execsql/cli/run.py +29 -2
  4. execsql/config.py +20 -0
  5. execsql/db/access.py +6 -0
  6. execsql/db/base.py +57 -1
  7. execsql/db/dsn.py +19 -9
  8. execsql/db/firebird.py +6 -0
  9. execsql/db/mysql.py +81 -0
  10. execsql/db/oracle.py +6 -0
  11. execsql/db/sqlite.py +37 -18
  12. execsql/db/sqlserver.py +31 -6
  13. execsql/exporters/base.py +1 -1
  14. execsql/exporters/duckdb.py +8 -4
  15. execsql/exporters/ods.py +11 -0
  16. execsql/exporters/sqlite.py +10 -3
  17. execsql/exporters/templates.py +10 -0
  18. execsql/exporters/xls.py +4 -0
  19. execsql/exporters/xlsx.py +9 -0
  20. execsql/importers/json.py +49 -32
  21. execsql/metacommands/conditions.py +7 -2
  22. execsql/metacommands/io_export.py +21 -26
  23. execsql/metacommands/io_fileops.py +21 -3
  24. execsql/metacommands/io_import.py +23 -3
  25. execsql/script/ast.py +8 -0
  26. execsql/script/engine.py +32 -0
  27. execsql/script/executor.py +12 -0
  28. execsql/script/variables.py +41 -15
  29. execsql/utils/auth.py +49 -1
  30. execsql/utils/fileio.py +120 -0
  31. execsql/utils/gui.py +11 -1
  32. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/md_compare.sql +12 -12
  33. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/md_glossary.sql +5 -5
  34. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/md_upsert.sql +13 -13
  35. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/pg_compare.sql +24 -24
  36. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/pg_glossary.sql +5 -5
  37. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/pg_upsert.sql +29 -29
  38. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/script_template.sql +2 -2
  39. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/ss_compare.sql +24 -24
  40. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/ss_glossary.sql +6 -6
  41. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/ss_upsert.sql +2917 -2917
  42. {execsql2-2.17.2.dist-info → execsql2-2.18.0.dist-info}/METADATA +8 -3
  43. {execsql2-2.17.2.dist-info → execsql2-2.18.0.dist-info}/RECORD +52 -52
  44. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/README.md +0 -0
  45. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  46. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  47. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/execsql.conf +0 -0
  48. {execsql2-2.17.2.data → execsql2-2.18.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  49. {execsql2-2.17.2.dist-info → execsql2-2.18.0.dist-info}/WHEEL +0 -0
  50. {execsql2-2.17.2.dist-info → execsql2-2.18.0.dist-info}/entry_points.txt +0 -0
  51. {execsql2-2.17.2.dist-info → execsql2-2.18.0.dist-info}/licenses/LICENSE.txt +0 -0
  52. {execsql2-2.17.2.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` — Lint result printing (``_print_lint_results``); the flat-CommandList ``_lint_cmdlist`` it also contains is legacy and no longer reached from the CLI
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
- """Legacy flat-CommandList linter and shared result printer for execsql.
1
+ """Shared Rich-formatted output for ``--lint`` results.
2
2
 
3
- The active ``--lint`` implementation is in :mod:`execsql.cli.lint_ast`,
4
- which works against the AST produced by
5
- :func:`execsql.script.parser.parse_script`. The CLI reaches that
6
- linter directly; the ``_lint_cmdlist`` walker in this module is
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
- What's still used from this module:
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
- - :func:`_print_lint_results` shared Rich-formatted output for both
13
- the AST linter and any code that still constructs lint issues
14
- manually. Called from ``cli/__init__.py`` and re-exported via
15
- ``cli/run.py``.
16
- - :class:`_Issue` and the ``_error()`` / ``_warning()`` constructors
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
- The flat-CommandList ``_lint_cmdlist``, ``_collect_defined_vars``, and
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
- import re
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