execsql2 2.8.0__py3-none-any.whl → 2.9.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 (24) hide show
  1. execsql/cli/__init__.py +25 -0
  2. execsql/cli/lint.py +459 -0
  3. execsql/cli/run.py +101 -9
  4. {execsql2-2.8.0.dist-info → execsql2-2.9.0.dist-info}/METADATA +2 -1
  5. {execsql2-2.8.0.dist-info → execsql2-2.9.0.dist-info}/RECORD +24 -23
  6. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/README.md +0 -0
  7. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  8. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  9. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/execsql.conf +0 -0
  10. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  11. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  12. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  13. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  14. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  15. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  16. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  17. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/script_template.sql +0 -0
  18. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  19. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  20. {execsql2-2.8.0.data → execsql2-2.9.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  21. {execsql2-2.8.0.dist-info → execsql2-2.9.0.dist-info}/WHEEL +0 -0
  22. {execsql2-2.8.0.dist-info → execsql2-2.9.0.dist-info}/entry_points.txt +0 -0
  23. {execsql2-2.8.0.dist-info → execsql2-2.9.0.dist-info}/licenses/LICENSE.txt +0 -0
  24. {execsql2-2.8.0.dist-info → execsql2-2.9.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/__init__.py CHANGED
@@ -223,6 +223,25 @@ def main(
223
223
  "--dry-run",
224
224
  help=("Parse the script and print the command list without connecting to a database or executing anything."),
225
225
  ),
226
+ lint: bool = typer.Option(
227
+ False,
228
+ "--lint",
229
+ help=(
230
+ "Parse the script and perform static analysis without connecting to a database or executing anything. "
231
+ "Reports unmatched IF/ENDIF/LOOP/BATCH blocks (errors), potentially undefined variables, "
232
+ "and missing INCLUDE files (warnings). Exits 0 if no errors, 1 if errors found."
233
+ ),
234
+ ),
235
+ ping: bool = typer.Option(
236
+ False,
237
+ "--ping",
238
+ help=(
239
+ "Test database connectivity and exit. "
240
+ "Prints connection details and the server version on success (exit 0), "
241
+ "or the error message on failure (exit 1). "
242
+ "No script file is required."
243
+ ),
244
+ ),
226
245
  dsn: str | None = typer.Option(
227
246
  None,
228
247
  "--dsn",
@@ -350,6 +369,10 @@ def main(
350
369
  positional = args or []
351
370
  if command is not None:
352
371
  script_name = None # inline mode — no script file
372
+ elif ping:
373
+ # --ping does not require a script file; positional args are still
374
+ # available for server/db arguments if --dsn is not used.
375
+ script_name = None
353
376
  else:
354
377
  if not positional:
355
378
  _err_console.print(
@@ -421,6 +444,8 @@ def main(
421
444
  output_dir=output_dir,
422
445
  progress=progress,
423
446
  profile=profile,
447
+ ping=ping,
448
+ lint=lint,
424
449
  )
425
450
 
426
451
 
execsql/cli/lint.py ADDED
@@ -0,0 +1,459 @@
1
+ """Static analysis (lint) for execsql scripts.
2
+
3
+ :func:`_lint_script` inspects a parsed :class:`~execsql.script.CommandList`
4
+ for common structural problems without connecting to a database or executing
5
+ any commands.
6
+
7
+ Checks performed
8
+ ----------------
9
+ 1. **Unmatched IF / ENDIF** — mismatched nesting depth (error).
10
+ 2. **Unmatched LOOP / END LOOP** — mismatched nesting depth (error).
11
+ 3. **Unmatched BEGIN BATCH / END BATCH** — mismatched nesting depth (error).
12
+ 4. **Potentially undefined variables** — ``!!$VAR!!`` tokens not preceded by a
13
+ ``SUB`` metacommand in the same parsed command list and not in the set of
14
+ built-in variables (warning).
15
+ 5. **Missing INCLUDE files** — INCLUDE target does not exist on disk relative
16
+ to the script directory (warning).
17
+ 6. **Empty script** — no commands found (warning).
18
+
19
+ The function walks ``CommandList.cmdlist`` and also descends into any
20
+ ``CommandList`` objects stored in ``_state.savedscripts`` (i.e. named scripts
21
+ defined with ``BEGIN SCRIPT … END SCRIPT`` in the same file). SCRIPT blocks
22
+ are analysed in isolation; nesting counters reset for each block.
23
+
24
+ Exit-code contract
25
+ ------------------
26
+ - Returns ``1`` when at least one **error**-severity issue is found.
27
+ - Returns ``0`` when only warnings (or nothing) are found.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from pathlib import Path
34
+ from typing import TYPE_CHECKING
35
+
36
+ if TYPE_CHECKING:
37
+ from execsql.script.engine import CommandList
38
+
39
+ __all__ = ["_lint_script"]
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Compiled patterns for metacommand recognition
44
+ # ---------------------------------------------------------------------------
45
+
46
+ # IF block — "IF(...)" block form (single-command, no ENDIF needed)
47
+ _RX_IF_INLINE = re.compile(
48
+ r"^\s*IF\s*\(\s*.+\s*\)\s*\{.+\}\s*$",
49
+ re.I,
50
+ )
51
+ # IF block form that opens a block requiring ENDIF
52
+ _RX_IF_BLOCK = re.compile(r"^\s*IF\s*\(\s*.+\s*\)\s*$", re.I)
53
+ _RX_ENDIF = re.compile(r"^\s*ENDIF\s*$", re.I)
54
+ _RX_ELSE = re.compile(r"^\s*ELSE\s*$", re.I)
55
+ _RX_ELSEIF = re.compile(r"^\s*ELSEIF\s*\(\s*.+\s*\)\s*$", re.I)
56
+ _RX_ANDIF = re.compile(r"^\s*ANDIF\s*\(\s*.+\s*\)\s*$", re.I)
57
+ _RX_ORIF = re.compile(r"^\s*ORIF\s*\(\s*.+\s*\)\s*$", re.I)
58
+
59
+ # LOOP … END LOOP
60
+ _RX_LOOP = re.compile(r"^\s*LOOP\s+(?:WHILE|UNTIL)\s*\(", re.I)
61
+ _RX_END_LOOP = re.compile(r"^\s*END\s+LOOP\s*$", re.I)
62
+
63
+ # BEGIN BATCH … END BATCH
64
+ _RX_BEGIN_BATCH = re.compile(r"^\s*BEGIN\s+BATCH\s*$", re.I)
65
+ _RX_END_BATCH = re.compile(r"^\s*END\s+BATCH\s*$", re.I)
66
+
67
+ # SUB <varname> <value> — defines a substitution variable
68
+ _RX_SUB = re.compile(r"^\s*SUB\s+(?P<name>[+~]?\w+)\s+", re.I)
69
+
70
+ # INCLUDE <file>
71
+ _RX_INCLUDE = re.compile(
72
+ r"^\s*INCLUDE(?:\s+IF\s+EXISTS?)?\s+(?P<path>\S+.*?)\s*$",
73
+ re.I,
74
+ )
75
+
76
+ # Variable reference — !!name!! where name may start with $, @, &, ~, #, +
77
+ _RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
78
+
79
+ # Built-in system variables that are always defined (populated by _run before
80
+ # any script commands execute). Variable names are stored without the leading
81
+ # ``$`` for case-insensitive set membership tests.
82
+ _BUILTIN_VARS: frozenset[str] = frozenset(
83
+ {
84
+ # Start-time / environment
85
+ "SCRIPT_START_TIME",
86
+ "SCRIPT_START_TIME_UTC",
87
+ "DATE_TAG",
88
+ "DATETIME_TAG",
89
+ "DATETIME_UTC_TAG",
90
+ "LAST_ROWCOUNT",
91
+ "LAST_SQL",
92
+ "LAST_ERROR",
93
+ "ERROR_MESSAGE",
94
+ "USER",
95
+ "STARTING_PATH",
96
+ "PATHSEP",
97
+ "OS",
98
+ "PYTHON_EXECUTABLE",
99
+ "STARTING_SCRIPT",
100
+ "STARTING_SCRIPT_NAME",
101
+ "STARTING_SCRIPT_REVTIME",
102
+ "RUN_ID",
103
+ # Execution-time (set during runscripts — not available in --dry-run
104
+ # but always defined before any script command can reference them)
105
+ "CURRENT_TIME",
106
+ "CURRENT_TIME_UTC",
107
+ "CURRENT_SCRIPT",
108
+ "CURRENT_SCRIPT_PATH",
109
+ "CURRENT_SCRIPT_NAME",
110
+ "CURRENT_SCRIPT_LINE",
111
+ "SCRIPT_LINE",
112
+ "CURRENT_DIR",
113
+ "CURRENT_PATH",
114
+ "CURRENT_ALIAS",
115
+ "AUTOCOMMIT_STATE",
116
+ "TIMER",
117
+ "DB_USER",
118
+ "DB_SERVER",
119
+ "DB_NAME",
120
+ "DB_NEED_PWD",
121
+ "RANDOM",
122
+ "UUID",
123
+ "VERSION1",
124
+ "VERSION2",
125
+ "VERSION3",
126
+ "CANCEL_HALT_STATE",
127
+ "ERROR_HALT_STATE",
128
+ "METACOMMAND_ERROR_HALT_STATE",
129
+ "CONSOLE_WAIT_WHEN_ERROR_HALT_STATE",
130
+ "CONSOLE_WAIT_WHEN_DONE_STATE",
131
+ "CURRENT_DBMS",
132
+ "CURRENT_DATABASE",
133
+ "SYSTEM_CMD_EXIT_STATUS",
134
+ # Connection-populated
135
+ "DB_FILE",
136
+ "DB_PORT",
137
+ # Counter variables (@@name) are always valid — skip validation
138
+ },
139
+ )
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Issue tuple helpers
144
+ # ---------------------------------------------------------------------------
145
+
146
+ _Issue = tuple[str, str, int, str] # (severity, source, line_no, message)
147
+
148
+
149
+ def _error(source: str, line_no: int, message: str) -> _Issue:
150
+ return ("error", source, line_no, message)
151
+
152
+
153
+ def _warning(source: str, line_no: int, message: str) -> _Issue:
154
+ return ("warning", source, line_no, message)
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Core lint implementation
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ def _lint_cmdlist(
163
+ cmdlist: CommandList,
164
+ script_dir: Path | None,
165
+ defined_vars: set[str],
166
+ ) -> list[_Issue]:
167
+ """Lint a single :class:`CommandList` and return any issues found.
168
+
169
+ Args:
170
+ cmdlist: The parsed command list to analyse.
171
+ script_dir: Directory of the top-level script file, used for resolving
172
+ relative INCLUDE paths. ``None`` for inline (``-c``) scripts.
173
+ defined_vars: Mutable set of variable names (without sigil) that have
174
+ been defined by preceding ``SUB`` metacommands. The caller passes
175
+ in the set from the outer scope so that variables defined before an
176
+ EXECUTE SCRIPT call are visible inside the script block when
177
+ analysing top-level scripts. For named-script analysis the caller
178
+ passes a *copy* so that local definitions don't leak.
179
+
180
+ Returns:
181
+ List of ``(severity, source, line_no, message)`` issue tuples.
182
+ """
183
+ issues: list[_Issue] = []
184
+
185
+ if_depth = 0
186
+ if_open_locs: list[tuple[str, int]] = [] # (source, line_no) of unmatched IF
187
+
188
+ loop_depth = 0
189
+ loop_open_locs: list[tuple[str, int]] = []
190
+
191
+ batch_depth = 0
192
+ batch_open_locs: list[tuple[str, int]] = []
193
+
194
+ for cmd in cmdlist.cmdlist:
195
+ src = cmd.source
196
+ lno = cmd.line_no
197
+ stmt = cmd.command.statement if cmd.command_type == "sql" else cmd.command.statement
198
+
199
+ if cmd.command_type == "sql":
200
+ # SQL statements: check for variable references only
201
+ for m in _RX_VAR_REF.finditer(stmt):
202
+ _check_var_ref(m.group(1), src, lno, defined_vars, issues)
203
+ continue
204
+
205
+ # Metacommand checks
206
+ for m in _RX_VAR_REF.finditer(stmt):
207
+ _check_var_ref(m.group(1), src, lno, defined_vars, issues)
208
+
209
+ # -- IF block (opens a block requiring ENDIF) --
210
+ if _RX_IF_BLOCK.match(stmt) and not _RX_IF_INLINE.match(stmt):
211
+ if_depth += 1
212
+ if_open_locs.append((src, lno))
213
+
214
+ elif _RX_ENDIF.match(stmt):
215
+ if if_depth == 0:
216
+ issues.append(_error(src, lno, "ENDIF without a matching preceding IF"))
217
+ else:
218
+ if_depth -= 1
219
+ if_open_locs.pop()
220
+
221
+ elif _RX_ELSEIF.match(stmt) or _RX_ELSE.match(stmt) or _RX_ANDIF.match(stmt) or _RX_ORIF.match(stmt):
222
+ if if_depth == 0:
223
+ kw = stmt.strip().split(None, 1)[0].upper()
224
+ issues.append(_error(src, lno, f"{kw} without a matching preceding IF"))
225
+
226
+ # -- LOOP --
227
+ elif _RX_LOOP.match(stmt):
228
+ loop_depth += 1
229
+ loop_open_locs.append((src, lno))
230
+
231
+ elif _RX_END_LOOP.match(stmt):
232
+ if loop_depth == 0:
233
+ issues.append(_error(src, lno, "END LOOP without a matching preceding LOOP"))
234
+ else:
235
+ loop_depth -= 1
236
+ loop_open_locs.pop()
237
+
238
+ # -- BATCH --
239
+ elif _RX_BEGIN_BATCH.match(stmt):
240
+ batch_depth += 1
241
+ batch_open_locs.append((src, lno))
242
+
243
+ elif _RX_END_BATCH.match(stmt):
244
+ if batch_depth == 0:
245
+ issues.append(_error(src, lno, "END BATCH without a matching preceding BEGIN BATCH"))
246
+ else:
247
+ batch_depth -= 1
248
+ batch_open_locs.pop()
249
+
250
+ # -- SUB variable definition --
251
+ sub_m = _RX_SUB.match(stmt)
252
+ if sub_m:
253
+ varname = sub_m.group("name").lstrip("+~")
254
+ defined_vars.add(varname.upper())
255
+
256
+ # -- INCLUDE file existence --
257
+ inc_m = _RX_INCLUDE.match(stmt)
258
+ if inc_m:
259
+ raw_path = inc_m.group("path").strip().strip("\"'")
260
+ # Only check if no substitution variables are in the path
261
+ if not _RX_VAR_REF.search(raw_path):
262
+ _check_include_path(raw_path, script_dir, src, lno, stmt, issues)
263
+
264
+ # Report unclosed blocks at end of command list
265
+ for osrc, olno in if_open_locs:
266
+ issues.append(_error(osrc, olno, "IF without a matching ENDIF"))
267
+ for osrc, olno in loop_open_locs:
268
+ issues.append(_error(osrc, olno, "LOOP without a matching END LOOP"))
269
+ for osrc, olno in batch_open_locs:
270
+ issues.append(_error(osrc, olno, "BEGIN BATCH without a matching END BATCH"))
271
+
272
+ return issues
273
+
274
+
275
+ def _check_var_ref(
276
+ raw_name: str,
277
+ source: str,
278
+ line_no: int,
279
+ defined_vars: set[str],
280
+ issues: list[_Issue],
281
+ ) -> None:
282
+ """Emit a warning if *raw_name* looks like an undefined user variable.
283
+
284
+ Built-in system variables, environment-variable references (``&``-prefix),
285
+ column variables (``@``-prefix), counter variables (``@@``), parameter
286
+ variables (``#``-prefix), and ``$ARG_N`` are excluded from the check.
287
+
288
+ Args:
289
+ raw_name: Variable name token as captured from ``!!name!!`` (with sigil).
290
+ source: Source file name for the issue location.
291
+ line_no: Line number of the command containing the reference.
292
+ defined_vars: Set of variable names (upper-case, no sigil) that have
293
+ been defined by preceding SUB metacommands.
294
+ issues: Issue list to append to.
295
+ """
296
+ if not raw_name:
297
+ return
298
+
299
+ sigil = raw_name[0] if raw_name[0] in ("$", "@", "&", "~", "#", "+") else ""
300
+ name = raw_name[len(sigil) :]
301
+
302
+ # Skip non-$ sigil prefixes — these are always resolved at runtime
303
+ if sigil in ("@", "&", "~", "#", "+"):
304
+ return
305
+
306
+ # $ARG_N is set via -a/--assign-arg at invocation time
307
+ if re.match(r"^ARG_\d+$", name, re.I):
308
+ return
309
+
310
+ # Built-in system variables
311
+ if name.upper() in _BUILTIN_VARS:
312
+ return
313
+
314
+ # User-defined via SUB
315
+ if name.upper() in defined_vars:
316
+ return
317
+
318
+ issues.append(
319
+ _warning(
320
+ source,
321
+ line_no,
322
+ f"Potentially undefined variable: !!{raw_name}!! "
323
+ "(not defined by a preceding SUB; may be set by a config file or -a arg)",
324
+ ),
325
+ )
326
+
327
+
328
+ def _check_include_path(
329
+ raw_path: str,
330
+ script_dir: Path | None,
331
+ source: str,
332
+ line_no: int,
333
+ stmt: str,
334
+ issues: list[_Issue],
335
+ ) -> None:
336
+ """Warn if the INCLUDE target does not exist on disk.
337
+
338
+ Args:
339
+ raw_path: Unquoted file path string from the INCLUDE metacommand.
340
+ script_dir: Directory of the top-level script file; used for relative
341
+ path resolution. ``None`` for inline scripts.
342
+ source: Source file name for issue location.
343
+ line_no: Line number of the INCLUDE command.
344
+ stmt: Full metacommand statement text (for the IF EXISTS variant).
345
+ issues: Issue list to append to.
346
+ """
347
+ # IF EXISTS variant — missing file is intentional; skip
348
+ if re.match(r"^\s*INCLUDE\s+IF\s+EXISTS?", stmt, re.I):
349
+ return
350
+
351
+ p = Path(raw_path)
352
+ if not p.is_absolute() and script_dir is not None:
353
+ p = script_dir / p
354
+
355
+ if not p.exists():
356
+ issues.append(
357
+ _warning(
358
+ source,
359
+ line_no,
360
+ f"INCLUDE target does not exist: {raw_path!r}",
361
+ ),
362
+ )
363
+
364
+
365
+ def _lint_script(
366
+ cmdlist: CommandList | None,
367
+ script_path: str | None = None,
368
+ ) -> list[_Issue]:
369
+ """Perform static analysis on a parsed command list.
370
+
371
+ Walks every :class:`~execsql.script.ScriptCmd` in *cmdlist* and any named
372
+ scripts accumulated in ``_state.savedscripts`` (those defined with
373
+ ``BEGIN SCRIPT … END SCRIPT`` in the same source file).
374
+
375
+ Args:
376
+ cmdlist: The top-level :class:`~execsql.script.CommandList` returned by
377
+ ``read_sqlfile()`` / ``read_sqlstring()``. If ``None`` or empty,
378
+ a single "empty script" warning is returned.
379
+ script_path: Absolute or relative path to the SQL script file. Used
380
+ to resolve relative INCLUDE paths. Pass ``None`` for inline
381
+ (``-c``) scripts.
382
+
383
+ Returns:
384
+ List of ``(severity, source, line_no, message)`` tuples, one per issue
385
+ found. An empty list means the script is clean.
386
+ """
387
+ import execsql.state as _state
388
+
389
+ issues: list[_Issue] = []
390
+
391
+ if cmdlist is None or not cmdlist.cmdlist:
392
+ issues.append(_warning("<script>", 0, "Script is empty — no commands found"))
393
+ return issues
394
+
395
+ script_dir = Path(script_path).resolve().parent if script_path else None
396
+
397
+ # Shared set of variables defined in the top-level script via SUB.
398
+ # Named scripts get a fresh copy so their internal definitions don't bleed
399
+ # back into the top-level analysis.
400
+ top_defined: set[str] = set()
401
+
402
+ issues.extend(_lint_cmdlist(cmdlist, script_dir, top_defined))
403
+
404
+ # Analyse each named SCRIPT block collected during parsing
405
+ for script_name, saved_cl in getattr(_state, "savedscripts", {}).items():
406
+ saved_issues = _lint_cmdlist(saved_cl, script_dir, set(top_defined))
407
+ for sev, src, lno, msg in saved_issues:
408
+ # Annotate with the script name if the source is the same file
409
+ issues.append((sev, src, lno, f"[script '{script_name}'] {msg}"))
410
+
411
+ return issues
412
+
413
+
414
+ # ---------------------------------------------------------------------------
415
+ # Rich output helper
416
+ # ---------------------------------------------------------------------------
417
+
418
+
419
+ def _print_lint_results(issues: list[_Issue], script_label: str) -> int:
420
+ """Print lint issues to the console using Rich formatting.
421
+
422
+ Args:
423
+ issues: List of ``(severity, source, line_no, message)`` tuples.
424
+ script_label: Human-readable label for the script (file path or
425
+ ``<inline>``), shown in the summary line.
426
+
427
+ Returns:
428
+ ``1`` if any errors were found, ``0`` if only warnings or nothing.
429
+ """
430
+ from execsql.cli.help import _console
431
+
432
+ n_errors = sum(1 for sev, *_ in issues if sev == "error")
433
+ n_warnings = sum(1 for sev, *_ in issues if sev == "warning")
434
+
435
+ _console.print(f"\n[bold cyan]Lint:[/bold cyan] {script_label}")
436
+ _console.print()
437
+
438
+ if not issues:
439
+ _console.print("[bold green]No issues found.[/bold green]")
440
+ _console.print()
441
+ return 0
442
+
443
+ for severity, source, line_no, message in issues:
444
+ loc = f"{source}:{line_no}" if line_no else source
445
+ if severity == "error":
446
+ _console.print(f" [bold red]ERROR [/bold red] [dim]{loc}[/dim] {message}")
447
+ else:
448
+ _console.print(f" [bold yellow]WARNING[/bold yellow] [dim]{loc}[/dim] {message}")
449
+
450
+ _console.print()
451
+ parts = []
452
+ if n_errors:
453
+ parts.append(f"[bold red]{n_errors} error{'s' if n_errors != 1 else ''}[/bold red]")
454
+ if n_warnings:
455
+ parts.append(f"[bold yellow]{n_warnings} warning{'s' if n_warnings != 1 else ''}[/bold yellow]")
456
+ _console.print(" " + ", ".join(parts))
457
+ _console.print()
458
+
459
+ return 1 if n_errors > 0 else 0
execsql/cli/run.py CHANGED
@@ -24,7 +24,11 @@ from execsql.script import SubVarSet, current_script_line, read_sqlfile, read_sq
24
24
  from execsql.utils.fileio import FileWriter, Logger, filewriter_end
25
25
  from execsql.utils.gui import gui_connect, gui_console_isrunning, gui_console_off, gui_console_on, gui_console_wait_user
26
26
 
27
- __all__ = ["_connect_initial_db", "_print_dry_run", "_print_profile", "_run"]
27
+ __all__ = ["_connect_initial_db", "_ping_db", "_print_dry_run", "_print_profile", "_run"]
28
+
29
+ # Lint helper — imported lazily inside _run() to keep start-up cost low, but
30
+ # re-exported here so that tests and callers can reach it via cli.run.
31
+ from execsql.cli.lint import _lint_script, _print_lint_results # noqa: F401 — re-export
28
32
 
29
33
 
30
34
  # ---------------------------------------------------------------------------
@@ -119,6 +123,64 @@ def _print_profile(profile_data: list[tuple]) -> None:
119
123
  _console.print()
120
124
 
121
125
 
126
+ # ---------------------------------------------------------------------------
127
+ # --ping helper
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ def _ping_db(db) -> None:
132
+ """Test connectivity for *db*, print connection details, and exit.
133
+
134
+ Attempts to execute ``SELECT version()`` (or ``SELECT sqlite_version()``
135
+ for SQLite) to retrieve the server version string. If the query fails the
136
+ connection is still reported as successful — only the version line is
137
+ omitted. On success the function raises :class:`SystemExit` with code 0.
138
+
139
+ Args:
140
+ db: An open :class:`~execsql.db.base.Database` instance.
141
+ """
142
+ dbms_id: str = db.type.dbms_id if db.type else "unknown"
143
+
144
+ # Try to fetch a human-readable server version string.
145
+ version_str: str | None = None
146
+ _version_queries = [
147
+ "SELECT version()",
148
+ "SELECT sqlite_version()",
149
+ "SELECT @@VERSION",
150
+ ]
151
+ for sql in _version_queries:
152
+ try:
153
+ curs = db.cursor()
154
+ curs.execute(sql)
155
+ row = curs.fetchone()
156
+ curs.close()
157
+ if row and row[0]:
158
+ version_str = str(row[0]).split("\n")[0].strip()
159
+ break
160
+ except Exception:
161
+ continue
162
+
163
+ # Build the connection descriptor.
164
+ if db.server_name:
165
+ port_part = f":{db.port}" if db.port else ""
166
+ location = f"{db.server_name}{port_part}/{db.db_name or ''}"
167
+ else:
168
+ location = db.db_name or "<in-memory>"
169
+
170
+ if version_str:
171
+ _console.print(
172
+ f"[bold green]Connected[/bold green] to [bold]{dbms_id}[/bold] "
173
+ f"[dim]{version_str}[/dim] at [cyan]{location}[/cyan]",
174
+ )
175
+ else:
176
+ _console.print(
177
+ f"[bold green]Connected[/bold green] to [bold]{dbms_id}[/bold] at [cyan]{location}[/cyan]",
178
+ )
179
+
180
+ db.close()
181
+ raise SystemExit(0)
182
+
183
+
122
184
  # ---------------------------------------------------------------------------
123
185
  # Core execution (split from argument parsing for testability)
124
186
  # ---------------------------------------------------------------------------
@@ -150,6 +212,8 @@ def _run(
150
212
  output_dir: str | None = None,
151
213
  progress: bool = False,
152
214
  profile: bool = False,
215
+ ping: bool = False,
216
+ lint: bool = False,
153
217
  ) -> None:
154
218
  """Initialise state, connect to the database, load the script, and run it.
155
219
 
@@ -157,6 +221,17 @@ def _run(
157
221
  without going through the Typer CLI layer. All parameters mirror the
158
222
  corresponding CLI options; see [Syntax & Options](../syntax.md) for
159
223
  descriptions.
224
+
225
+ When *ping* is ``True``, the function connects to the database, prints
226
+ connection details (DBMS name, server version, and location), and calls
227
+ :func:`_ping_db` which raises ``SystemExit(0)``. No script is loaded or
228
+ executed. *script_name* and *command* may both be ``None`` in ping mode.
229
+
230
+ When *lint* is ``True``, the script is parsed and statically analysed for
231
+ structural issues (unmatched IF/ENDIF/LOOP/BATCH blocks, potentially
232
+ undefined variables, missing INCLUDE files, empty scripts) without
233
+ connecting to a database or executing anything. Exits with code 0 if no
234
+ errors were found, or code 1 if errors were found.
160
235
  """
161
236
  import execsql.state as _state
162
237
 
@@ -286,10 +361,10 @@ def _run(
286
361
  if progress:
287
362
  conf.show_progress = True
288
363
 
289
- # Positional arguments after the script name (or all positionals in inline mode)
364
+ # Positional arguments after the script name (or all positionals in inline/ping mode)
290
365
  # off=1: script file occupies positional[0]; connection args start at [1]
291
- # off=0: no script file; all positionals are connection args
292
- off = 0 if command is not None else 1
366
+ # off=0: no script file; all positionals are connection args (inline -c or --ping)
367
+ off = 0 if (command is not None or ping) else 1
293
368
  if len(positional) == off + 1:
294
369
  if conf.db_type in ("a", "l", "k"):
295
370
  conf.db_file = positional[off]
@@ -403,12 +478,13 @@ def _run(
403
478
  )
404
479
 
405
480
  # ------------------------------------------------------------------
406
- # Load the SQL script
481
+ # Load the SQL script (skipped in --ping and --dry-run with no script)
407
482
  # ------------------------------------------------------------------
408
- if command is not None:
409
- read_sqlstring(command.replace("\\n", "\n").replace("\\t", "\t"), "<inline>")
410
- else:
411
- read_sqlfile(script_name)
483
+ if not ping:
484
+ if command is not None:
485
+ read_sqlstring(command.replace("\\n", "\n").replace("\\t", "\t"), "<inline>")
486
+ else:
487
+ read_sqlfile(script_name)
412
488
 
413
489
  # ------------------------------------------------------------------
414
490
  # Dry-run: print command list and exit without connecting to DB
@@ -417,6 +493,16 @@ def _run(
417
493
  _print_dry_run(_state.commandliststack[-1] if _state.commandliststack else None)
418
494
  raise SystemExit(0)
419
495
 
496
+ # ------------------------------------------------------------------
497
+ # Lint: static analysis without connecting to DB
498
+ # ------------------------------------------------------------------
499
+ if lint:
500
+ cmdlist = _state.commandliststack[-1] if _state.commandliststack else None
501
+ issues = _lint_script(cmdlist, script_name)
502
+ label = script_name or "<inline>"
503
+ exit_code = _print_lint_results(issues, label)
504
+ raise SystemExit(exit_code)
505
+
420
506
  # ------------------------------------------------------------------
421
507
  # Start GUI console if requested
422
508
  # ------------------------------------------------------------------
@@ -446,6 +532,12 @@ def _run(
446
532
  _state.subvars.add_substitution("$DB_SERVER", db.server_name)
447
533
  _state.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", "0")
448
534
 
535
+ # ------------------------------------------------------------------
536
+ # --ping: report connection details and exit (no script executed)
537
+ # ------------------------------------------------------------------
538
+ if ping:
539
+ _ping_db(db) # raises SystemExit(0) on success
540
+
449
541
  # ------------------------------------------------------------------
450
542
  # Execute the script
451
543
  # ------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: execsql2
3
- Version: 2.8.0
3
+ Version: 2.9.0
4
4
  Summary: Runs a SQL script against a PostgreSQL, SQLite, MariaDB/MySQL, DuckDB, Firebird, MS-Access, MS-SQL-Server, or Oracle database, or an ODBC DSN. Provides metacommands to import and export data, copy data between databases, conditionally execute SQL and metacommands, and dynamically alter SQL and metacommands with substitution variables.
5
5
  Project-URL: Repository, https://github.com/geocoug/execsql
6
6
  Project-URL: Issues, https://github.com/geocoug/execsql/issues
@@ -221,6 +221,7 @@ execsql script.sql # read connection from config file
221
221
  | `-w` | Skip password prompt when a username is supplied |
222
222
  | `--dsn URL` | Connection string (e.g. `postgresql://user:pass@host/db`) |
223
223
  | `--dry-run` | Parse the script and report commands without executing |
224
+ | `--lint` | Static analysis: check structure and warn on issues (no DB) |
224
225
  | `--progress` | Show a progress bar for long-running IMPORT operations |
225
226
  | `--dump-keywords` | Print metacommand keywords as JSON and exit |
226
227
  | `--gui-framework {tkinter,textual}` | GUI framework for interactive prompts |
@@ -9,10 +9,11 @@ execsql/parser.py,sha256=mbNSMiAMR1NvNvFtQAZq6nxBOupMGJZXSimLWLtZeNs,15537
9
9
  execsql/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  execsql/state.py,sha256=BodGWiLD7I3s7LFd8Mb6SHMp3I1BhVE4rYcR0UZWAoM,14799
11
11
  execsql/types.py,sha256=HVWb4umIB9lpxCGgqk3xy1hoGYPfN39xci5mHF0Izq4,31882
12
- execsql/cli/__init__.py,sha256=KewdhCBL8iRa_iPZZ7RKeRl90pGCXg3_lsMaVZph-kY,15118
12
+ execsql/cli/__init__.py,sha256=Y4lFKvKWyjtMgSLsmACHYG33DFpeAQxddIVrlKURwpg,16081
13
13
  execsql/cli/dsn.py,sha256=svaZtrUXFRL2W5G6FRRiKtR6kehOp7urrVhIx_642Z8,2820
14
14
  execsql/cli/help.py,sha256=Sn_TgSJiQeBx-xZH0fuP5OvR_wasSTumjWF9UHfIX5k,5414
15
- execsql/cli/run.py,sha256=pkUIcWtzDSYfoIlM418cZdLix7NI45HKRIBxgZfFtHg,26405
15
+ execsql/cli/lint.py,sha256=KluYROdjGJNUrVbO3cJym-H296zbih4no-F10HF0P4U,16165
16
+ execsql/cli/run.py,sha256=i0ip8tm21Sm4EFbfBdcekETmiABzrRfeP84SqA4IF68,30158
16
17
  execsql/db/__init__.py,sha256=jTbuafuKOqYtXFR1wvCOoKK5Lr3l1uErfaIbIr6UywI,1063
17
18
  execsql/db/access.py,sha256=L79gUnAnnM9EJ_f4k42jr7DI0qGcKtLOnJTlBC7uPm0,17879
18
19
  execsql/db/base.py,sha256=hfMFj8fXY0T1aXLvWJHqb0aU4EQUDFOc-YrS29HH8U4,30405
@@ -89,24 +90,24 @@ execsql/utils/numeric.py,sha256=xh02ANSRk3nUpQ-rtm66ILoMqoi7HtzCoRMIOT9U8QI,1570
89
90
  execsql/utils/regex.py,sha256=diEzTZqU_HHwVMadPAvN1Vgzhl7I03eVaEFGCXyGGL8,3770
90
91
  execsql/utils/strings.py,sha256=5Dvzrk-9SIw2lpxXZQkiJbNyo1sy7iXXAtSULlZ0KG8,8488
91
92
  execsql/utils/timer.py,sha256=eDYf5VzCNFk7oo90InJucUm3XcBdhYMogjZMqeg9xzc,1899
92
- execsql2-2.8.0.data/data/execsql2_extras/README.md,sha256=sxwVyU0ZahCfANv56LahkyuM505kFjrMhe-1SvWE69E,4845
93
- execsql2-2.8.0.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
94
- execsql2-2.8.0.data/data/execsql2_extras/example_config_prompt.sql,sha256=SY3Jxn1qcVm4kPW9xmmTfknHfvURXmeEYTbRjYrjGSw,7487
95
- execsql2-2.8.0.data/data/execsql2_extras/execsql.conf,sha256=_45iJ-KWZnB8uMW_gEg067MM5pmGJ-dVl7VbAZMunAE,9530
96
- execsql2-2.8.0.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
97
- execsql2-2.8.0.data/data/execsql2_extras/md_compare.sql,sha256=B8Wd7LZ0vnMY2qvA139JIEBkPObgRH2i5xj6PejTQt8,24092
98
- execsql2-2.8.0.data/data/execsql2_extras/md_glossary.sql,sha256=DJRHcU5NbFpxTTX-IwH3yRlsboj1q6BBGrUAHKn4Cuo,10796
99
- execsql2-2.8.0.data/data/execsql2_extras/md_upsert.sql,sha256=v_7GbWh_N1mBTmw3gvTrkagOVp2q0KmXvM8hE-DlFxY,112524
100
- execsql2-2.8.0.data/data/execsql2_extras/pg_compare.sql,sha256=9dWa8hnfy5dVJI-z2iGpd9JzQmI4j2ziMlEdpnr66ro,24352
101
- execsql2-2.8.0.data/data/execsql2_extras/pg_glossary.sql,sha256=pKjIIDsROAgJq2H-1qNEcRMAWManivcZ_AEVHzUUlic,9908
102
- execsql2-2.8.0.data/data/execsql2_extras/pg_upsert.sql,sha256=k7AFiGTLBy3nf-qO5QIaZrEYTAKvdxxU3JDLx9jqkzs,108315
103
- execsql2-2.8.0.data/data/execsql2_extras/script_template.sql,sha256=1Estacb_vm1FgK41k_G9nuduP1yiA-fQ1Kn4Z4mv5Ao,11153
104
- execsql2-2.8.0.data/data/execsql2_extras/ss_compare.sql,sha256=TsVxWm3cEpR5-EiMYXNhtaY0arSNeKZhsJdHdLA7xeI,24833
105
- execsql2-2.8.0.data/data/execsql2_extras/ss_glossary.sql,sha256=cLM7nN8JOIu9ZVP9oY9qdSK3hrnWJiDcX6nZmQQbQWI,13065
106
- execsql2-2.8.0.data/data/execsql2_extras/ss_upsert.sql,sha256=BCqmBykXBF-BpCgOFeG1qhf2XfScKsxPD17wd1hYfHw,120647
107
- execsql2-2.8.0.dist-info/METADATA,sha256=fGxvlbidjAgVeGg51ZWXNPcblzQrR3UWbgfF60yGpuA,16849
108
- execsql2-2.8.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
109
- execsql2-2.8.0.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
110
- execsql2-2.8.0.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
111
- execsql2-2.8.0.dist-info/licenses/NOTICE,sha256=sqVrM73Ys9zfvWC_P797lHfTnoPW_ETeBSrUTFaob0A,339
112
- execsql2-2.8.0.dist-info/RECORD,,
93
+ execsql2-2.9.0.data/data/execsql2_extras/README.md,sha256=sxwVyU0ZahCfANv56LahkyuM505kFjrMhe-1SvWE69E,4845
94
+ execsql2-2.9.0.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
95
+ execsql2-2.9.0.data/data/execsql2_extras/example_config_prompt.sql,sha256=SY3Jxn1qcVm4kPW9xmmTfknHfvURXmeEYTbRjYrjGSw,7487
96
+ execsql2-2.9.0.data/data/execsql2_extras/execsql.conf,sha256=_45iJ-KWZnB8uMW_gEg067MM5pmGJ-dVl7VbAZMunAE,9530
97
+ execsql2-2.9.0.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
98
+ execsql2-2.9.0.data/data/execsql2_extras/md_compare.sql,sha256=B8Wd7LZ0vnMY2qvA139JIEBkPObgRH2i5xj6PejTQt8,24092
99
+ execsql2-2.9.0.data/data/execsql2_extras/md_glossary.sql,sha256=DJRHcU5NbFpxTTX-IwH3yRlsboj1q6BBGrUAHKn4Cuo,10796
100
+ execsql2-2.9.0.data/data/execsql2_extras/md_upsert.sql,sha256=v_7GbWh_N1mBTmw3gvTrkagOVp2q0KmXvM8hE-DlFxY,112524
101
+ execsql2-2.9.0.data/data/execsql2_extras/pg_compare.sql,sha256=9dWa8hnfy5dVJI-z2iGpd9JzQmI4j2ziMlEdpnr66ro,24352
102
+ execsql2-2.9.0.data/data/execsql2_extras/pg_glossary.sql,sha256=pKjIIDsROAgJq2H-1qNEcRMAWManivcZ_AEVHzUUlic,9908
103
+ execsql2-2.9.0.data/data/execsql2_extras/pg_upsert.sql,sha256=k7AFiGTLBy3nf-qO5QIaZrEYTAKvdxxU3JDLx9jqkzs,108315
104
+ execsql2-2.9.0.data/data/execsql2_extras/script_template.sql,sha256=1Estacb_vm1FgK41k_G9nuduP1yiA-fQ1Kn4Z4mv5Ao,11153
105
+ execsql2-2.9.0.data/data/execsql2_extras/ss_compare.sql,sha256=TsVxWm3cEpR5-EiMYXNhtaY0arSNeKZhsJdHdLA7xeI,24833
106
+ execsql2-2.9.0.data/data/execsql2_extras/ss_glossary.sql,sha256=cLM7nN8JOIu9ZVP9oY9qdSK3hrnWJiDcX6nZmQQbQWI,13065
107
+ execsql2-2.9.0.data/data/execsql2_extras/ss_upsert.sql,sha256=BCqmBykXBF-BpCgOFeG1qhf2XfScKsxPD17wd1hYfHw,120647
108
+ execsql2-2.9.0.dist-info/METADATA,sha256=9IJPnmBiSv11eTc7x9Xswfv0KiAwzwizxJX_umIXaSo,16955
109
+ execsql2-2.9.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
110
+ execsql2-2.9.0.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
111
+ execsql2-2.9.0.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
112
+ execsql2-2.9.0.dist-info/licenses/NOTICE,sha256=sqVrM73Ys9zfvWC_P797lHfTnoPW_ETeBSrUTFaob0A,339
113
+ execsql2-2.9.0.dist-info/RECORD,,