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/lint_ast.py DELETED
@@ -1,439 +0,0 @@
1
- """AST-based static analysis (lint) for execsql scripts.
2
-
3
- Performs the same checks as :mod:`execsql.cli.lint` but operates on the
4
- :class:`~execsql.script.ast.Script` tree instead of a flat
5
- :class:`~execsql.script.engine.CommandList`.
6
-
7
- Advantages over the flat linter:
8
-
9
- - **No runtime state required** — works with the AST parser alone, so it
10
- can run as an early exit in the CLI without initialising ``_state``.
11
- - **Structural validation is free** — the AST parser already rejects
12
- unmatched IF/LOOP/BATCH/SCRIPT blocks at parse time with precise source
13
- spans. This linter only needs to report variable and INCLUDE issues.
14
- - **Script blocks are in the tree** — ``EXECUTE SCRIPT`` targets are
15
- resolved by finding :class:`ScriptBlock` nodes, not by looking up
16
- ``_state.savedscripts``.
17
-
18
- Checks performed
19
- ----------------
20
- 1. **Parse errors** — the AST parser rejects unmatched blocks, so any
21
- parse failure is reported as an error with the parser's message.
22
- 2. **Potentially undefined variables** — same heuristic as the flat linter.
23
- 3. **EXECUTE SCRIPT target resolution** — warns when a target name does
24
- not correspond to a :class:`ScriptBlock` in the same file.
25
- 4. **Missing INCLUDE files** — warns when the file does not exist on disk.
26
- 5. **Empty script** — warns when no nodes were parsed.
27
- """
28
-
29
- from __future__ import annotations
30
-
31
- import re
32
- from pathlib import Path
33
-
34
- from execsql.script.ast import (
35
- BatchBlock,
36
- IfBlock,
37
- IncludeDirective,
38
- LoopBlock,
39
- MetaCommandStatement,
40
- Node,
41
- Script,
42
- ScriptBlock,
43
- SqlBlock,
44
- SqlStatement,
45
- )
46
-
47
- __all__ = ["lint_ast"]
48
-
49
-
50
- # ---------------------------------------------------------------------------
51
- # Variable-related patterns (shared with the flat linter)
52
- # ---------------------------------------------------------------------------
53
-
54
- _RX_SUB = re.compile(r"^\s*SUB\s+(?P<name>[+~]?\w+)\s+", re.I)
55
- _RX_SUB_EMPTY = re.compile(r"^\s*SUB_EMPTY\s+(?P<name>[+~]?\w+)\s*$", re.I)
56
- _RX_SUB_ADD = re.compile(r"^\s*SUB_ADD\s+(?P<name>[+~]?\w+)\s+", re.I)
57
- _RX_SUB_APPEND = re.compile(r"^\s*SUB_APPEND\s+(?P<name>[+~]?\w+)\s", re.I)
58
- _RX_SUBDATA = re.compile(r"^\s*SUBDATA\s+(?P<name>[+~]?\w+)\s+", re.I)
59
- _RX_SUB_INI = re.compile(
60
- r'^\s*SUB_INI\s+(?:FILE\s+)?(?:"(?P<qfile>[^"]+)"|(?P<file>\S+))'
61
- r"(?:\s+SECTION)?\s+(?P<section>\w+)\s*$",
62
- re.I,
63
- )
64
- _RX_SELECTSUB = re.compile(r"^\s*(?:SELECT_?SUB|PROMPT\s+SELECT_?SUB)\s+", re.I)
65
- _RX_SUB_LOCAL = re.compile(r"^\s*SUB_LOCAL\s+(?P<name>\w+)\s+", re.I)
66
- _RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name>\w+)\s", re.I)
67
- _RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>\w+)\s+", re.I)
68
- _RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>\w+)\s+", re.I)
69
- _RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<name>\w+)\s+", re.I)
70
-
71
- _RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
72
-
73
-
74
- # ---------------------------------------------------------------------------
75
- # Issue tuple helpers
76
- # ---------------------------------------------------------------------------
77
-
78
- _Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
79
-
80
-
81
- def _error(source: str, line_no: int, message: str) -> _Issue:
82
- return ("error", source, line_no, message)
83
-
84
-
85
- def _warning(source: str, line_no: int, message: str) -> _Issue:
86
- return ("warning", source, line_no, message)
87
-
88
-
89
- # ---------------------------------------------------------------------------
90
- # Built-in variable discovery (reuse from flat linter)
91
- # ---------------------------------------------------------------------------
92
-
93
-
94
- def _discover_builtin_vars() -> frozenset[str]:
95
- """Scan the execsql package source for ``$VARNAME`` system variables."""
96
- import importlib.util
97
-
98
- _rx_add_sub = re.compile(r'(?:(?<!\w)add_substitution|(?<!\w)sv)\s*\(\s*["\'](\$\w+)["\']')
99
- _rx_lazy = re.compile(r'register_lazy\s*\(\s*["\'](\$\w+)["\']')
100
-
101
- names: set[str] = set()
102
-
103
- spec = importlib.util.find_spec("execsql")
104
- if spec is None or spec.submodule_search_locations is None:
105
- return frozenset(names)
106
-
107
- pkg_dir = Path(spec.submodule_search_locations[0])
108
- for src_file in pkg_dir.rglob("*.py"):
109
- try:
110
- text = src_file.read_text(encoding="utf-8")
111
- except OSError:
112
- continue
113
- for m in _rx_add_sub.finditer(text):
114
- names.add(m.group(1).lstrip("$").upper())
115
- for m in _rx_lazy.finditer(text):
116
- names.add(m.group(1).lstrip("$").upper())
117
-
118
- return frozenset(names)
119
-
120
-
121
- _BUILTIN_VARS: frozenset[str] | None = None
122
-
123
-
124
- def _get_builtin_vars() -> frozenset[str]:
125
- """Return the cached set of built-in variable names, discovering on first call."""
126
- global _BUILTIN_VARS
127
- if _BUILTIN_VARS is None:
128
- _BUILTIN_VARS = _discover_builtin_vars()
129
- return _BUILTIN_VARS
130
-
131
-
132
- # ---------------------------------------------------------------------------
133
- # AST walker helpers
134
- # ---------------------------------------------------------------------------
135
-
136
-
137
- def _collect_script_blocks(script: Script) -> dict[str, ScriptBlock]:
138
- """Build a name → ScriptBlock lookup from all ScriptBlock nodes in the tree."""
139
- blocks: dict[str, ScriptBlock] = {}
140
- for node in script.walk():
141
- if isinstance(node, ScriptBlock):
142
- blocks[node.name] = node
143
- return blocks
144
-
145
-
146
- def _collect_defined_vars_from_nodes(
147
- nodes: list[Node],
148
- script_blocks: dict[str, ScriptBlock],
149
- script_dir: Path | None,
150
- defined: set[str],
151
- visited: set[str] | None = None,
152
- ) -> None:
153
- """Walk nodes and collect variable definitions into *defined*."""
154
- if visited is None:
155
- visited = set()
156
-
157
- for node in nodes:
158
- if isinstance(node, MetaCommandStatement):
159
- _extract_var_definition(node.command, script_dir, defined)
160
-
161
- elif isinstance(node, IncludeDirective) and node.is_execute_script:
162
- target = node.target.lower()
163
- if target in script_blocks and target not in visited:
164
- visited.add(target)
165
- _collect_defined_vars_from_nodes(
166
- script_blocks[target].body,
167
- script_blocks,
168
- script_dir,
169
- defined,
170
- visited,
171
- )
172
-
173
- # Recurse into block children
174
- if isinstance(node, (IfBlock, LoopBlock, BatchBlock, ScriptBlock, SqlBlock)):
175
- _collect_defined_vars_from_nodes(
176
- list(node.children()),
177
- script_blocks,
178
- script_dir,
179
- defined,
180
- visited,
181
- )
182
-
183
-
184
- def _extract_var_definition(
185
- command: str,
186
- script_dir: Path | None,
187
- defined: set[str],
188
- ) -> None:
189
- """Extract variable name from a SUB-family metacommand into *defined*."""
190
- for rx in (
191
- _RX_SUB,
192
- _RX_SUB_EMPTY,
193
- _RX_SUB_ADD,
194
- _RX_SUB_APPEND,
195
- _RX_SUBDATA,
196
- _RX_SUB_LOCAL,
197
- _RX_SUB_TEMPFILE,
198
- _RX_SUB_DECRYPT,
199
- _RX_SUB_ENCRYPT,
200
- _RX_SUB_QUERYSTRING,
201
- ):
202
- m = rx.match(command)
203
- if m:
204
- defined.add(m.group("name").lstrip("+~").upper())
205
- return
206
-
207
- # SUB_INI bulk-defines from INI file — read keys at lint time
208
- ini_m = _RX_SUB_INI.match(command)
209
- if ini_m:
210
- ini_file = ini_m.group("qfile") or ini_m.group("file")
211
- ini_section = ini_m.group("section")
212
- if ini_file and not _RX_VAR_REF.search(ini_file):
213
- _read_ini_vars(ini_file, ini_section, script_dir, defined)
214
-
215
-
216
- def _read_ini_vars(
217
- ini_file: str,
218
- section: str,
219
- script_dir: Path | None,
220
- defined_vars: set[str],
221
- ) -> None:
222
- """Read an INI file and register its section keys as defined variables."""
223
- from configparser import ConfigParser
224
-
225
- p = Path(ini_file)
226
- if not p.is_absolute() and script_dir is not None:
227
- p = script_dir / p
228
-
229
- if not p.exists():
230
- return
231
-
232
- cp = ConfigParser()
233
- cp.read(p)
234
- if cp.has_section(section):
235
- for key, _value in cp.items(section):
236
- defined_vars.add(key.upper())
237
-
238
-
239
- def _check_var_ref(
240
- raw_name: str,
241
- source: str,
242
- line_no: int,
243
- defined_vars: set[str],
244
- issues: list[_Issue],
245
- ) -> None:
246
- """Emit a warning if *raw_name* looks like an undefined user variable."""
247
- if not raw_name:
248
- return
249
-
250
- sigil = raw_name[0] if raw_name[0] in ("$", "@", "&", "~", "#", "+") else ""
251
- name = raw_name[len(sigil) :]
252
-
253
- # Skip non-$ sigil prefixes — resolved at runtime
254
- if sigil in ("@", "&", "~", "#", "+"):
255
- return
256
-
257
- # $ARG_N is set via -a/--assign-arg at invocation time
258
- if re.match(r"^ARG_\d+$", name, re.I):
259
- return
260
-
261
- # $COUNTER_N is managed by CounterVars
262
- if re.match(r"^COUNTER_\d+$", name, re.I):
263
- return
264
-
265
- # Built-in system variables
266
- if name.upper() in _get_builtin_vars():
267
- return
268
-
269
- # User-defined via SUB
270
- if name.upper() in defined_vars:
271
- return
272
-
273
- issues.append(
274
- _warning(
275
- source,
276
- line_no,
277
- f"Potentially undefined variable: !!{raw_name}!! "
278
- "(not defined by a preceding SUB; may be set by a config file or -a arg)",
279
- ),
280
- )
281
-
282
-
283
- def _check_include_path(
284
- raw_path: str,
285
- script_dir: Path | None,
286
- source: str,
287
- line_no: int,
288
- issues: list[_Issue],
289
- ) -> None:
290
- """Warn if the INCLUDE target does not exist on disk."""
291
- p = Path(raw_path)
292
- if not p.is_absolute() and script_dir is not None:
293
- p = script_dir / p
294
-
295
- if not p.exists():
296
- issues.append(
297
- _warning(source, line_no, f"INCLUDE target does not exist: {raw_path!r}"),
298
- )
299
-
300
-
301
- # ---------------------------------------------------------------------------
302
- # Core lint walk
303
- # ---------------------------------------------------------------------------
304
-
305
-
306
- def _lint_nodes(
307
- nodes: list[Node],
308
- script_dir: Path | None,
309
- defined_vars: set[str],
310
- script_blocks: dict[str, ScriptBlock],
311
- issues: list[_Issue],
312
- *,
313
- visited_scripts: set[str] | None = None,
314
- ) -> None:
315
- """Walk a list of AST nodes and collect lint issues."""
316
- if visited_scripts is None:
317
- visited_scripts = set()
318
-
319
- for node in nodes:
320
- src = node.span.file
321
- lno = node.span.start_line
322
-
323
- # -- Variable references in SQL --
324
- if isinstance(node, SqlStatement):
325
- for m in _RX_VAR_REF.finditer(node.text):
326
- _check_var_ref(m.group(1), src, lno, defined_vars, issues)
327
-
328
- # -- Metacommand checks --
329
- elif isinstance(node, MetaCommandStatement):
330
- for m in _RX_VAR_REF.finditer(node.command):
331
- _check_var_ref(m.group(1), src, lno, defined_vars, issues)
332
-
333
- # -- IncludeDirective checks --
334
- elif isinstance(node, IncludeDirective):
335
- if node.is_execute_script:
336
- target = node.target.lower()
337
- if target not in script_blocks:
338
- if not node.if_exists:
339
- issues.append(
340
- _warning(src, lno, f"EXECUTE SCRIPT target not found: '{target}'"),
341
- )
342
- elif target not in visited_scripts:
343
- visited_scripts.add(target)
344
- _lint_nodes(
345
- script_blocks[target].body,
346
- script_dir,
347
- defined_vars,
348
- script_blocks,
349
- issues,
350
- visited_scripts=visited_scripts,
351
- )
352
- else:
353
- # INCLUDE file existence check
354
- if not node.if_exists:
355
- raw_path = node.target.strip().strip("\"'")
356
- if not _RX_VAR_REF.search(raw_path):
357
- _check_include_path(raw_path, script_dir, src, lno, issues)
358
-
359
- # -- Recurse into block children --
360
- if isinstance(node, IfBlock):
361
- _lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
362
- for clause in node.elseif_clauses:
363
- _lint_nodes(
364
- clause.body,
365
- script_dir,
366
- defined_vars,
367
- script_blocks,
368
- issues,
369
- visited_scripts=visited_scripts,
370
- )
371
- _lint_nodes(
372
- node.else_body,
373
- script_dir,
374
- defined_vars,
375
- script_blocks,
376
- issues,
377
- visited_scripts=visited_scripts,
378
- )
379
- elif isinstance(node, (LoopBlock, BatchBlock, SqlBlock)):
380
- _lint_nodes(node.body, script_dir, defined_vars, script_blocks, issues, visited_scripts=visited_scripts)
381
- elif isinstance(node, ScriptBlock):
382
- # Lint script block body (structural errors already caught by parser)
383
- if node.name not in visited_scripts:
384
- visited_scripts.add(node.name)
385
- sub_issues: list[_Issue] = []
386
- _lint_nodes(
387
- node.body,
388
- script_dir,
389
- defined_vars,
390
- script_blocks,
391
- sub_issues,
392
- visited_scripts=visited_scripts,
393
- )
394
- for sev, ssrc, slno, msg in sub_issues:
395
- issues.append((sev, ssrc, slno, f"[script '{node.name}'] {msg}"))
396
-
397
-
398
- # ---------------------------------------------------------------------------
399
- # Public API
400
- # ---------------------------------------------------------------------------
401
-
402
-
403
- def lint_ast(
404
- script: Script,
405
- script_path: str | None = None,
406
- ) -> list[_Issue]:
407
- """Perform static analysis on an AST-parsed script.
408
-
409
- Args:
410
- script: The parsed :class:`Script` tree.
411
- script_path: Path to the source file (for resolving relative
412
- INCLUDE paths). ``None`` for inline scripts.
413
-
414
- Returns:
415
- List of ``(severity, source, line_no, message)`` issue tuples.
416
- """
417
- issues: list[_Issue] = []
418
-
419
- if not script.body:
420
- issues.append(_warning("<script>", 0, "Script is empty — no commands found"))
421
- return issues
422
-
423
- script_dir = Path(script_path).resolve().parent if script_path else None
424
- script_blocks = _collect_script_blocks(script)
425
-
426
- # Pass 1: collect all variable definitions
427
- all_defined: set[str] = set()
428
- _collect_defined_vars_from_nodes(script.body, script_blocks, script_dir, all_defined)
429
-
430
- # Pass 2: lint for variable and include issues
431
- _lint_nodes(
432
- script.body,
433
- script_dir,
434
- all_defined,
435
- script_blocks,
436
- issues,
437
- )
438
-
439
- return issues