execsql2 2.8.0__py3-none-any.whl → 2.10.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 (30) hide show
  1. execsql/cli/__init__.py +25 -0
  2. execsql/cli/lint.py +459 -0
  3. execsql/cli/run.py +101 -9
  4. execsql/metacommands/__init__.py +3 -0
  5. execsql/metacommands/conditions.py +148 -0
  6. execsql/metacommands/debug_repl.py +223 -0
  7. execsql/metacommands/dispatch.py +12 -0
  8. execsql/script/engine.py +5 -0
  9. execsql/state.py +9 -0
  10. {execsql2-2.8.0.dist-info → execsql2-2.10.0.dist-info}/METADATA +2 -1
  11. {execsql2-2.8.0.dist-info → execsql2-2.10.0.dist-info}/RECORD +30 -28
  12. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/README.md +0 -0
  13. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  14. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  15. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/execsql.conf +0 -0
  16. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  17. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  18. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  19. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  20. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  21. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  22. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  23. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/script_template.sql +0 -0
  24. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  25. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  26. {execsql2-2.8.0.data → execsql2-2.10.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  27. {execsql2-2.8.0.dist-info → execsql2-2.10.0.dist-info}/WHEEL +0 -0
  28. {execsql2-2.8.0.dist-info → execsql2-2.10.0.dist-info}/entry_points.txt +0 -0
  29. {execsql2-2.8.0.dist-info → execsql2-2.10.0.dist-info}/licenses/LICENSE.txt +0 -0
  30. {execsql2-2.8.0.dist-info → execsql2-2.10.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
  # ------------------------------------------------------------------
@@ -100,6 +100,7 @@ from execsql.metacommands.debug import (
100
100
  x_debug_write_subvars,
101
101
  x_debug_write_config,
102
102
  )
103
+ from execsql.metacommands.debug_repl import x_breakpoint
103
104
  from execsql.metacommands.io import (
104
105
  x_export,
105
106
  x_export_query,
@@ -300,6 +301,8 @@ __all__ = [
300
301
  "x_debug_log_config",
301
302
  "x_debug_write_subvars",
302
303
  "x_debug_write_config",
304
+ # debug repl handlers
305
+ "x_breakpoint",
303
306
  # io handlers
304
307
  "x_export",
305
308
  "x_export_query",
@@ -71,6 +71,124 @@ def xf_hasrows(**kwargs: Any) -> bool:
71
71
  return nrows > 0
72
72
 
73
73
 
74
+ def _row_count(queryname: str, sql_context: str, metacommandline: str) -> int:
75
+ """Return the number of rows in *queryname*, raising ErrInfo on failure.
76
+
77
+ Args:
78
+ queryname: Table or view name to count rows in.
79
+ sql_context: The SQL string to include in error messages.
80
+ metacommandline: The full metacommand line for error context.
81
+
82
+ Returns:
83
+ Integer row count.
84
+
85
+ Raises:
86
+ ErrInfo: If the query fails or the result is not numeric.
87
+ """
88
+ sql = f"select count(*) from {queryname};"
89
+ try:
90
+ _hdrs, rec = _state.dbs.current().select_data(sql)
91
+ except ErrInfo:
92
+ raise
93
+ except Exception as e:
94
+ raise ErrInfo("db", sql, exception_msg=exception_desc()) from e
95
+ try:
96
+ return int(rec[0][0])
97
+ except (IndexError, TypeError, ValueError) as e:
98
+ raise ErrInfo(
99
+ type="cmd",
100
+ command_text=metacommandline,
101
+ other_msg=f"Could not read row count for {queryname}.",
102
+ ) from e
103
+
104
+
105
+ def _parse_row_count_n(raw: str, metacommandline: str) -> int:
106
+ """Parse and return the numeric threshold N from the matched group.
107
+
108
+ Args:
109
+ raw: The raw string captured by the regex group (``n``).
110
+ metacommandline: The full metacommand line for error context.
111
+
112
+ Returns:
113
+ Integer value of *raw*.
114
+
115
+ Raises:
116
+ ErrInfo: If *raw* cannot be parsed as an integer.
117
+ """
118
+ try:
119
+ return int(raw.strip())
120
+ except (ValueError, TypeError) as e:
121
+ raise ErrInfo(
122
+ type="cmd",
123
+ command_text=metacommandline,
124
+ other_msg=f"ROW_COUNT threshold must be an integer; got {raw!r}.",
125
+ ) from e
126
+
127
+
128
+ def xf_row_count_gt(**kwargs: Any) -> bool:
129
+ """Return True if the row count of *queryname* is strictly greater than N.
130
+
131
+ Args:
132
+ **kwargs: Named groups from the regex match, plus ``metacommandline``.
133
+ Required keys: ``queryname``, ``n``.
134
+
135
+ Returns:
136
+ True if ``count(*) > N``.
137
+ """
138
+ queryname = kwargs["queryname"]
139
+ mcl = kwargs["metacommandline"]
140
+ n = _parse_row_count_n(kwargs["n"], mcl)
141
+ return _row_count(queryname, f"select count(*) from {queryname};", mcl) > n
142
+
143
+
144
+ def xf_row_count_gte(**kwargs: Any) -> bool:
145
+ """Return True if the row count of *queryname* is greater than or equal to N.
146
+
147
+ Args:
148
+ **kwargs: Named groups from the regex match, plus ``metacommandline``.
149
+ Required keys: ``queryname``, ``n``.
150
+
151
+ Returns:
152
+ True if ``count(*) >= N``.
153
+ """
154
+ queryname = kwargs["queryname"]
155
+ mcl = kwargs["metacommandline"]
156
+ n = _parse_row_count_n(kwargs["n"], mcl)
157
+ return _row_count(queryname, f"select count(*) from {queryname};", mcl) >= n
158
+
159
+
160
+ def xf_row_count_eq(**kwargs: Any) -> bool:
161
+ """Return True if the row count of *queryname* equals N exactly.
162
+
163
+ Args:
164
+ **kwargs: Named groups from the regex match, plus ``metacommandline``.
165
+ Required keys: ``queryname``, ``n``.
166
+
167
+ Returns:
168
+ True if ``count(*) == N``.
169
+ """
170
+ queryname = kwargs["queryname"]
171
+ mcl = kwargs["metacommandline"]
172
+ n = _parse_row_count_n(kwargs["n"], mcl)
173
+ return _row_count(queryname, f"select count(*) from {queryname};", mcl) == n
174
+
175
+
176
+ def xf_row_count_lt(**kwargs: Any) -> bool:
177
+ """Return True if the row count of *queryname* is strictly less than N.
178
+
179
+ Args:
180
+ **kwargs: Named groups from the regex match, plus ``metacommandline``.
181
+ Required keys: ``queryname``, ``n``.
182
+
183
+ Returns:
184
+ True if ``count(*) < N``.
185
+ """
186
+ queryname = kwargs["queryname"]
187
+ mcl = kwargs["metacommandline"]
188
+ n = _parse_row_count_n(kwargs["n"], mcl)
189
+ return _row_count(queryname, f"select count(*) from {queryname};", mcl) < n
190
+
191
+
74
192
  def xf_sqlerror(**kwargs: Any) -> bool:
75
193
  return _state.status.sql_error
76
194
 
@@ -495,6 +613,36 @@ def build_conditional_table() -> Any:
495
613
  mcl.add(r"^\s*HASROWS\((?P<queryname>[^)]+)\)", xf_hasrows, description="HASROWS", category="condition")
496
614
  mcl.add(r"^\s*HAS_ROWS\((?P<queryname>[^)]+)\)", xf_hasrows)
497
615
 
616
+ # ROW_COUNT comparisons — ROW_COUNT_GT/GTE/EQ/LT(table, N)
617
+ # Table name: unquoted, double-quoted, or single-quoted. N: integer literal.
618
+ _rc_table = r"(?P<queryname>[A-Za-z0-9_.\"'\[\]]+)"
619
+ _rc_n = r"(?P<n>\d+)"
620
+ _rc_sep = r"\s*,\s*"
621
+ mcl.add(
622
+ rf"^\s*ROW_COUNT_GT\s*\(\s*{_rc_table}{_rc_sep}{_rc_n}\s*\)",
623
+ xf_row_count_gt,
624
+ description="ROW_COUNT_GT",
625
+ category="condition",
626
+ )
627
+ mcl.add(
628
+ rf"^\s*ROW_COUNT_GTE\s*\(\s*{_rc_table}{_rc_sep}{_rc_n}\s*\)",
629
+ xf_row_count_gte,
630
+ description="ROW_COUNT_GTE",
631
+ category="condition",
632
+ )
633
+ mcl.add(
634
+ rf"^\s*ROW_COUNT_EQ\s*\(\s*{_rc_table}{_rc_sep}{_rc_n}\s*\)",
635
+ xf_row_count_eq,
636
+ description="ROW_COUNT_EQ",
637
+ category="condition",
638
+ )
639
+ mcl.add(
640
+ rf"^\s*ROW_COUNT_LT\s*\(\s*{_rc_table}{_rc_sep}{_rc_n}\s*\)",
641
+ xf_row_count_lt,
642
+ description="ROW_COUNT_LT",
643
+ category="condition",
644
+ )
645
+
498
646
  # Status predicates
499
647
  mcl.add(r"^\s*sql_error\(\s*\)", xf_sqlerror, description="SQL_ERROR", category="condition")
500
648
  mcl.add(r"^\s*dialog_canceled\(\s*\)", xf_dialogcanceled, description="DIALOG_CANCELED", category="condition")
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Interactive debug REPL metacommand handler for execsql.
5
+
6
+ Implements ``x_breakpoint`` — the ``BREAKPOINT`` metacommand — which pauses
7
+ script execution and drops into an interactive read-eval-print loop.
8
+
9
+ The REPL allows the user to:
10
+
11
+ - Inspect and print substitution variables.
12
+ - Run ad-hoc SQL queries against the current database.
13
+ - Step through the script one statement at a time.
14
+ - Resume or abort execution.
15
+
16
+ In non-interactive environments (CI, piped input, ``sys.stdin.isatty()`` is
17
+ ``False``) the metacommand is silently skipped so automated pipelines are not
18
+ blocked.
19
+ """
20
+
21
+ import sys
22
+ from typing import Any
23
+
24
+ import execsql.state as _state
25
+
26
+ __all__ = ["x_breakpoint"]
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Public handler
30
+ # ---------------------------------------------------------------------------
31
+
32
+ _HELP_TEXT = """\
33
+ execsql debug REPL commands:
34
+ continue c Resume script execution
35
+ abort q quit Halt the script (exit 1)
36
+ vars List all substitution variables and their values
37
+ $VARNAME Print a single variable's value (also &VAR, @VAR)
38
+ SELECT ...; Run ad-hoc SQL against the current database
39
+ next n Execute the next statement then pause again (step mode)
40
+ stack Show the command-list stack (script name, line, depth)
41
+ help Show this help text
42
+ """
43
+
44
+
45
+ def x_breakpoint(**kwargs: Any) -> None:
46
+ """Pause execution and enter the interactive debug REPL.
47
+
48
+ If ``sys.stdin`` is not a TTY (CI, piped input), the metacommand is
49
+ silently skipped — scripts will not hang in automation.
50
+
51
+ Args:
52
+ **kwargs: Keyword arguments injected by the dispatch table (unused).
53
+ """
54
+ if not sys.stdin.isatty():
55
+ return
56
+ _debug_repl()
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # REPL core
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def _debug_repl() -> None:
65
+ """Interactive read-eval-print loop for script debugging.
66
+
67
+ Reads commands from stdin until the user types ``continue`` or ``abort``,
68
+ or until EOF / KeyboardInterrupt.
69
+ """
70
+ try:
71
+ import readline as _readline # noqa: F401 — side-effect: enables history/arrow keys
72
+ except ImportError:
73
+ pass # readline not available on Windows; continue without it
74
+
75
+ _write("\n[Breakpoint] Script paused. Type 'help' for commands, 'continue' to resume.\n")
76
+
77
+ while True:
78
+ try:
79
+ line = input("execsql debug> ").strip()
80
+ except EOFError:
81
+ _write("\n")
82
+ return # Ctrl-D → continue
83
+ except KeyboardInterrupt:
84
+ _write("\n")
85
+ return # Ctrl-C → continue
86
+
87
+ if not line:
88
+ continue
89
+
90
+ lower = line.lower()
91
+
92
+ if lower in ("continue", "c"):
93
+ return
94
+ elif lower in ("abort", "q", "quit"):
95
+ raise SystemExit(1)
96
+ elif lower == "help":
97
+ _write(_HELP_TEXT)
98
+ elif lower == "vars":
99
+ _print_all_vars()
100
+ elif lower == "stack":
101
+ _print_stack()
102
+ elif lower in ("next", "n"):
103
+ _enable_step_mode()
104
+ return
105
+ elif line[0] in ("$", "&", "@"):
106
+ _print_var(line)
107
+ elif line.rstrip().endswith(";"):
108
+ _run_sql(line)
109
+ else:
110
+ _write(f"Unknown command: {line!r}. Type 'help' for available commands.\n")
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # REPL command implementations
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ def _write(text: str) -> None:
119
+ """Write *text* to the execsql output stream (falls back to stdout)."""
120
+ output = _state.output
121
+ if output is not None:
122
+ output.write(text)
123
+ else:
124
+ sys.stdout.write(text)
125
+ sys.stdout.flush()
126
+
127
+
128
+ def _print_all_vars() -> None:
129
+ """Print all substitution variables and their current values."""
130
+ subvars = _state.subvars
131
+ if subvars is None:
132
+ _write(" (no substitution variables defined)\n")
133
+ return
134
+ items = subvars.substitutions # list of (name, value) tuples
135
+ if not items:
136
+ _write(" (no substitution variables defined)\n")
137
+ return
138
+ # Compute column width for aligned output.
139
+ max_name = max((len(name) for name, _ in items), default=0)
140
+ for name, value in sorted(items):
141
+ _write(f" {name:<{max_name}} = {value!r}\n")
142
+
143
+
144
+ def _print_var(varname: str) -> None:
145
+ """Print the value of a single substitution variable.
146
+
147
+ Args:
148
+ varname: The variable reference as typed by the user, e.g. ``$FOO``.
149
+ """
150
+ subvars = _state.subvars
151
+ if subvars is None:
152
+ _write(f" {varname}: (substitution variables not initialised)\n")
153
+ return
154
+ # varvalue() expects the name with its prefix (e.g. "$foo"); it lowercases internally.
155
+ value = subvars.varvalue(varname)
156
+ if value is None:
157
+ _write(f" {varname}: (undefined)\n")
158
+ else:
159
+ _write(f" {varname} = {value!r}\n")
160
+
161
+
162
+ def _print_stack() -> None:
163
+ """Print the current command-list stack (script name, line number, depth)."""
164
+ stack = _state.commandliststack
165
+ if not stack:
166
+ _write(" (command list stack is empty)\n")
167
+ return
168
+ _write(f" Stack depth: {len(stack)}\n")
169
+ for depth, cmdlist in enumerate(stack):
170
+ listname = getattr(cmdlist, "listname", "<unknown>")
171
+ cmdptr = getattr(cmdlist, "cmdptr", 0)
172
+ _write(f" [{depth}] {listname} (cursor at index {cmdptr})\n")
173
+
174
+
175
+ def _run_sql(sql: str) -> None:
176
+ """Execute ad-hoc SQL against the current database and pretty-print the results.
177
+
178
+ Args:
179
+ sql: A complete SQL statement ending with a semicolon.
180
+ """
181
+ dbs = _state.dbs
182
+ if dbs is None:
183
+ _write(" (no database connection is active)\n")
184
+ return
185
+ db = dbs.current()
186
+ if db is None:
187
+ _write(" (no database connection is active)\n")
188
+ return
189
+ try:
190
+ colnames, rows = db.select_data(sql)
191
+ except Exception as exc:
192
+ _write(f" SQL error: {exc}\n")
193
+ return
194
+
195
+ if not colnames:
196
+ _write(" (query returned no columns)\n")
197
+ return
198
+
199
+ # Build a simple text table.
200
+ col_widths = [len(c) for c in colnames]
201
+ str_rows: list[list[str]] = []
202
+ for row in rows:
203
+ str_row = [str(v) if v is not None else "NULL" for v in row]
204
+ str_rows.append(str_row)
205
+ for i, cell in enumerate(str_row):
206
+ col_widths[i] = max(col_widths[i], len(cell))
207
+
208
+ sep = "+-" + "-+-".join("-" * w for w in col_widths) + "-+"
209
+ header = "| " + " | ".join(c.ljust(col_widths[i]) for i, c in enumerate(colnames)) + " |"
210
+ _write(sep + "\n")
211
+ _write(header + "\n")
212
+ _write(sep + "\n")
213
+ for str_row in str_rows:
214
+ data_line = "| " + " | ".join(cell.ljust(col_widths[i]) for i, cell in enumerate(str_row)) + " |"
215
+ _write(data_line + "\n")
216
+ _write(sep + "\n")
217
+ row_word = "row" if len(str_rows) == 1 else "rows"
218
+ _write(f" ({len(str_rows)} {row_word})\n")
219
+
220
+
221
+ def _enable_step_mode() -> None:
222
+ """Activate step mode so the engine re-enters the REPL after the next statement."""
223
+ _state.step_mode = True
@@ -100,6 +100,7 @@ from execsql.metacommands.debug import (
100
100
  x_debug_write_odbc_drivers,
101
101
  x_debug_write_subvars,
102
102
  )
103
+ from execsql.metacommands.debug_repl import x_breakpoint
103
104
  from execsql.metacommands.io import (
104
105
  x_cd,
105
106
  x_copy,
@@ -1690,6 +1691,17 @@ def build_dispatch_table() -> MetaCommandList:
1690
1691
  run_when_false=False,
1691
1692
  )
1692
1693
 
1694
+ # ------------------------------------------------------------------
1695
+ # BREAKPOINT
1696
+ # ------------------------------------------------------------------
1697
+ mcl.add(
1698
+ r"^\s*BREAKPOINT\s*$",
1699
+ x_breakpoint,
1700
+ description="BREAKPOINT",
1701
+ category="action",
1702
+ run_when_false=False,
1703
+ )
1704
+
1693
1705
  # ------------------------------------------------------------------
1694
1706
  # IF / ORIF / ANDIF / ELSEIF / ELSE / ENDIF
1695
1707
  # ------------------------------------------------------------------
execsql/script/engine.py CHANGED
@@ -506,6 +506,11 @@ class CommandList:
506
506
  cmditem.command.commandline()[:100],
507
507
  ),
508
508
  )
509
+ if _state.step_mode:
510
+ _state.step_mode = False
511
+ from execsql.metacommands.debug_repl import _debug_repl
512
+
513
+ _debug_repl()
509
514
  self.cmdptr += 1
510
515
 
511
516
  def run_next(self) -> None:
execsql/state.py CHANGED
@@ -96,6 +96,8 @@ __all__ = [
96
96
  "gui_manager_thread",
97
97
  # Profiling
98
98
  "profile_data",
99
+ # Debug REPL
100
+ "step_mode",
99
101
  # Version
100
102
  "primary_vno",
101
103
  "secondary_vno",
@@ -195,6 +197,8 @@ _CONTEXT_ATTRS: frozenset[str] = frozenset(
195
197
  "gui_manager_thread",
196
198
  # Profiling
197
199
  "profile_data",
200
+ # Debug REPL
201
+ "step_mode",
198
202
  },
199
203
  )
200
204
 
@@ -248,6 +252,8 @@ class RuntimeContext:
248
252
  "gui_manager_thread",
249
253
  # Profiling
250
254
  "profile_data",
255
+ # Debug REPL
256
+ "step_mode",
251
257
  )
252
258
 
253
259
  def __init__(self) -> None:
@@ -299,6 +305,9 @@ class RuntimeContext:
299
305
  # Each entry: (source, line_no, command_type, elapsed_secs, command_text_preview)
300
306
  self.profile_data: list[tuple] | None = None
301
307
 
308
+ # Debug REPL — True after a ``next`` command; engine re-enters REPL after next statement.
309
+ self.step_mode: bool = False
310
+
302
311
 
303
312
  # ---------------------------------------------------------------------------
304
313
  # Module proxy — transparently delegates context attr access to _ctx
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: execsql2
3
- Version: 2.8.0
3
+ Version: 2.10.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 |
@@ -7,12 +7,13 @@ execsql/format.py,sha256=-6iknDddqbkapMo4NKmT5LAynDLqMW5kHgDWRg0KSws,11990
7
7
  execsql/models.py,sha256=DxkGp9iWbuZDWPGmnxZp9mvEeyOwxEJNx94fxQQiLfQ,13538
8
8
  execsql/parser.py,sha256=mbNSMiAMR1NvNvFtQAZq6nxBOupMGJZXSimLWLtZeNs,15537
9
9
  execsql/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- execsql/state.py,sha256=BodGWiLD7I3s7LFd8Mb6SHMp3I1BhVE4rYcR0UZWAoM,14799
10
+ execsql/state.py,sha256=ovUQOr78R3LzsDUzBLL9Bq9ZdHiiFVlBvCNzilQ-K-s,15055
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
@@ -58,13 +59,14 @@ execsql/importers/csv.py,sha256=Mu848WNzuhVO1ade-WurPyxqGOuVNRO8UwRF3-bav_I,4845
58
59
  execsql/importers/feather.py,sha256=g2B69d2uv9vmnXcmjFyTVsMP40LYEzFYkhk3gD26mGw,1900
59
60
  execsql/importers/ods.py,sha256=MJsdsjropzCvxAA3DDZfAL_AnmZ4yij7DnrjGyDJqHQ,2843
60
61
  execsql/importers/xls.py,sha256=e0Zfe47ZiCpA1Ae3XDJ1ko3sCiH3-8U6XLKi6NvD0jQ,3683
61
- execsql/metacommands/__init__.py,sha256=ejuY2GFHxNh5f_Yp_GOV0EBe2vuUcly0-zBrKiR3qes,11112
62
- execsql/metacommands/conditions.py,sha256=u-XdeIWj9QMht9hRGhvH0XlB9V09AliAPKDBHRXc02s,24540
62
+ execsql/metacommands/__init__.py,sha256=TT1ARHgHltHqZ7qx4Y62o1h_GOPvUztZKCem-wAE560,11215
63
+ execsql/metacommands/conditions.py,sha256=QUpevCHce9kbKpt6XpHkc73q3bYAIfaBin0b6eaJnYQ,29259
63
64
  execsql/metacommands/connect.py,sha256=Nsm0D91i3RX-R2rzQQ-Br-gULaI6Uvdn9fqb7DOAVfE,14804
64
65
  execsql/metacommands/control.py,sha256=CBCg0ZKSR-BGejBW5cXwk6aJ9VrYBzCg9C40ofi8qi8,8776
65
66
  execsql/metacommands/data.py,sha256=tRQBGTAuW-eJ2tBNWaoZI9OjTyNNyHJISo7gOdL-sm8,11370
66
67
  execsql/metacommands/debug.py,sha256=nmfQ2ijUbTQO3drnyV9EzFueGSTfMl-CddP_NlQyI14,8178
67
- execsql/metacommands/dispatch.py,sha256=I6HoBKMofRalL1Cmdsnj1jQFZSFXCgntTofFaIZWgWQ,83670
68
+ execsql/metacommands/debug_repl.py,sha256=fjf8O25dQs-Nrl_Uw_0RahKSttkty4cbzUClNsZuV18,7303
69
+ execsql/metacommands/dispatch.py,sha256=1Mae6yqrea6wViFLBsvVt33Zgx4xP8tnhOuB_aQC89c,84054
68
70
  execsql/metacommands/io.py,sha256=Duh60caM4go9JczbGYNMKKYpcMimwPzF6EQ_tshKxdE,2971
69
71
  execsql/metacommands/io_export.py,sha256=7lkCSnPhXy9FVau9_hT1u68NOVdG2DsWmvUh9hM1QWI,18359
70
72
  execsql/metacommands/io_fileops.py,sha256=RKqbWPTYiwiqCZYG-lpih0w1JVOY4RBFdWr3BJb_pnY,9669
@@ -75,7 +77,7 @@ execsql/metacommands/script_ext.py,sha256=TUgAldB2LSJAwZrCvDDi804hQ1d9BDQD2GDqHN
75
77
  execsql/metacommands/system.py,sha256=sUR5kLL7idTVg8WXIMdd-Kv7nkERIiaeL0beWsz8NyY,7293
76
78
  execsql/script/__init__.py,sha256=pIo0EJ7-vg67rSMbOvbri_BOUgLoGoSEUfJgxUN7ZS0,3380
77
79
  execsql/script/control.py,sha256=s-1eZdGARM6H1FwZ6VDdO_f50j7bvvRtTHesfUm9tbc,6144
78
- execsql/script/engine.py,sha256=d3iUGF_r4OQAlqKpd8pIuWGAjDlYvzYiKqi-2Ew1-Yo,40213
80
+ execsql/script/engine.py,sha256=2WcOfYEOwO7L_NQAd3vk_c2wk1VZKJYSakSl07FBUts,40390
79
81
  execsql/script/variables.py,sha256=MOT9XEHucpuuuHQZM5bklxGMBQcwHzwTBxd0q3aO0XY,11641
80
82
  execsql/utils/__init__.py,sha256=0uR6JwVJQRX3vceByNBduCAf5dd5assKjeqJUWvpZoA,278
81
83
  execsql/utils/auth.py,sha256=onXzNkNZQZxGC5w7eey06sjvAIAX_Lf9g7nUJtcsel0,7009
@@ -89,24 +91,24 @@ execsql/utils/numeric.py,sha256=xh02ANSRk3nUpQ-rtm66ILoMqoi7HtzCoRMIOT9U8QI,1570
89
91
  execsql/utils/regex.py,sha256=diEzTZqU_HHwVMadPAvN1Vgzhl7I03eVaEFGCXyGGL8,3770
90
92
  execsql/utils/strings.py,sha256=5Dvzrk-9SIw2lpxXZQkiJbNyo1sy7iXXAtSULlZ0KG8,8488
91
93
  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,,
94
+ execsql2-2.10.0.data/data/execsql2_extras/README.md,sha256=sxwVyU0ZahCfANv56LahkyuM505kFjrMhe-1SvWE69E,4845
95
+ execsql2-2.10.0.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
96
+ execsql2-2.10.0.data/data/execsql2_extras/example_config_prompt.sql,sha256=SY3Jxn1qcVm4kPW9xmmTfknHfvURXmeEYTbRjYrjGSw,7487
97
+ execsql2-2.10.0.data/data/execsql2_extras/execsql.conf,sha256=_45iJ-KWZnB8uMW_gEg067MM5pmGJ-dVl7VbAZMunAE,9530
98
+ execsql2-2.10.0.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
99
+ execsql2-2.10.0.data/data/execsql2_extras/md_compare.sql,sha256=B8Wd7LZ0vnMY2qvA139JIEBkPObgRH2i5xj6PejTQt8,24092
100
+ execsql2-2.10.0.data/data/execsql2_extras/md_glossary.sql,sha256=DJRHcU5NbFpxTTX-IwH3yRlsboj1q6BBGrUAHKn4Cuo,10796
101
+ execsql2-2.10.0.data/data/execsql2_extras/md_upsert.sql,sha256=v_7GbWh_N1mBTmw3gvTrkagOVp2q0KmXvM8hE-DlFxY,112524
102
+ execsql2-2.10.0.data/data/execsql2_extras/pg_compare.sql,sha256=9dWa8hnfy5dVJI-z2iGpd9JzQmI4j2ziMlEdpnr66ro,24352
103
+ execsql2-2.10.0.data/data/execsql2_extras/pg_glossary.sql,sha256=pKjIIDsROAgJq2H-1qNEcRMAWManivcZ_AEVHzUUlic,9908
104
+ execsql2-2.10.0.data/data/execsql2_extras/pg_upsert.sql,sha256=k7AFiGTLBy3nf-qO5QIaZrEYTAKvdxxU3JDLx9jqkzs,108315
105
+ execsql2-2.10.0.data/data/execsql2_extras/script_template.sql,sha256=1Estacb_vm1FgK41k_G9nuduP1yiA-fQ1Kn4Z4mv5Ao,11153
106
+ execsql2-2.10.0.data/data/execsql2_extras/ss_compare.sql,sha256=TsVxWm3cEpR5-EiMYXNhtaY0arSNeKZhsJdHdLA7xeI,24833
107
+ execsql2-2.10.0.data/data/execsql2_extras/ss_glossary.sql,sha256=cLM7nN8JOIu9ZVP9oY9qdSK3hrnWJiDcX6nZmQQbQWI,13065
108
+ execsql2-2.10.0.data/data/execsql2_extras/ss_upsert.sql,sha256=BCqmBykXBF-BpCgOFeG1qhf2XfScKsxPD17wd1hYfHw,120647
109
+ execsql2-2.10.0.dist-info/METADATA,sha256=V4hDmO7GKgx0ZRu8BsVES5C6oyRIUus-MIu7k7RJlfI,16956
110
+ execsql2-2.10.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
111
+ execsql2-2.10.0.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
112
+ execsql2-2.10.0.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
113
+ execsql2-2.10.0.dist-info/licenses/NOTICE,sha256=sqVrM73Ys9zfvWC_P797lHfTnoPW_ETeBSrUTFaob0A,339
114
+ execsql2-2.10.0.dist-info/RECORD,,