execsql2 2.24.0__py3-none-any.whl → 2.25.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/api.py +169 -160
- execsql/cli/__init__.py +2 -0
- execsql/cli/commands/lint.py +2 -10
- execsql/cli/commands/run.py +35 -27
- execsql/cli/dsn.py +11 -4
- execsql/cli/lint.py +271 -28
- execsql/cli/options.py +1 -1
- execsql/cli/run.py +56 -19
- execsql/config.py +5 -19
- execsql/data/execsql.conf.template +14 -4
- execsql/db/access.py +29 -8
- execsql/db/base.py +40 -8
- execsql/db/dsn.py +13 -4
- execsql/db/duckdb.py +3 -1
- execsql/db/factory.py +11 -2
- execsql/db/firebird.py +21 -7
- execsql/db/mysql.py +37 -16
- execsql/db/oracle.py +49 -16
- execsql/db/postgres.py +30 -6
- execsql/db/sqlite.py +7 -9
- execsql/db/sqlserver.py +25 -8
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +29 -2
- execsql/gui/desktop.py +1 -3
- execsql/gui/tui.py +2 -2
- execsql/importers/base.py +35 -1
- execsql/importers/csv.py +3 -1
- execsql/manifest.py +33 -22
- execsql/metacommands/conditions.py +6 -7
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/io_fileops.py +62 -37
- execsql/metacommands/io_import.py +0 -1
- execsql/metacommands/prompt.py +2 -0
- execsql/metacommands/upsert.py +5 -2
- execsql/models.py +3 -2
- execsql/parser.py +14 -5
- execsql/plugins.py +24 -0
- execsql/script/engine.py +40 -11
- execsql/script/executor.py +9 -5
- execsql/script/parser.py +167 -118
- execsql/state.py +23 -9
- execsql/state.pyi +8 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +71 -9
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/METADATA +18 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/RECORD +72 -72
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/lint.py
CHANGED
|
@@ -7,10 +7,10 @@ required.
|
|
|
7
7
|
|
|
8
8
|
Checks performed:
|
|
9
9
|
|
|
10
|
-
1. **Parse errors** —
|
|
11
|
-
|
|
12
|
-
``
|
|
13
|
-
|
|
10
|
+
1. **Parse errors** — :func:`lint_source` parses with error recovery, so
|
|
11
|
+
every unmatched or unclosed IF / LOOP / BATCH / SCRIPT / SQL block is a
|
|
12
|
+
``P001`` at its own line and the remaining checks run on the recovered
|
|
13
|
+
tree.
|
|
14
14
|
2. **Empty scripts** — warns when no nodes were parsed.
|
|
15
15
|
3. **Potentially undefined variables** — flags ``!!$VAR!!`` references
|
|
16
16
|
with no preceding ``SUB``-family definition, ignoring built-in
|
|
@@ -22,6 +22,12 @@ Checks performed:
|
|
|
22
22
|
5. **EXECUTE SCRIPT target resolution** — warns when a target name does
|
|
23
23
|
not correspond to a :class:`ScriptBlock` in the same file (skipped
|
|
24
24
|
when ``IF EXISTS`` is present).
|
|
25
|
+
6. **Unknown metacommands** — reports a metacommand that no entry in the
|
|
26
|
+
dispatch table ``execsql run`` uses (plus installed plugins) matches,
|
|
27
|
+
skipping commands that contain a substitution variable.
|
|
28
|
+
7. **Unknown conditions** — parses (never evaluates) every IF, ELSEIF,
|
|
29
|
+
ANDIF, ORIF, LOOP and EXECUTE SCRIPT ... WHILE/UNTIL condition, and the
|
|
30
|
+
condition in ASSERT and WAIT_UNTIL, against the conditional-test table.
|
|
25
31
|
|
|
26
32
|
Every check has a rule code (:data:`RULES`), so a caller can select or
|
|
27
33
|
ignore rules and machine-readable output can name them.
|
|
@@ -31,6 +37,8 @@ Public surface:
|
|
|
31
37
|
- :func:`lint` — entry point; returns a list of :class:`Issue`.
|
|
32
38
|
- :data:`RULES` / :class:`Rule` — the rule registry: code, name, severity.
|
|
33
39
|
- :func:`parse_error` — the :class:`Issue` for a script that fails to parse.
|
|
40
|
+
- :func:`lint_source` — parse script text and lint it: every block-structure
|
|
41
|
+
error as a ``P001``, plus every other rule on the recovered tree.
|
|
34
42
|
- :func:`resolve_selectors` / :func:`filter_issues` — ``--select`` and
|
|
35
43
|
``--ignore`` handling.
|
|
36
44
|
- :func:`print_text`, :func:`print_concise`, :func:`print_statistics`,
|
|
@@ -45,7 +53,7 @@ import re
|
|
|
45
53
|
from collections import Counter
|
|
46
54
|
from collections.abc import Iterable
|
|
47
55
|
from pathlib import Path
|
|
48
|
-
from typing import NamedTuple
|
|
56
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
49
57
|
|
|
50
58
|
from execsql.exceptions import ErrInfo
|
|
51
59
|
from execsql.script.ast import (
|
|
@@ -62,6 +70,10 @@ from execsql.script.ast import (
|
|
|
62
70
|
SqlStatement,
|
|
63
71
|
)
|
|
64
72
|
|
|
73
|
+
if TYPE_CHECKING:
|
|
74
|
+
from execsql.script.engine import MetaCommandList
|
|
75
|
+
from execsql.script.ast import ConditionModifier
|
|
76
|
+
|
|
65
77
|
__all__ = [
|
|
66
78
|
"RULES",
|
|
67
79
|
"Issue",
|
|
@@ -69,6 +81,7 @@ __all__ = [
|
|
|
69
81
|
"exit_code",
|
|
70
82
|
"filter_issues",
|
|
71
83
|
"lint",
|
|
84
|
+
"lint_source",
|
|
72
85
|
"parse_error",
|
|
73
86
|
"print_concise",
|
|
74
87
|
"print_statistics",
|
|
@@ -109,6 +122,24 @@ RULES: dict[str, Rule] = {
|
|
|
109
122
|
"error",
|
|
110
123
|
"The script cannot be parsed, for example an IF, LOOP, BATCH or SCRIPT block that is never closed.",
|
|
111
124
|
),
|
|
125
|
+
Rule(
|
|
126
|
+
"P002",
|
|
127
|
+
"split-dollar-quote",
|
|
128
|
+
"error",
|
|
129
|
+
"A dollar-quoted body ($$ ... $$) is split into separate statements at a line ending in ';'.",
|
|
130
|
+
),
|
|
131
|
+
Rule(
|
|
132
|
+
"P003",
|
|
133
|
+
"unknown-metacommand",
|
|
134
|
+
"error",
|
|
135
|
+
"A metacommand is misspelled or malformed: no form of any metacommand matches it.",
|
|
136
|
+
),
|
|
137
|
+
Rule(
|
|
138
|
+
"P004",
|
|
139
|
+
"unknown-condition",
|
|
140
|
+
"error",
|
|
141
|
+
"A condition (IF, ELSEIF, ANDIF, ORIF, LOOP, ASSERT, ...) uses an unknown test or cannot be parsed.",
|
|
142
|
+
),
|
|
112
143
|
Rule("S001", "empty-script", "warning", "The script contains no statements."),
|
|
113
144
|
Rule(
|
|
114
145
|
"V001",
|
|
@@ -194,10 +225,22 @@ _RX_SUB_INI = re.compile(
|
|
|
194
225
|
)
|
|
195
226
|
_RX_SELECTSUB = re.compile(r"^\s*(?:SELECT_?SUB|PROMPT\s+SELECT_?SUB)\s+", re.I)
|
|
196
227
|
_RX_SUB_LOCAL = re.compile(r"^\s*SUB_LOCAL\s+(?P<name>\w+)\s+", re.I)
|
|
197
|
-
_RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name
|
|
198
|
-
_RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name
|
|
199
|
-
_RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name
|
|
200
|
-
_RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<
|
|
228
|
+
_RX_SUB_TEMPFILE = re.compile(r"^\s*SUB_TEMPFILE\s+(?P<name>[+~]?\w+)\s*$", re.I)
|
|
229
|
+
_RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
230
|
+
_RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>[+~]?\w+)\s+", re.I)
|
|
231
|
+
_RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<qstr>.+?)\s*$", re.I)
|
|
232
|
+
# The prompts that store the answer in a variable.
|
|
233
|
+
_RX_ASK_SUB = re.compile(r"""^\s*(?:PROMPT\s+)?ASK\s+(?:'.*'|".*")\s+SUB\s+(?P<name>~?\w+)(?:\s|$)""", re.I)
|
|
234
|
+
_RX_PROMPT_ENTER_SUB = re.compile(r"^\s*PROMPT\s+ENTER_SUB\s+(?P<name>~?\w+)\s", re.I)
|
|
235
|
+
_RX_PROMPT_DIRECTORY = re.compile(r"^\s*PROMPT\s+DIRECTORY\s+SUB\s+(?P<name>~?\w+)(?:\s|$)", re.I)
|
|
236
|
+
_RX_PROMPT_FILE = re.compile(
|
|
237
|
+
r"^\s*PROMPT\s+(?:OPENFILE|SAVEFILE)\s+SUB\s+(?P<names>~?\w+(?:\s+~?\w+){0,4})(?=\s+FROM\s|\s*$)",
|
|
238
|
+
re.I,
|
|
239
|
+
)
|
|
240
|
+
_RX_PROMPT_CREDENTIALS = re.compile(
|
|
241
|
+
r'^\s*PROMPT(?:\s+MESSAGE)?(?:\s+"[^"]*")?\s+CREDENTIALS\s+(?P<user>\w+)\s+(?P<pw>\w+)\s*$',
|
|
242
|
+
re.I,
|
|
243
|
+
)
|
|
201
244
|
|
|
202
245
|
_RX_VAR_REF = re.compile(r"!!([$@&~#+]?\w+)!!", re.I)
|
|
203
246
|
|
|
@@ -245,6 +288,39 @@ def _get_builtin_vars() -> frozenset[str]:
|
|
|
245
288
|
return _BUILTIN_VARS
|
|
246
289
|
|
|
247
290
|
|
|
291
|
+
_DISPATCH_TABLE: MetaCommandList | None = None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _dispatch_table() -> MetaCommandList:
|
|
295
|
+
"""Return the metacommand table ``execsql run`` dispatches from, built on first call.
|
|
296
|
+
|
|
297
|
+
A private copy, with installed metacommand plugins registered, so that
|
|
298
|
+
linting never adds entries to the table a run in this process uses.
|
|
299
|
+
"""
|
|
300
|
+
global _DISPATCH_TABLE
|
|
301
|
+
if _DISPATCH_TABLE is None:
|
|
302
|
+
from execsql.metacommands.dispatch import build_dispatch_table
|
|
303
|
+
from execsql.plugins import discover_metacommand_plugins
|
|
304
|
+
|
|
305
|
+
table = build_dispatch_table()
|
|
306
|
+
discover_metacommand_plugins(table)
|
|
307
|
+
_DISPATCH_TABLE = table
|
|
308
|
+
return _DISPATCH_TABLE
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
_CONDITIONAL_TABLE: MetaCommandList | None = None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _conditional_table() -> MetaCommandList:
|
|
315
|
+
"""Return the table of conditional tests ``execsql run`` evaluates with, built on first call."""
|
|
316
|
+
global _CONDITIONAL_TABLE
|
|
317
|
+
if _CONDITIONAL_TABLE is None:
|
|
318
|
+
from execsql.metacommands.conditions import build_conditional_table
|
|
319
|
+
|
|
320
|
+
_CONDITIONAL_TABLE = build_conditional_table()
|
|
321
|
+
return _CONDITIONAL_TABLE
|
|
322
|
+
|
|
323
|
+
|
|
248
324
|
# ---------------------------------------------------------------------------
|
|
249
325
|
# AST walker helpers
|
|
250
326
|
# ---------------------------------------------------------------------------
|
|
@@ -302,7 +378,7 @@ def _extract_var_definition(
|
|
|
302
378
|
script_dir: Path | None,
|
|
303
379
|
defined: set[str],
|
|
304
380
|
) -> None:
|
|
305
|
-
"""
|
|
381
|
+
"""Add the variable(s) a metacommand defines to *defined*: the SUB family and the prompts that store an answer."""
|
|
306
382
|
for rx in (
|
|
307
383
|
_RX_SUB,
|
|
308
384
|
_RX_SUB_EMPTY,
|
|
@@ -313,13 +389,32 @@ def _extract_var_definition(
|
|
|
313
389
|
_RX_SUB_TEMPFILE,
|
|
314
390
|
_RX_SUB_DECRYPT,
|
|
315
391
|
_RX_SUB_ENCRYPT,
|
|
316
|
-
|
|
392
|
+
_RX_ASK_SUB,
|
|
393
|
+
_RX_PROMPT_ENTER_SUB,
|
|
394
|
+
_RX_PROMPT_DIRECTORY,
|
|
317
395
|
):
|
|
318
396
|
m = rx.match(command)
|
|
319
397
|
if m:
|
|
320
398
|
defined.add(m.group("name").lstrip("+~").upper())
|
|
321
399
|
return
|
|
322
400
|
|
|
401
|
+
m = _RX_PROMPT_FILE.match(command)
|
|
402
|
+
if m:
|
|
403
|
+
defined.update(name.lstrip("~").upper() for name in m.group("names").split())
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
m = _RX_PROMPT_CREDENTIALS.match(command)
|
|
407
|
+
if m:
|
|
408
|
+
defined.update((m.group("user").upper(), m.group("pw").upper()))
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
m = _RX_SUB_QUERYSTRING.match(command)
|
|
412
|
+
if m:
|
|
413
|
+
from urllib.parse import parse_qsl
|
|
414
|
+
|
|
415
|
+
defined.update(key.upper() for key, _value in parse_qsl(m.group("qstr")))
|
|
416
|
+
return
|
|
417
|
+
|
|
323
418
|
# SUB_INI bulk-defines from INI file — read keys at lint time
|
|
324
419
|
ini_m = _RX_SUB_INI.match(command)
|
|
325
420
|
if ini_m:
|
|
@@ -370,20 +465,13 @@ def _check_var_ref(
|
|
|
370
465
|
if sigil in ("@", "&", "~", "#", "+"):
|
|
371
466
|
return
|
|
372
467
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
# Built-in system variables
|
|
382
|
-
if name.upper() in _get_builtin_vars():
|
|
383
|
-
return
|
|
384
|
-
|
|
385
|
-
# User-defined via SUB
|
|
386
|
-
if name.upper() in defined_vars:
|
|
468
|
+
if sigil == "$":
|
|
469
|
+
# System variables: $ARG_N is set with -a/--assign-arg, $COUNTER_N by
|
|
470
|
+
# CounterVars, the rest by execsql itself. A SUB cannot define one.
|
|
471
|
+
if re.match(r"^(?:ARG|COUNTER)_\d+$", name, re.I) or name.upper() in _get_builtin_vars():
|
|
472
|
+
return
|
|
473
|
+
elif name.upper() in defined_vars:
|
|
474
|
+
# User variables, defined by a SUB-family metacommand or a prompt.
|
|
387
475
|
return
|
|
388
476
|
|
|
389
477
|
issues.append(
|
|
@@ -427,8 +515,8 @@ def _check_include_path(
|
|
|
427
515
|
#: Conditions whose value is fixed at parse time. ``IF(True)`` is a real and
|
|
428
516
|
#: reasonable thing to write while developing; leaving it in is what makes
|
|
429
517
|
#: every other branch dead code.
|
|
430
|
-
_RX_CONST_TRUE = re.compile(r"^\s*\(?\s*(?:true|
|
|
431
|
-
_RX_CONST_FALSE = re.compile(r"^\s*\(?\s*(?:false|
|
|
518
|
+
_RX_CONST_TRUE = re.compile(r"^\s*\(?\s*(?:true|yes)\s*\)?\s*$", re.I)
|
|
519
|
+
_RX_CONST_FALSE = re.compile(r"^\s*\(?\s*(?:false|no)\s*\)?\s*$", re.I)
|
|
432
520
|
|
|
433
521
|
#: Metacommands after which nothing in the same block can run.
|
|
434
522
|
_RX_TERMINAL = re.compile(r"^\s*HALT\b(?!\s+DISPLAY)", re.I)
|
|
@@ -482,6 +570,138 @@ def _check_constant_condition(node: IfBlock, issues: list[_Issue]) -> None:
|
|
|
482
570
|
)
|
|
483
571
|
|
|
484
572
|
|
|
573
|
+
def _check_split_dollar_quotes(nodes: list[Node], issues: list[_Issue]) -> None:
|
|
574
|
+
"""Report a dollar-quoted body that the script splits into several statements.
|
|
575
|
+
|
|
576
|
+
A statement ends at any line ending in ``;``, even inside ``$$ ... $$``,
|
|
577
|
+
so a function body is sent to the database in pieces: the first piece
|
|
578
|
+
fails, and with ERROR_HALT OFF the statements inside the body then run
|
|
579
|
+
on their own. One issue is reported where the body opens; the pieces
|
|
580
|
+
that follow until it closes are part of the same problem.
|
|
581
|
+
"""
|
|
582
|
+
from execsql.format import open_dollar_quote
|
|
583
|
+
|
|
584
|
+
open_tag: str | None = None
|
|
585
|
+
for node in nodes:
|
|
586
|
+
if not isinstance(node, SqlStatement):
|
|
587
|
+
continue
|
|
588
|
+
was_open = open_tag
|
|
589
|
+
open_tag = open_dollar_quote(node.text, open_tag)
|
|
590
|
+
if was_open is None and open_tag is not None:
|
|
591
|
+
issues.append(
|
|
592
|
+
_issue(
|
|
593
|
+
"P002",
|
|
594
|
+
node.span.file,
|
|
595
|
+
node.span.start_line,
|
|
596
|
+
f"dollar-quoted body {open_tag} is split at the first line ending in ';'; "
|
|
597
|
+
"put the statement between BEGIN SQL and END SQL",
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# A command holding a variable (!!v!!, !'!v!'!, !"!v!"! or deferred !{v}!) is
|
|
603
|
+
# only known after substitution at run time.
|
|
604
|
+
_RX_ANY_VAR = re.compile(r"!(['\"]?)![$&@~#+]?\w+!\1!|!\{[$&@~#+]?\w+\}!")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
#: Values a variable is tried as: a boolean (a whole condition), a number
|
|
608
|
+
#: (``ROW_COUNT_EQ(t, !!n!!)``) and a name (a table or column). Between them
|
|
609
|
+
#: they fit wherever a variable can stand in a condition or a metacommand.
|
|
610
|
+
_PLACEHOLDERS = ("TRUE", "1", "x")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _with_placeholders(text: str, value: str = "TRUE") -> str:
|
|
614
|
+
"""Replace each substitution variable with the literal *value*."""
|
|
615
|
+
return _RX_ANY_VAR.sub(value, text)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _placeholder_forms(text: str) -> list[str]:
|
|
619
|
+
"""*text* with its variables replaced by each of :data:`_PLACEHOLDERS` in turn.
|
|
620
|
+
|
|
621
|
+
What none of them makes valid is not valid with any value, such as
|
|
622
|
+
``!!flag!! = TRUE`` (there is no ``=`` comparison).
|
|
623
|
+
"""
|
|
624
|
+
return [_with_placeholders(text, value) for value in _PLACEHOLDERS]
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# Named groups that capture a condition in a metacommand's pattern: ASSERT,
|
|
628
|
+
# WAIT_UNTIL, and the WHILE / UNTIL of ON ... EXECUTE SCRIPT.
|
|
629
|
+
_CONDITION_GROUPS = ("condtest", "condition", "loopcond")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _check_metacommand(node: MetaCommandStatement, issues: list[_Issue]) -> None:
|
|
633
|
+
"""Report a metacommand that no dispatch-table entry matches, or whose condition does not parse.
|
|
634
|
+
|
|
635
|
+
``execsql run`` halts on such a command with "Unknown metacommand",
|
|
636
|
+
whether the keyword is misspelled or the arguments don't fit any form.
|
|
637
|
+
"""
|
|
638
|
+
match = _dispatch_table().get_match(node.command)
|
|
639
|
+
if match is None and _RX_ANY_VAR.search(node.command):
|
|
640
|
+
# A variable can stand for a keyword or a whole clause: try the
|
|
641
|
+
# command with placeholders, and judge it only if one matches.
|
|
642
|
+
match = next(
|
|
643
|
+
(m for form in _placeholder_forms(node.command) if (m := _dispatch_table().get_match(form)) is not None),
|
|
644
|
+
None,
|
|
645
|
+
)
|
|
646
|
+
if match is None:
|
|
647
|
+
return
|
|
648
|
+
if match is None:
|
|
649
|
+
issues.append(
|
|
650
|
+
_issue(
|
|
651
|
+
"P003",
|
|
652
|
+
node.span.file,
|
|
653
|
+
node.span.start_line,
|
|
654
|
+
f"unknown or malformed metacommand: {node.command.strip()}",
|
|
655
|
+
),
|
|
656
|
+
)
|
|
657
|
+
return
|
|
658
|
+
groups = match[1].groupdict()
|
|
659
|
+
for name in _CONDITION_GROUPS:
|
|
660
|
+
if groups.get(name):
|
|
661
|
+
_check_condition(groups[name], node.span.file, node.span.start_line, issues)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _check_condition(condition: str, source: str, line_no: int, issues: list[_Issue]) -> None:
|
|
665
|
+
"""Report a condition that the conditional-expression parser rejects.
|
|
666
|
+
|
|
667
|
+
Only parses: no test is evaluated, so nothing touches a database. A
|
|
668
|
+
condition with variables is reported only when it parses with none of
|
|
669
|
+
the placeholder values (:func:`_placeholder_forms`).
|
|
670
|
+
"""
|
|
671
|
+
from execsql.exceptions import CondParserError
|
|
672
|
+
from execsql.parser import CondParser
|
|
673
|
+
|
|
674
|
+
forms = _placeholder_forms(condition) if _RX_ANY_VAR.search(condition) else [condition]
|
|
675
|
+
for form in forms:
|
|
676
|
+
try:
|
|
677
|
+
CondParser(form, _conditional_table()).parse()
|
|
678
|
+
except (CondParserError, RecursionError): # RecursionError: nested past the parser's depth
|
|
679
|
+
continue
|
|
680
|
+
return
|
|
681
|
+
issues.append(
|
|
682
|
+
_issue("P004", source, line_no, f"unknown or malformed condition: {condition.strip()}"),
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _check_modifiers(modifiers: list[ConditionModifier], issues: list[_Issue]) -> None:
|
|
687
|
+
for mod in modifiers:
|
|
688
|
+
_check_condition(mod.condition, mod.span.file, mod.span.start_line, issues)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _check_block_conditions(node: Node, issues: list[_Issue]) -> None:
|
|
692
|
+
"""Check the conditions an IF (with its ANDIF / ORIF / ELSEIF), LOOP or EXECUTE SCRIPT carries."""
|
|
693
|
+
if isinstance(node, IfBlock):
|
|
694
|
+
_check_condition(node.condition, node.span.file, node.span.start_line, issues)
|
|
695
|
+
_check_modifiers(node.condition_modifiers, issues)
|
|
696
|
+
for clause in node.elseif_clauses:
|
|
697
|
+
_check_condition(clause.condition, clause.span.file, clause.span.start_line, issues)
|
|
698
|
+
_check_modifiers(clause.condition_modifiers, issues)
|
|
699
|
+
elif isinstance(node, LoopBlock):
|
|
700
|
+
_check_condition(node.condition, node.span.file, node.span.start_line, issues)
|
|
701
|
+
elif isinstance(node, IncludeDirective) and node.loop_condition:
|
|
702
|
+
_check_condition(node.loop_condition, node.span.file, node.span.start_line, issues)
|
|
703
|
+
|
|
704
|
+
|
|
485
705
|
def _check_unreachable_after_halt(nodes: list[Node], issues: list[_Issue]) -> None:
|
|
486
706
|
"""Report statements that follow an unconditional HALT in the same block.
|
|
487
707
|
|
|
@@ -507,12 +727,18 @@ def _check_unreachable_after_halt(nodes: list[Node], issues: list[_Issue]) -> No
|
|
|
507
727
|
return
|
|
508
728
|
|
|
509
729
|
|
|
730
|
+
# The conditional tests that read a variable by its bare name.
|
|
731
|
+
_RX_VAR_TEST = re.compile(r"\bSUB_(?:DEFINED|EMPTY)\s*\(\s*([$&@~#]?\w+)\s*\)", re.I)
|
|
732
|
+
|
|
733
|
+
|
|
510
734
|
def _collect_var_references(nodes: list[Node], seen: set[str]) -> None:
|
|
511
|
-
"""Record every
|
|
735
|
+
"""Record every variable read anywhere beneath *nodes*: ``!!var!!``, or ``sub_defined(var)`` / ``sub_empty(var)``."""
|
|
512
736
|
for node in nodes:
|
|
513
737
|
for text in _referencing_text(node):
|
|
514
738
|
for m in _RX_VAR_REF.finditer(text):
|
|
515
739
|
seen.add(m.group(1).lstrip("$@&~#+").upper())
|
|
740
|
+
for m in _RX_VAR_TEST.finditer(text):
|
|
741
|
+
seen.add(m.group(1).lstrip("$@&~#+").upper())
|
|
516
742
|
for child in node.children():
|
|
517
743
|
_collect_var_references([child], seen)
|
|
518
744
|
|
|
@@ -591,10 +817,12 @@ def _lint_nodes(
|
|
|
591
817
|
visited_scripts = set()
|
|
592
818
|
|
|
593
819
|
_check_unreachable_after_halt(nodes, issues)
|
|
820
|
+
_check_split_dollar_quotes(nodes, issues)
|
|
594
821
|
|
|
595
822
|
for node in nodes:
|
|
596
823
|
src = node.span.file
|
|
597
824
|
lno = node.span.start_line
|
|
825
|
+
_check_block_conditions(node, issues)
|
|
598
826
|
|
|
599
827
|
# -- Variable references in SQL --
|
|
600
828
|
if isinstance(node, SqlStatement):
|
|
@@ -603,6 +831,7 @@ def _lint_nodes(
|
|
|
603
831
|
|
|
604
832
|
# -- Metacommand checks --
|
|
605
833
|
elif isinstance(node, MetaCommandStatement):
|
|
834
|
+
_check_metacommand(node, issues)
|
|
606
835
|
for m in _RX_VAR_REF.finditer(node.command):
|
|
607
836
|
_check_var_ref(m.group(1), src, lno, defined_vars, issues)
|
|
608
837
|
|
|
@@ -711,6 +940,20 @@ def lint(
|
|
|
711
940
|
return issues
|
|
712
941
|
|
|
713
942
|
|
|
943
|
+
def lint_source(source: str, label: str, script_path: str | None = None) -> list[_Issue]:
|
|
944
|
+
"""Parse *source* and lint it, unfiltered.
|
|
945
|
+
|
|
946
|
+
Each block-structure error (an IF without ENDIF, a stray END LOOP, ...)
|
|
947
|
+
is a ``P001`` at its own line. The parser recovers from them, so every
|
|
948
|
+
other rule still runs on the rest of the script.
|
|
949
|
+
"""
|
|
950
|
+
from execsql.script.parser import parse_string
|
|
951
|
+
|
|
952
|
+
errors: list[ErrInfo] = []
|
|
953
|
+
tree = parse_string(source, label, errors=errors)
|
|
954
|
+
return [parse_error(label, exc) for exc in errors] + lint(tree, script_path=script_path)
|
|
955
|
+
|
|
956
|
+
|
|
714
957
|
# ---------------------------------------------------------------------------
|
|
715
958
|
# Selecting rules
|
|
716
959
|
# ---------------------------------------------------------------------------
|
execsql/cli/options.py
CHANGED
|
@@ -50,7 +50,7 @@ DsnOpt = Annotated[
|
|
|
50
50
|
metavar="URL",
|
|
51
51
|
help=(
|
|
52
52
|
"Database connection URL, e.g. postgresql://user:pass@host:5432/db. "
|
|
53
|
-
"Supported schemes: postgresql, mysql, mssql, oracle, firebird, sqlite, duckdb. "
|
|
53
|
+
"Supported schemes: postgresql (postgres), mysql (mariadb), mssql (sqlserver), oracle, firebird, sqlite, duckdb. "
|
|
54
54
|
"Overrides -t/-u/-p and positional server/db args."
|
|
55
55
|
),
|
|
56
56
|
),
|
execsql/cli/run.py
CHANGED
|
@@ -514,7 +514,8 @@ def _setup_logging(
|
|
|
514
514
|
opts_dict = {
|
|
515
515
|
k: v
|
|
516
516
|
for k, v in {
|
|
517
|
-
|
|
517
|
+
# Count only: -a values are opaque and often secrets.
|
|
518
|
+
"sub_vars": f"{len(sub_vars)} value(s)" if sub_vars else None,
|
|
518
519
|
"boolean_int": boolean_int,
|
|
519
520
|
"make_dirs": make_dirs,
|
|
520
521
|
"database_encoding": database_encoding,
|
|
@@ -535,7 +536,7 @@ def _setup_logging(
|
|
|
535
536
|
}
|
|
536
537
|
logger = Logger(
|
|
537
538
|
script_name or "<inline>",
|
|
538
|
-
conf.db or "",
|
|
539
|
+
conf.db or conf.db_file or "",
|
|
539
540
|
conf.server,
|
|
540
541
|
opts_dict,
|
|
541
542
|
conf.user_logfile,
|
|
@@ -552,6 +553,9 @@ def _setup_logging(
|
|
|
552
553
|
)
|
|
553
554
|
|
|
554
555
|
subvars.add_substitution("$RUN_ID", logger.run_id)
|
|
556
|
+
# Passwords from the DSN or config files, known before the first connection.
|
|
557
|
+
logger.add_redaction_value(getattr(conf, "db_password", None))
|
|
558
|
+
logger.add_redaction_value(getattr(conf, "smtp_password", None))
|
|
555
559
|
|
|
556
560
|
if sub_vars:
|
|
557
561
|
for n, repl in enumerate(sub_vars):
|
|
@@ -740,27 +744,13 @@ def _run(
|
|
|
740
744
|
manifest = _manifest.start(manifest_path, script_name, variables)
|
|
741
745
|
manifest.config_files = list(conf.files_read)
|
|
742
746
|
|
|
743
|
-
import execsql.utils.fileio as _fileio
|
|
744
|
-
|
|
745
747
|
if _state.filewriter is None or not _state.filewriter.is_alive():
|
|
746
|
-
|
|
747
|
-
# can't leak responses or unconsumed commands into the new one. On
|
|
748
|
-
# macOS (`spawn`) the OS pipe buffer is small enough that retained
|
|
749
|
-
# entries from a crashed writer would deadlock the next put().
|
|
750
|
-
for q in (_fileio.fw_input, _fileio.fw_output):
|
|
751
|
-
try:
|
|
752
|
-
while True:
|
|
753
|
-
q.get_nowait()
|
|
754
|
-
except Exception:
|
|
755
|
-
pass
|
|
756
|
-
_fileio.filewriter = _state.filewriter = FileWriter(
|
|
757
|
-
_fileio.fw_input,
|
|
758
|
-
_fileio.fw_output,
|
|
748
|
+
_state.filewriter = FileWriter(
|
|
759
749
|
file_encoding=conf.output_encoding,
|
|
760
750
|
open_timeout=getattr(conf, "outfile_open_timeout", 10),
|
|
761
751
|
)
|
|
762
752
|
_state.filewriter.start()
|
|
763
|
-
atexit.register(filewriter_end)
|
|
753
|
+
atexit.register(filewriter_end, _state.filewriter)
|
|
764
754
|
|
|
765
755
|
# ------------------------------------------------------------------
|
|
766
756
|
# Logging
|
|
@@ -856,6 +846,28 @@ def _run(
|
|
|
856
846
|
# ---------------------------------------------------------------------------
|
|
857
847
|
|
|
858
848
|
|
|
849
|
+
def _end_run_on_sigterm() -> None:
|
|
850
|
+
"""Make SIGTERM end the process through ``SystemExit`` (exit status 143).
|
|
851
|
+
|
|
852
|
+
Python's default SIGTERM action stops the process without running
|
|
853
|
+
``atexit`` handlers, so the run's log (buffered until close) was lost,
|
|
854
|
+
the open transaction was not rolled back explicitly, and a running
|
|
855
|
+
``SYSTEM_CMD`` was left behind. ``timeout``, ``docker stop``, systemd
|
|
856
|
+
and CI cancellation all send SIGTERM. Installed by the console entry
|
|
857
|
+
point only: ``execsql.run()`` leaves the host program's signals alone.
|
|
858
|
+
"""
|
|
859
|
+
import signal
|
|
860
|
+
|
|
861
|
+
def _terminate(signum: int, _frame: Any) -> None:
|
|
862
|
+
import execsql.state as _state
|
|
863
|
+
|
|
864
|
+
if _state.exec_log is not None:
|
|
865
|
+
_state.exec_log.log_exit_terminated()
|
|
866
|
+
raise SystemExit(128 + signum)
|
|
867
|
+
|
|
868
|
+
signal.signal(signal.SIGTERM, _terminate)
|
|
869
|
+
|
|
870
|
+
|
|
859
871
|
def _execute_script_ast(
|
|
860
872
|
tree: Any,
|
|
861
873
|
conf: ConfigData,
|
|
@@ -883,7 +895,8 @@ def _execute_script_ast(
|
|
|
883
895
|
try:
|
|
884
896
|
execute(tree)
|
|
885
897
|
except SystemExit as exc:
|
|
886
|
-
|
|
898
|
+
terminated = _state.exec_log is not None and _state.exec_log.exit_type == "terminated"
|
|
899
|
+
if gui_console_isrunning() and conf.gui_wait_on_exit and not terminated:
|
|
887
900
|
gui_console_wait_user(
|
|
888
901
|
"Script complete; close the console window to exit execsql.",
|
|
889
902
|
)
|
|
@@ -895,6 +908,9 @@ def _execute_script_ast(
|
|
|
895
908
|
sys.exit(exc.code)
|
|
896
909
|
except ConfigError:
|
|
897
910
|
raise
|
|
911
|
+
except KeyboardInterrupt:
|
|
912
|
+
_state.exec_log.log_exit_interrupted()
|
|
913
|
+
raise
|
|
898
914
|
except ErrInfo as exc:
|
|
899
915
|
from execsql.utils.errors import exit_now
|
|
900
916
|
|
|
@@ -908,6 +924,7 @@ def _execute_script_ast(
|
|
|
908
924
|
exit_now(1, ErrInfo("exception", exception_msg=msg))
|
|
909
925
|
|
|
910
926
|
_state.dbs.do_rollback = False
|
|
927
|
+
_flush_output_files()
|
|
911
928
|
if gui_console_isrunning() and conf.gui_wait_on_exit:
|
|
912
929
|
gui_console_wait_user(
|
|
913
930
|
"Script complete; close the console window to exit execsql.",
|
|
@@ -970,10 +987,30 @@ def _execute_script_textual_console(tree: Any, conf: ConfigData) -> None:
|
|
|
970
987
|
exit_now(1, ErrInfo("exception", exception_msg=msg))
|
|
971
988
|
|
|
972
989
|
_state.dbs.do_rollback = False
|
|
990
|
+
_flush_output_files()
|
|
973
991
|
_state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
|
|
974
992
|
_state.exec_log.log_exit_end()
|
|
975
993
|
|
|
976
994
|
|
|
995
|
+
def _flush_output_files() -> None:
|
|
996
|
+
"""Flush file output at the normal end of a script; lost output fails the run.
|
|
997
|
+
|
|
998
|
+
A WRITE/EXPORT target that stays locked past ``outfile_open_timeout`` has
|
|
999
|
+
its output discarded. That must not end in a successful exit status.
|
|
1000
|
+
"""
|
|
1001
|
+
from execsql.utils.fileio import filewriter_close_all_after_write
|
|
1002
|
+
|
|
1003
|
+
try:
|
|
1004
|
+
filewriter_close_all_after_write()
|
|
1005
|
+
except ErrInfo as exc:
|
|
1006
|
+
from execsql.utils.errors import exit_now
|
|
1007
|
+
|
|
1008
|
+
# The loss belongs to no particular statement; keep exit_now from
|
|
1009
|
+
# attributing it to whatever line happened to run last.
|
|
1010
|
+
exc.script_file = ""
|
|
1011
|
+
exit_now(1, exc)
|
|
1012
|
+
|
|
1013
|
+
|
|
977
1014
|
# ---------------------------------------------------------------------------
|
|
978
1015
|
# Utility: build the database connection for the initial/default database
|
|
979
1016
|
# ---------------------------------------------------------------------------
|
execsql/config.py
CHANGED
|
@@ -372,6 +372,8 @@ class ConfigData:
|
|
|
372
372
|
self._get_str(cp, self._EMAIL_SECTION, "password", "smtp_password")
|
|
373
373
|
self._get_bool(cp, self._EMAIL_SECTION, "use_ssl", "smtp_ssl")
|
|
374
374
|
self._get_bool(cp, self._EMAIL_SECTION, "use_tls", "smtp_tls")
|
|
375
|
+
self._get_bool(cp, self._EMAIL_SECTION, "verify_certificate", "smtp_verify_certificate")
|
|
376
|
+
self._get_str(cp, self._EMAIL_SECTION, "ca_file", "smtp_ca_file")
|
|
375
377
|
self._get_str(cp, self._EMAIL_SECTION, "message_css", "email_css")
|
|
376
378
|
# --- [format] and [lint]: read by `execsql format` / `execsql lint`; a run ignores them ---
|
|
377
379
|
self._get_int(cp, self._FORMAT_SECTION, "indent", "format_indent")
|
|
@@ -523,6 +525,8 @@ class ConfigData:
|
|
|
523
525
|
self.smtp_password: str | None = None
|
|
524
526
|
self.smtp_ssl = False
|
|
525
527
|
self.smtp_tls = False
|
|
528
|
+
self.smtp_verify_certificate = True
|
|
529
|
+
self.smtp_ca_file: str | None = None
|
|
526
530
|
self.email_format = "plain"
|
|
527
531
|
self.email_css: str | None = None
|
|
528
532
|
self.include_req: list = []
|
|
@@ -727,13 +731,12 @@ class WriteHooks:
|
|
|
727
731
|
"""
|
|
728
732
|
|
|
729
733
|
def __repr__(self) -> str:
|
|
730
|
-
return f"WriteHooks({self.write_func!r}, {self.err_func!r}
|
|
734
|
+
return f"WriteHooks({self.write_func!r}, {self.err_func!r})"
|
|
731
735
|
|
|
732
736
|
def __init__(
|
|
733
737
|
self,
|
|
734
738
|
standard_output_func: Callable[[str], object] | None = None,
|
|
735
739
|
error_output_func: Callable[[str], object] | None = None,
|
|
736
|
-
status_output_func: Callable[[str], object] | None = None,
|
|
737
740
|
) -> None:
|
|
738
741
|
"""Store optional hook callables; ``None`` means use the default stream.
|
|
739
742
|
|
|
@@ -742,8 +745,6 @@ class WriteHooks:
|
|
|
742
745
|
``None`` to use ``sys.stdout``.
|
|
743
746
|
error_output_func: Callable to receive error-output text, or
|
|
744
747
|
``None`` to use ``sys.stderr``.
|
|
745
|
-
status_output_func: Callable to receive status-line text, or
|
|
746
|
-
``None`` to suppress.
|
|
747
748
|
"""
|
|
748
749
|
# Arguments should be functions that take a single string and
|
|
749
750
|
# write it to the desired destination. Both stdout and stderr can be hooked.
|
|
@@ -752,7 +753,6 @@ class WriteHooks:
|
|
|
752
753
|
# The purpose is to allow writing to be redirected to a GUI.
|
|
753
754
|
self.write_func = standard_output_func
|
|
754
755
|
self.err_func = error_output_func
|
|
755
|
-
self.status_func = status_output_func
|
|
756
756
|
self.tee_stderr = True
|
|
757
757
|
|
|
758
758
|
def reset(self) -> None:
|
|
@@ -770,15 +770,6 @@ class WriteHooks:
|
|
|
770
770
|
self.err_func = error_output_func
|
|
771
771
|
self.tee_stderr = tee
|
|
772
772
|
|
|
773
|
-
def redir(
|
|
774
|
-
self,
|
|
775
|
-
standard_output_func: Callable[[str], object],
|
|
776
|
-
error_output_func: Callable[[str], object],
|
|
777
|
-
) -> None:
|
|
778
|
-
"""Redirect both stdout and stderr hooks in one call."""
|
|
779
|
-
self.redir_stdout(standard_output_func)
|
|
780
|
-
self.redir_stderr(error_output_func)
|
|
781
|
-
|
|
782
773
|
def write(self, strval: str) -> None:
|
|
783
774
|
"""Write a string to the standard-output hook, or to sys.stdout if unset."""
|
|
784
775
|
if self.write_func:
|
|
@@ -799,8 +790,3 @@ class WriteHooks:
|
|
|
799
790
|
else:
|
|
800
791
|
sys.stderr.write(strval)
|
|
801
792
|
sys.stderr.flush()
|
|
802
|
-
|
|
803
|
-
def write_status(self, strval: str) -> None:
|
|
804
|
-
"""Forward a status string to the status hook if one is registered."""
|
|
805
|
-
if self.status_func:
|
|
806
|
-
self.status_func(strval)
|