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.
- execsql/cli/__init__.py +31 -0
- execsql/cli/lint.py +459 -0
- execsql/cli/run.py +195 -15
- execsql/metacommands/__init__.py +2 -0
- execsql/metacommands/control.py +33 -0
- execsql/metacommands/dispatch.py +31 -0
- execsql/script/engine.py +16 -0
- execsql/state.py +10 -0
- {execsql2-2.7.1.dist-info → execsql2-2.9.0.dist-info}/METADATA +3 -1
- {execsql2-2.7.1.dist-info → execsql2-2.9.0.dist-info}/RECORD +29 -28
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.7.1.data → execsql2-2.9.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.7.1.dist-info → execsql2-2.9.0.dist-info}/WHEEL +0 -0
- {execsql2-2.7.1.dist-info → execsql2-2.9.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.7.1.dist-info → execsql2-2.9.0.dist-info}/licenses/LICENSE.txt +0 -0
- {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
|
-
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
|
execsql/metacommands/__init__.py
CHANGED
|
@@ -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",
|
execsql/metacommands/control.py
CHANGED
|
@@ -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:
|
execsql/metacommands/dispatch.py
CHANGED
|
@@ -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.
|
|
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=
|
|
10
|
+
execsql/state.py,sha256=BodGWiLD7I3s7LFd8Mb6SHMp3I1BhVE4rYcR0UZWAoM,14799
|
|
11
11
|
execsql/types.py,sha256=HVWb4umIB9lpxCGgqk3xy1hoGYPfN39xci5mHF0Izq4,31882
|
|
12
|
-
execsql/cli/__init__.py,sha256=
|
|
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/
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
93
|
-
execsql2-2.
|
|
94
|
-
execsql2-2.
|
|
95
|
-
execsql2-2.
|
|
96
|
-
execsql2-2.
|
|
97
|
-
execsql2-2.
|
|
98
|
-
execsql2-2.
|
|
99
|
-
execsql2-2.
|
|
100
|
-
execsql2-2.
|
|
101
|
-
execsql2-2.
|
|
102
|
-
execsql2-2.
|
|
103
|
-
execsql2-2.
|
|
104
|
-
execsql2-2.
|
|
105
|
-
execsql2-2.
|
|
106
|
-
execsql2-2.
|
|
107
|
-
execsql2-2.
|
|
108
|
-
execsql2-2.
|
|
109
|
-
execsql2-2.
|
|
110
|
-
execsql2-2.
|
|
111
|
-
execsql2-2.
|
|
112
|
-
execsql2-2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|