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.
Files changed (72) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +2 -0
  3. execsql/cli/commands/lint.py +2 -10
  4. execsql/cli/commands/run.py +35 -27
  5. execsql/cli/dsn.py +11 -4
  6. execsql/cli/lint.py +271 -28
  7. execsql/cli/options.py +1 -1
  8. execsql/cli/run.py +56 -19
  9. execsql/config.py +5 -19
  10. execsql/data/execsql.conf.template +14 -4
  11. execsql/db/access.py +29 -8
  12. execsql/db/base.py +40 -8
  13. execsql/db/dsn.py +13 -4
  14. execsql/db/duckdb.py +3 -1
  15. execsql/db/factory.py +11 -2
  16. execsql/db/firebird.py +21 -7
  17. execsql/db/mysql.py +37 -16
  18. execsql/db/oracle.py +49 -16
  19. execsql/db/postgres.py +30 -6
  20. execsql/db/sqlite.py +7 -9
  21. execsql/db/sqlserver.py +25 -8
  22. execsql/exporters/delimited.py +87 -28
  23. execsql/exporters/xlsx.py +24 -9
  24. execsql/exporters/zip.py +1 -2
  25. execsql/format.py +29 -2
  26. execsql/gui/desktop.py +1 -3
  27. execsql/gui/tui.py +2 -2
  28. execsql/importers/base.py +35 -1
  29. execsql/importers/csv.py +3 -1
  30. execsql/manifest.py +33 -22
  31. execsql/metacommands/conditions.py +6 -7
  32. execsql/metacommands/data.py +4 -1
  33. execsql/metacommands/debug.py +2 -4
  34. execsql/metacommands/io_fileops.py +62 -37
  35. execsql/metacommands/io_import.py +0 -1
  36. execsql/metacommands/prompt.py +2 -0
  37. execsql/metacommands/upsert.py +5 -2
  38. execsql/models.py +3 -2
  39. execsql/parser.py +14 -5
  40. execsql/plugins.py +24 -0
  41. execsql/script/engine.py +40 -11
  42. execsql/script/executor.py +9 -5
  43. execsql/script/parser.py +167 -118
  44. execsql/state.py +23 -9
  45. execsql/state.pyi +8 -0
  46. execsql/types.py +22 -13
  47. execsql/utils/auth.py +169 -24
  48. execsql/utils/datetime.py +55 -17
  49. execsql/utils/errors.py +71 -9
  50. execsql/utils/fileio.py +404 -117
  51. execsql/utils/gui.py +3 -0
  52. execsql/utils/mail.py +37 -12
  53. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/METADATA +18 -12
  54. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/RECORD +72 -72
  55. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +0 -0
  56. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  57. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  58. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  59. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  60. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  61. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  62. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  63. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  64. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  65. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  66. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  67. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  68. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  69. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  70. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/entry_points.txt +0 -0
  71. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  72. {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** — the AST parser rejects unmatched IF / LOOP /
11
- BATCH / SCRIPT blocks at parse time with precise source spans;
12
- ``cli/__init__.py`` reports any parse failure as a lint error before
13
- :func:`lint` is even called.
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>\w+)\s", re.I)
198
- _RX_SUB_DECRYPT = re.compile(r"^\s*SUB_DECRYPT\s+(?P<name>\w+)\s+", re.I)
199
- _RX_SUB_ENCRYPT = re.compile(r"^\s*SUB_ENCRYPT\s+(?P<name>\w+)\s+", re.I)
200
- _RX_SUB_QUERYSTRING = re.compile(r"^\s*SUB_QUERYSTRING\s+(?P<name>\w+)\s+", re.I)
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
- """Extract variable name from a SUB-family metacommand into *defined*."""
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
- _RX_SUB_QUERYSTRING,
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
- # $ARG_N is set via -a/--assign-arg at invocation time
374
- if re.match(r"^ARG_\d+$", name, re.I):
375
- return
376
-
377
- # $COUNTER_N is managed by CounterVars
378
- if re.match(r"^COUNTER_\d+$", name, re.I):
379
- return
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|1\s*=\s*1|yes)\s*\)?\s*$", re.I)
431
- _RX_CONST_FALSE = re.compile(r"^\s*\(?\s*(?:false|0\s*=\s*1|1\s*=\s*0|no)\s*\)?\s*$", re.I)
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 ``!!var!!`` reference anywhere beneath *nodes*."""
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
- "sub_vars": sub_vars,
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
- # Drain stale messages from the queues so a previously-dead subprocess
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
- if gui_console_isrunning() and conf.gui_wait_on_exit:
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}, {self.status_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)