execsql2 2.7.1__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 (29) hide show
  1. execsql/cli/__init__.py +31 -0
  2. execsql/cli/lint.py +459 -0
  3. execsql/cli/run.py +195 -15
  4. execsql/metacommands/__init__.py +2 -0
  5. execsql/metacommands/control.py +33 -0
  6. execsql/metacommands/dispatch.py +31 -0
  7. execsql/script/engine.py +16 -0
  8. execsql/state.py +10 -0
  9. {execsql2-2.7.1.dist-info → execsql2-2.9.0.dist-info}/METADATA +3 -1
  10. {execsql2-2.7.1.dist-info → execsql2-2.9.0.dist-info}/RECORD +29 -28
  11. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/README.md +0 -0
  12. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  13. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  14. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/execsql.conf +0 -0
  15. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  16. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  17. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  18. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  19. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  20. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  21. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  22. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/script_template.sql +0 -0
  23. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  24. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  25. {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  26. {execsql2-2.7.1.dist-info → execsql2-2.9.0.dist-info}/WHEEL +0 -0
  27. {execsql2-2.7.1.dist-info → execsql2-2.9.0.dist-info}/entry_points.txt +0 -0
  28. {execsql2-2.7.1.dist-info → execsql2-2.9.0.dist-info}/licenses/LICENSE.txt +0 -0
  29. {execsql2-2.7.1.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",
@@ -254,6 +273,11 @@ def main(
254
273
  "--dump-keywords",
255
274
  help="Dump all metacommand keywords as JSON and exit.",
256
275
  ),
276
+ profile: bool = typer.Option(
277
+ False,
278
+ "--profile",
279
+ help="Record per-statement execution times and print a timing summary after the script completes.",
280
+ ),
257
281
  version: bool | None = typer.Option(
258
282
  None,
259
283
  "--version",
@@ -345,6 +369,10 @@ def main(
345
369
  positional = args or []
346
370
  if command is not None:
347
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
348
376
  else:
349
377
  if not positional:
350
378
  _err_console.print(
@@ -415,6 +443,9 @@ def main(
415
443
  dsn=dsn,
416
444
  output_dir=output_dir,
417
445
  progress=progress,
446
+ profile=profile,
447
+ ping=ping,
448
+ lint=lint,
418
449
  )
419
450
 
420
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
@@ -20,11 +20,15 @@ from execsql.cli.dsn import _parse_connection_string
20
20
  from execsql.cli.help import _console, _err_console
21
21
  from execsql.config import ConfigData, StatObj
22
22
  from execsql.exceptions import ConfigError, ErrInfo
23
- from execsql.script import SubVarSet, current_script_line, read_sqlfile, read_sqlstring, runscripts
23
+ from execsql.script import SubVarSet, current_script_line, read_sqlfile, read_sqlstring, runscripts, substitute_vars
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", "_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
  # ---------------------------------------------------------------------------
@@ -33,7 +37,16 @@ __all__ = ["_connect_initial_db", "_print_dry_run", "_run"]
33
37
 
34
38
 
35
39
  def _print_dry_run(cmdlist: object) -> None:
36
- """Print the parsed command list for --dry-run mode."""
40
+ """Print the parsed command list for --dry-run mode.
41
+
42
+ Substitution variables (``$VAR``, ``&ENV``, ``@COUNTER``) that are already
43
+ populated — from environment variables, ``--assign-arg`` values, or config —
44
+ are expanded in the displayed text. System variables that are set at
45
+ execution time (e.g. ``$CURRENT_TIME``, ``$DB_NAME``, ``$TIMER``) will
46
+ appear unexpanded because ``set_system_vars()`` has not yet been called.
47
+ Local ``~``-prefixed script-scope variables are also not expanded (no script
48
+ execution context exists in dry-run mode).
49
+ """
37
50
  if cmdlist is None or not cmdlist.cmdlist:
38
51
  _console.print("[yellow]No commands found in script.[/yellow]")
39
52
  return
@@ -43,7 +56,129 @@ def _print_dry_run(cmdlist: object) -> None:
43
56
  for i, cmd in enumerate(cmdlist.cmdlist, 1):
44
57
  ctype = "SQL " if cmd.command_type == "sql" else "METACMD"
45
58
  source_info = f"[dim]{cmd.source}:{cmd.line_no}[/dim]"
46
- _console.print(f" [dim]{i:>4}[/dim] [bold green]{ctype}[/bold green] {source_info} {cmd.commandline()}")
59
+ raw = cmd.commandline()
60
+ try:
61
+ expanded = substitute_vars(raw)
62
+ except Exception:
63
+ # Cycle detection or other expansion errors — fall back to raw text.
64
+ expanded = raw
65
+ _console.print(f" [dim]{i:>4}[/dim] [bold green]{ctype}[/bold green] {source_info} {expanded}")
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Profile report helper
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def _print_profile(profile_data: list[tuple]) -> None:
74
+ """Print a per-statement timing summary to stdout.
75
+
76
+ Args:
77
+ profile_data: List of ``(source, line_no, command_type, elapsed_secs,
78
+ command_text_preview)`` tuples collected during execution.
79
+ """
80
+ if not profile_data:
81
+ _console.print("[dim]Profile: no statements recorded.[/dim]")
82
+ return
83
+
84
+ total_secs = sum(row[3] for row in profile_data)
85
+ n = len(profile_data)
86
+
87
+ # Sort descending by elapsed time; show top 20 (or all if <= 20).
88
+ sorted_data = sorted(profile_data, key=lambda r: r[3], reverse=True)
89
+ display = sorted_data[:20]
90
+
91
+ _console.print()
92
+ _console.print(f"[bold cyan]Profile:[/bold cyan] {n} statement{'s' if n != 1 else ''} in {total_secs:.3f}s")
93
+ _console.print()
94
+
95
+ header = f" {'Time (s)':<10} {'Pct':<7} {'Source:Line':<20} {'Type':<7} Command"
96
+ sep = f" {'-' * 10} {'-' * 7} {'-' * 20} {'-' * 7} {'-' * 40}"
97
+ _console.print(f"[dim]{header}[/dim]")
98
+ _console.print(f"[dim]{sep}[/dim]")
99
+
100
+ for source, line_no, command_type, elapsed, preview in display:
101
+ pct = (elapsed / total_secs * 100) if total_secs > 0 else 0.0
102
+ source_col = f"{source}:{line_no}"
103
+ if len(source_col) > 20:
104
+ source_col = "..." + source_col[-17:]
105
+ ctype_label = "SQL " if command_type == "sql" else "METACMD"
106
+ preview_short = preview[:50].replace("\n", " ").strip()
107
+ if len(preview) > 50:
108
+ preview_short += "..."
109
+ _console.print(
110
+ f" [yellow]{elapsed:<10.3f}[/yellow] "
111
+ f"[dim]{pct:<6.1f}%[/dim] "
112
+ f"[cyan]{source_col:<20}[/cyan] "
113
+ f"[green]{ctype_label:<7}[/green] "
114
+ f"{preview_short}",
115
+ )
116
+
117
+ if len(sorted_data) > 20:
118
+ omitted = len(sorted_data) - 20
119
+ _console.print(
120
+ f"[dim] ... {omitted} more statement{'s' if omitted != 1 else ''} not shown (top 20 by time)[/dim]",
121
+ )
122
+
123
+ _console.print()
124
+
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)
47
182
 
48
183
 
49
184
  # ---------------------------------------------------------------------------
@@ -76,6 +211,9 @@ def _run(
76
211
  dsn: str | None = None,
77
212
  output_dir: str | None = None,
78
213
  progress: bool = False,
214
+ profile: bool = False,
215
+ ping: bool = False,
216
+ lint: bool = False,
79
217
  ) -> None:
80
218
  """Initialise state, connect to the database, load the script, and run it.
81
219
 
@@ -83,6 +221,17 @@ def _run(
83
221
  without going through the Typer CLI layer. All parameters mirror the
84
222
  corresponding CLI options; see [Syntax & Options](../syntax.md) for
85
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.
86
235
  """
87
236
  import execsql.state as _state
88
237
 
@@ -212,10 +361,10 @@ def _run(
212
361
  if progress:
213
362
  conf.show_progress = True
214
363
 
215
- # 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)
216
365
  # off=1: script file occupies positional[0]; connection args start at [1]
217
- # off=0: no script file; all positionals are connection args
218
- 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
219
368
  if len(positional) == off + 1:
220
369
  if conf.db_type in ("a", "l", "k"):
221
370
  conf.db_file = positional[off]
@@ -329,12 +478,13 @@ def _run(
329
478
  )
330
479
 
331
480
  # ------------------------------------------------------------------
332
- # Load the SQL script
481
+ # Load the SQL script (skipped in --ping and --dry-run with no script)
333
482
  # ------------------------------------------------------------------
334
- if command is not None:
335
- read_sqlstring(command.replace("\\n", "\n").replace("\\t", "\t"), "<inline>")
336
- else:
337
- 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)
338
488
 
339
489
  # ------------------------------------------------------------------
340
490
  # Dry-run: print command list and exit without connecting to DB
@@ -343,6 +493,16 @@ def _run(
343
493
  _print_dry_run(_state.commandliststack[-1] if _state.commandliststack else None)
344
494
  raise SystemExit(0)
345
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
+
346
506
  # ------------------------------------------------------------------
347
507
  # Start GUI console if requested
348
508
  # ------------------------------------------------------------------
@@ -372,13 +532,22 @@ def _run(
372
532
  _state.subvars.add_substitution("$DB_SERVER", db.server_name)
373
533
  _state.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", "0")
374
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
+
375
541
  # ------------------------------------------------------------------
376
542
  # Execute the script
377
543
  # ------------------------------------------------------------------
378
544
  atexit.register(_state.dbs.closeall)
379
545
  _state.dbs.do_rollback = True
380
546
 
381
- _execute_script_direct(conf)
547
+ if profile:
548
+ _state.profile_data = []
549
+
550
+ _execute_script_direct(conf, profile=profile)
382
551
 
383
552
 
384
553
  # ---------------------------------------------------------------------------
@@ -437,8 +606,15 @@ def _execute_script_textual_console(conf: ConfigData) -> None:
437
606
  _state.exec_log.log_exit_end()
438
607
 
439
608
 
440
- def _execute_script_direct(conf: ConfigData) -> None:
441
- """Run runscripts() in the current (main) thread — used when Textual is not active."""
609
+ def _execute_script_direct(conf: ConfigData, *, profile: bool = False) -> None:
610
+ """Run runscripts() in the current (main) thread — used when Textual is not active.
611
+
612
+ Args:
613
+ conf: The active configuration object.
614
+ profile: When ``True``, print a per-statement timing summary after the
615
+ script completes. Timing data must already have been activated on
616
+ ``_state.profile_data`` before this function is called.
617
+ """
442
618
  import execsql.state as _state
443
619
  import execsql.utils.gui as _gui
444
620
 
@@ -463,6 +639,8 @@ def _execute_script_direct(conf: ConfigData) -> None:
463
639
  if gui_console_isrunning():
464
640
  gui_console_off()
465
641
  _state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
642
+ if profile and _state.profile_data is not None:
643
+ _print_profile(_state.profile_data)
466
644
  sys.exit(exc.code)
467
645
  except ConfigError:
468
646
  raise
@@ -489,6 +667,8 @@ def _execute_script_direct(conf: ConfigData) -> None:
489
667
  if gui_console_isrunning():
490
668
  gui_console_off()
491
669
  _state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
670
+ if profile and _state.profile_data is not None:
671
+ _print_profile(_state.profile_data)
492
672
  _state.exec_log.log_exit_end()
493
673
 
494
674
 
@@ -36,6 +36,7 @@ from execsql.metacommands.connect import (
36
36
  x_daoflushdelay,
37
37
  )
38
38
  from execsql.metacommands.control import (
39
+ x_assert,
39
40
  x_if,
40
41
  x_if_orif,
41
42
  x_if_andif,
@@ -238,6 +239,7 @@ __all__ = [
238
239
  "x_pg_vacuum",
239
240
  "x_daoflushdelay",
240
241
  # control handlers
242
+ "x_assert",
241
243
  "x_if",
242
244
  "x_if_orif",
243
245
  "x_if_andif",
@@ -34,6 +34,39 @@ from execsql.utils.fileio import EncodedFile, check_dir
34
34
  from execsql.utils.gui import GUI_HALT, GuiSpec, enable_gui, gui_console_isrunning
35
35
 
36
36
 
37
+ def x_assert(**kwargs: Any) -> None:
38
+ """Evaluate a condition and raise ErrInfo if it is false.
39
+
40
+ Syntax::
41
+
42
+ -- !x! ASSERT <condition> ["message"]
43
+ -- !x! ASSERT <condition> ['message']
44
+ -- !x! ASSERT <condition>
45
+
46
+ Args:
47
+ **kwargs: Keyword arguments injected by the dispatch table.
48
+ ``condtest`` — the condition expression string.
49
+ ``message`` — optional user-supplied failure message; may be None.
50
+
51
+ Raises:
52
+ ErrInfo: When the condition evaluates to False (or raises internally
53
+ for an unrecognized condition).
54
+ """
55
+ condition: str = kwargs["condtest"].strip()
56
+ raw_message: str | None = kwargs.get("message")
57
+ if raw_message:
58
+ # Strip surrounding quotes that the regex captured
59
+ message: str = raw_message.strip("'\"")
60
+ else:
61
+ message = f"Assertion failed: {condition}"
62
+
63
+ result = _state.xcmd_test(condition)
64
+ if result:
65
+ _state.exec_log.log_user_msg(f"ASSERT passed: {condition}")
66
+ else:
67
+ raise ErrInfo(type="cmd", other_msg=message)
68
+
69
+
37
70
  def x_if(**kwargs: Any) -> None:
38
71
  tf_value = _state.xcmd_test(kwargs["condtest"])
39
72
  if tf_value:
@@ -36,6 +36,7 @@ from execsql.metacommands.connect import (
36
36
  x_use,
37
37
  )
38
38
  from execsql.metacommands.control import (
39
+ x_assert,
39
40
  x_begin_batch,
40
41
  x_break,
41
42
  x_end_batch,
@@ -1659,6 +1660,36 @@ def build_dispatch_table() -> MetaCommandList:
1659
1660
  category="action",
1660
1661
  )
1661
1662
 
1663
+ # ------------------------------------------------------------------
1664
+ # ASSERT
1665
+ # ------------------------------------------------------------------
1666
+ # Two registrations; MetaCommandList.add() prepends, so register the
1667
+ # broader (no-message) pattern first and the more specific (with-message)
1668
+ # pattern second — the second registration wins because it is prepended
1669
+ # last and therefore tried first during dispatch.
1670
+ #
1671
+ # with-message: the trailing quoted token is captured as `message`;
1672
+ # everything between ASSERT and the message becomes `condtest`.
1673
+ # This handles conditions that themselves contain quoted strings, e.g.:
1674
+ # ASSERT $VAR = 'expected' 'wrong value'
1675
+ # The non-greedy (.+?) stops before the LAST quoted token on the line.
1676
+ #
1677
+ # no-message: full remainder after ASSERT goes into `condtest`.
1678
+ mcl.add(
1679
+ r"^\s*ASSERT\s+(?P<condtest>.+?)\s*$",
1680
+ x_assert,
1681
+ description="ASSERT",
1682
+ category="action",
1683
+ run_when_false=False,
1684
+ )
1685
+ mcl.add(
1686
+ r"^\s*ASSERT\s+(?P<condtest>.+?)\s+(?P<message>(?:\"[^\"]*\"|'[^']*'))\s*$",
1687
+ x_assert,
1688
+ description="ASSERT",
1689
+ category="action",
1690
+ run_when_false=False,
1691
+ )
1692
+
1662
1693
  # ------------------------------------------------------------------
1663
1694
  # IF / ORIF / ANDIF / ELSEIF / ELSE / ENDIF
1664
1695
  # ------------------------------------------------------------------
execsql/script/engine.py CHANGED
@@ -489,7 +489,23 @@ class CommandList:
489
489
  _state.subvars.add_substitution("$CURRENT_SCRIPT_NAME", Path(cmditem.source).name)
490
490
  _state.subvars.add_substitution("$CURRENT_SCRIPT_LINE", str(cmditem.line_no))
491
491
  _state.subvars.add_substitution("$SCRIPT_LINE", str(cmditem.line_no))
492
+ _profiling = _state.profile_data is not None
493
+ if _profiling:
494
+ import time as _time
495
+
496
+ _t0 = _time.perf_counter()
492
497
  cmditem.command.run(self.localvars.merge(self.paramvals), not _state.status.batch.in_batch())
498
+ if _profiling:
499
+ _elapsed = _time.perf_counter() - _t0
500
+ _state.profile_data.append(
501
+ (
502
+ cmditem.source,
503
+ cmditem.line_no,
504
+ cmditem.command_type,
505
+ _elapsed,
506
+ cmditem.command.commandline()[:100],
507
+ ),
508
+ )
493
509
  self.cmdptr += 1
494
510
 
495
511
  def run_next(self) -> None:
execsql/state.py CHANGED
@@ -94,6 +94,8 @@ __all__ = [
94
94
  "gui_console",
95
95
  "gui_manager_queue",
96
96
  "gui_manager_thread",
97
+ # Profiling
98
+ "profile_data",
97
99
  # Version
98
100
  "primary_vno",
99
101
  "secondary_vno",
@@ -191,6 +193,8 @@ _CONTEXT_ATTRS: frozenset[str] = frozenset(
191
193
  "gui_console",
192
194
  "gui_manager_queue",
193
195
  "gui_manager_thread",
196
+ # Profiling
197
+ "profile_data",
194
198
  },
195
199
  )
196
200
 
@@ -242,6 +246,8 @@ class RuntimeContext:
242
246
  "gui_console",
243
247
  "gui_manager_queue",
244
248
  "gui_manager_thread",
249
+ # Profiling
250
+ "profile_data",
245
251
  )
246
252
 
247
253
  def __init__(self) -> None:
@@ -289,6 +295,10 @@ class RuntimeContext:
289
295
  self.gui_manager_queue: _mp.Queue | None = None
290
296
  self.gui_manager_thread: _threading.Thread | None = None
291
297
 
298
+ # Profiling — None means profiling is disabled; a list means it is enabled.
299
+ # Each entry: (source, line_no, command_type, elapsed_secs, command_text_preview)
300
+ self.profile_data: list[tuple] | None = None
301
+
292
302
 
293
303
  # ---------------------------------------------------------------------------
294
304
  # 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.7.1
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 |
@@ -233,6 +234,7 @@ Run `execsql --help` for the full option list, or `execsql -m` to list all metac
233
234
  - Export query results in 20+ formats including CSV, TSV, JSON, YAML, XML, HTML, Markdown, LaTeX, XLSX, OpenDocument, Feather, Parquet, HDF5, DuckDB, SQLite, plain text, and Jinja2 templates.
234
235
  - Copy data between databases, including across different DBMS types.
235
236
  - Conditionally execute SQL and metacommands using `IF`/`ELSE`/`ENDIF` based on data values, DBMS type, or user input.
237
+ - Validate data with `ASSERT` — halt the script with a clear error message if a condition is false (ideal for CI pipelines).
236
238
  - Loop over blocks of SQL and metacommands using `LOOP`/`ENDLOOP`.
237
239
  - Use substitution variables (`SUB`, `$ARG_x`, built-in variables like `$date_tag`) to parameterize scripts.
238
240
  - Include or chain scripts with `INCLUDE` and `SCRIPT`.
@@ -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=Kg_cMr0DDEjFkEQ02BKO2xxeH7N03aBRj8UjvzQK-C0,14445
10
+ execsql/state.py,sha256=BodGWiLD7I3s7LFd8Mb6SHMp3I1BhVE4rYcR0UZWAoM,14799
11
11
  execsql/types.py,sha256=HVWb4umIB9lpxCGgqk3xy1hoGYPfN39xci5mHF0Izq4,31882
12
- execsql/cli/__init__.py,sha256=s4283f04Z-SS5CdwsJj6t_oHuyL5sQELsUGTh6x45NU,14908
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=seThMuXV0BmpG94lzOteoxlSjez-p-Ht4__5jjsJZAI,22829
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,13 @@ 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=zYRUuCnU-e-HbPdp_zCgQIriR_YgRqHkais45Eo3FNk,11082
62
+ execsql/metacommands/__init__.py,sha256=ejuY2GFHxNh5f_Yp_GOV0EBe2vuUcly0-zBrKiR3qes,11112
62
63
  execsql/metacommands/conditions.py,sha256=u-XdeIWj9QMht9hRGhvH0XlB9V09AliAPKDBHRXc02s,24540
63
64
  execsql/metacommands/connect.py,sha256=Nsm0D91i3RX-R2rzQQ-Br-gULaI6Uvdn9fqb7DOAVfE,14804
64
- execsql/metacommands/control.py,sha256=FCIWD-ZivHRZDqMS-2k37iR05HKHsv_7UPh5zJAg4I4,7693
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=aZq4QFY3AjqbplCr2iSoD13ayvT34wWns63a15a_JRM,82407
68
+ execsql/metacommands/dispatch.py,sha256=I6HoBKMofRalL1Cmdsnj1jQFZSFXCgntTofFaIZWgWQ,83670
68
69
  execsql/metacommands/io.py,sha256=Duh60caM4go9JczbGYNMKKYpcMimwPzF6EQ_tshKxdE,2971
69
70
  execsql/metacommands/io_export.py,sha256=7lkCSnPhXy9FVau9_hT1u68NOVdG2DsWmvUh9hM1QWI,18359
70
71
  execsql/metacommands/io_fileops.py,sha256=RKqbWPTYiwiqCZYG-lpih0w1JVOY4RBFdWr3BJb_pnY,9669
@@ -75,7 +76,7 @@ execsql/metacommands/script_ext.py,sha256=TUgAldB2LSJAwZrCvDDi804hQ1d9BDQD2GDqHN
75
76
  execsql/metacommands/system.py,sha256=sUR5kLL7idTVg8WXIMdd-Kv7nkERIiaeL0beWsz8NyY,7293
76
77
  execsql/script/__init__.py,sha256=pIo0EJ7-vg67rSMbOvbri_BOUgLoGoSEUfJgxUN7ZS0,3380
77
78
  execsql/script/control.py,sha256=s-1eZdGARM6H1FwZ6VDdO_f50j7bvvRtTHesfUm9tbc,6144
78
- execsql/script/engine.py,sha256=5WOuSbQR1vrp_SawylshzLmdHco2oEjqZBSoxRg0Ggo,39638
79
+ execsql/script/engine.py,sha256=d3iUGF_r4OQAlqKpd8pIuWGAjDlYvzYiKqi-2Ew1-Yo,40213
79
80
  execsql/script/variables.py,sha256=MOT9XEHucpuuuHQZM5bklxGMBQcwHzwTBxd0q3aO0XY,11641
80
81
  execsql/utils/__init__.py,sha256=0uR6JwVJQRX3vceByNBduCAf5dd5assKjeqJUWvpZoA,278
81
82
  execsql/utils/auth.py,sha256=onXzNkNZQZxGC5w7eey06sjvAIAX_Lf9g7nUJtcsel0,7009
@@ -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.7.1.data/data/execsql2_extras/README.md,sha256=sxwVyU0ZahCfANv56LahkyuM505kFjrMhe-1SvWE69E,4845
93
- execsql2-2.7.1.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
94
- execsql2-2.7.1.data/data/execsql2_extras/example_config_prompt.sql,sha256=SY3Jxn1qcVm4kPW9xmmTfknHfvURXmeEYTbRjYrjGSw,7487
95
- execsql2-2.7.1.data/data/execsql2_extras/execsql.conf,sha256=_45iJ-KWZnB8uMW_gEg067MM5pmGJ-dVl7VbAZMunAE,9530
96
- execsql2-2.7.1.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
97
- execsql2-2.7.1.data/data/execsql2_extras/md_compare.sql,sha256=B8Wd7LZ0vnMY2qvA139JIEBkPObgRH2i5xj6PejTQt8,24092
98
- execsql2-2.7.1.data/data/execsql2_extras/md_glossary.sql,sha256=DJRHcU5NbFpxTTX-IwH3yRlsboj1q6BBGrUAHKn4Cuo,10796
99
- execsql2-2.7.1.data/data/execsql2_extras/md_upsert.sql,sha256=v_7GbWh_N1mBTmw3gvTrkagOVp2q0KmXvM8hE-DlFxY,112524
100
- execsql2-2.7.1.data/data/execsql2_extras/pg_compare.sql,sha256=9dWa8hnfy5dVJI-z2iGpd9JzQmI4j2ziMlEdpnr66ro,24352
101
- execsql2-2.7.1.data/data/execsql2_extras/pg_glossary.sql,sha256=pKjIIDsROAgJq2H-1qNEcRMAWManivcZ_AEVHzUUlic,9908
102
- execsql2-2.7.1.data/data/execsql2_extras/pg_upsert.sql,sha256=k7AFiGTLBy3nf-qO5QIaZrEYTAKvdxxU3JDLx9jqkzs,108315
103
- execsql2-2.7.1.data/data/execsql2_extras/script_template.sql,sha256=1Estacb_vm1FgK41k_G9nuduP1yiA-fQ1Kn4Z4mv5Ao,11153
104
- execsql2-2.7.1.data/data/execsql2_extras/ss_compare.sql,sha256=TsVxWm3cEpR5-EiMYXNhtaY0arSNeKZhsJdHdLA7xeI,24833
105
- execsql2-2.7.1.data/data/execsql2_extras/ss_glossary.sql,sha256=cLM7nN8JOIu9ZVP9oY9qdSK3hrnWJiDcX6nZmQQbQWI,13065
106
- execsql2-2.7.1.data/data/execsql2_extras/ss_upsert.sql,sha256=BCqmBykXBF-BpCgOFeG1qhf2XfScKsxPD17wd1hYfHw,120647
107
- execsql2-2.7.1.dist-info/METADATA,sha256=UEW6Lpayq5sSHyOOrXXz0SvdAw0MzuoEbRQQ1Chr2VI,16722
108
- execsql2-2.7.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
109
- execsql2-2.7.1.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
110
- execsql2-2.7.1.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
111
- execsql2-2.7.1.dist-info/licenses/NOTICE,sha256=sqVrM73Ys9zfvWC_P797lHfTnoPW_ETeBSrUTFaob0A,339
112
- execsql2-2.7.1.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,,