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.
Files changed (43) hide show
  1. execsql/cli/__init__.py +3 -5
  2. execsql/cli/lint.py +433 -18
  3. execsql/cli/run.py +46 -17
  4. execsql/data/execsql.conf.template +34 -2
  5. execsql/db/access.py +0 -6
  6. execsql/db/base.py +0 -13
  7. execsql/db/mysql.py +0 -6
  8. execsql/db/oracle.py +0 -6
  9. execsql/db/sqlserver.py +0 -6
  10. execsql/debug/repl.py +117 -35
  11. execsql/exporters/feather.py +10 -9
  12. execsql/format.py +23 -4
  13. execsql/importers/base.py +3 -4
  14. execsql/importers/xls.py +6 -1
  15. execsql/metacommands/__init__.py +2 -2
  16. execsql/metacommands/data.py +1 -0
  17. execsql/metacommands/dispatch.py +5 -10
  18. execsql/metacommands/script_ext.py +8 -7
  19. execsql/script/engine.py +1 -12
  20. execsql/script/executor.py +2 -6
  21. execsql/script/parser.py +49 -12
  22. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/example_config_prompt.sql +1 -1
  23. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/execsql.conf +34 -2
  24. {execsql2-2.18.0.dist-info → execsql2-2.19.0.dist-info}/METADATA +54 -43
  25. {execsql2-2.18.0.dist-info → execsql2-2.19.0.dist-info}/RECORD +42 -43
  26. {execsql2-2.18.0.dist-info → execsql2-2.19.0.dist-info}/WHEEL +1 -1
  27. execsql/cli/lint_ast.py +0 -439
  28. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/README.md +0 -0
  29. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  30. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  31. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  32. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  33. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  34. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  35. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  36. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  37. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/script_template.sql +0 -0
  38. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  39. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  40. {execsql2-2.18.0.data → execsql2-2.19.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  41. {execsql2-2.18.0.dist-info → execsql2-2.19.0.dist-info}/entry_points.txt +0 -0
  42. {execsql2-2.18.0.dist-info → execsql2-2.19.0.dist-info}/licenses/LICENSE.txt +0 -0
  43. {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.lint_ast` — AST-based ``--lint`` static analyser
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 = lint_ast(tree, script_path=script_name)
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
- """Shared Rich-formatted output for ``--lint`` results.
1
+ """AST-based static analysis (``--lint``) for execsql scripts.
2
2
 
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:
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
- - :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``).
8
+ Checks performed:
12
9
 
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.
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
- Exit-code contract (honoured by the AST linter):
26
+ Public surface:
20
27
 
21
- - ``1`` when at least one error-severity issue is found.
22
- - ``0`` when only warnings (or nothing) are found.
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
- __all__ = ["_Issue", "_error", "_print_lint_results", "_warning"]
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
- source_info = f"[dim]{node.span.file}:{node.span.start_line}[/dim]"
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
- _console.print(f" [dim]{i:>4}[/dim] [bold green]{ctype}[/bold green] {source_info} {expanded}")
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 (skipped in --ping mode)
741
+ # Load the SQL script (--dry-run / --ping already exited above)
700
742
  # ------------------------------------------------------------------
701
- _ast_tree = None if ping else _load_script(command, script_name, conf.script_encoding)
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)