execsql2 2.23.1__py3-none-any.whl → 2.24.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 +17 -586
- execsql/cli/application.py +309 -0
- execsql/cli/commands/__init__.py +13 -0
- execsql/cli/commands/config.py +205 -0
- execsql/cli/commands/format.py +100 -0
- execsql/cli/commands/init.py +135 -0
- execsql/cli/commands/lint.py +233 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +451 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +513 -76
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +41 -14
- execsql/config.py +251 -154
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +24 -1
- execsql/data/script_header.sql +23 -0
- execsql/db/factory.py +2 -2
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/format.py +87 -75
- execsql/interactive.py +232 -0
- execsql/manifest.py +189 -0
- execsql/metacommands/conditions.py +1 -1
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/effects.py +124 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +6 -93
- execsql/script/executor.py +153 -34
- execsql/state.py +11 -2
- execsql/state.pyi +3 -0
- execsql/utils/color.py +18 -0
- execsql/utils/errors.py +11 -1
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/RECORD +55 -39
- execsql2-2.24.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.1.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Check execsql.conf files and report every problem with its file and line.
|
|
2
|
+
|
|
3
|
+
A run reads config files with :class:`~execsql.config.ConfigData`, which stops
|
|
4
|
+
at the first invalid value and silently ignores anything it does not
|
|
5
|
+
recognize — a misspelled key or section simply does nothing. This module
|
|
6
|
+
backs ``execsql config --validate``: it reads the same files in the same order
|
|
7
|
+
and reports all of it.
|
|
8
|
+
|
|
9
|
+
Values are checked by :meth:`ConfigData._apply_file`, the method a run uses,
|
|
10
|
+
applied to one option at a time on a fresh instance. Validation therefore
|
|
11
|
+
cannot accept a value a run rejects, or reject one a run accepts.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import configparser
|
|
17
|
+
import difflib
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import warnings
|
|
22
|
+
from collections import deque
|
|
23
|
+
from collections.abc import Iterable
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from execsql.config import ConfigData, describe_parse_error
|
|
29
|
+
from execsql.exceptions import ConfigError
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from execsql.config import _VariablePool
|
|
33
|
+
|
|
34
|
+
__all__ = ["ConfigProblem", "known_options", "validate_config"]
|
|
35
|
+
|
|
36
|
+
#: Sections whose keys are free-form: variable names, and numbered include files.
|
|
37
|
+
_FREE_SECTIONS = frozenset({"variables", "include_required", "include_optional", configparser.DEFAULTSECT})
|
|
38
|
+
|
|
39
|
+
#: Keys that chain to another config file, and the platform each applies on.
|
|
40
|
+
_CHAIN_KEYS = {
|
|
41
|
+
"config_file": None,
|
|
42
|
+
"linux_config_file": "linux",
|
|
43
|
+
"macos_config_file": "darwin",
|
|
44
|
+
"win_config_file": "win32",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ConfigProblem:
|
|
50
|
+
"""One problem in one config file.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
file: The config file.
|
|
54
|
+
line: 1-based line number, or ``0`` when the problem has no single line.
|
|
55
|
+
severity: ``"error"`` — a run fails on it — or ``"warning"`` — a run
|
|
56
|
+
silently ignores it.
|
|
57
|
+
message: What is wrong, and the likely fix when there is one.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
file: str
|
|
61
|
+
line: int
|
|
62
|
+
severity: str
|
|
63
|
+
message: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def known_options() -> dict[str, set[str]]:
|
|
67
|
+
"""Every section execsql reads, and the keys it reads in each.
|
|
68
|
+
|
|
69
|
+
Free-form sections (``[variables]`` and the include sections) map to an
|
|
70
|
+
empty set: any key is accepted there.
|
|
71
|
+
"""
|
|
72
|
+
probe = ConfigData.__new__(ConfigData)
|
|
73
|
+
probe._set_defaults()
|
|
74
|
+
probe._read_known_options(configparser.ConfigParser()) # fills ConfigData._option_keys
|
|
75
|
+
known: dict[str, set[str]] = {}
|
|
76
|
+
for section, key, _attr in ConfigData._option_keys:
|
|
77
|
+
known.setdefault(section, set()).add(key)
|
|
78
|
+
known.setdefault(ConfigData._CONFIG_SECTION, set()).update(_CHAIN_KEYS)
|
|
79
|
+
for section in _FREE_SECTIONS - {configparser.DEFAULTSECT}:
|
|
80
|
+
known[section] = set()
|
|
81
|
+
return known
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _line_numbers(text: str) -> tuple[dict[str, int], dict[tuple[str, str], int]]:
|
|
85
|
+
"""Where each section header and each key is, by 1-based line number.
|
|
86
|
+
|
|
87
|
+
Keys are lower-cased, as :class:`configparser.ConfigParser` does.
|
|
88
|
+
Continuation lines (indented) and comments are skipped.
|
|
89
|
+
"""
|
|
90
|
+
sections: dict[str, int] = {}
|
|
91
|
+
keys: dict[tuple[str, str], int] = {}
|
|
92
|
+
current: str | None = None
|
|
93
|
+
for number, raw in enumerate(text.splitlines(), 1):
|
|
94
|
+
line = raw.strip()
|
|
95
|
+
if not line or line[0] in "#;":
|
|
96
|
+
continue
|
|
97
|
+
header = re.fullmatch(r"\[(.+)\]", line)
|
|
98
|
+
if header:
|
|
99
|
+
current = header.group(1)
|
|
100
|
+
sections.setdefault(current, number)
|
|
101
|
+
continue
|
|
102
|
+
if raw[0].isspace() or current is None:
|
|
103
|
+
continue
|
|
104
|
+
option = re.match(r"([^=:]+?)\s*[=:]", line)
|
|
105
|
+
if option:
|
|
106
|
+
keys[(current, option.group(1).strip().lower())] = number
|
|
107
|
+
return sections, keys
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _suggest(word: str, candidates: Iterable[str]) -> str | None:
|
|
111
|
+
match = difflib.get_close_matches(word, sorted(candidates), n=1, cutoff=0.7)
|
|
112
|
+
return match[0] if match else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _check_value(
|
|
116
|
+
section: str,
|
|
117
|
+
key: str,
|
|
118
|
+
value: str,
|
|
119
|
+
variable_pool: _VariablePool,
|
|
120
|
+
current_script: str,
|
|
121
|
+
) -> str | None:
|
|
122
|
+
"""Apply one option the way a run would; return why it fails, or ``None``."""
|
|
123
|
+
single = configparser.ConfigParser()
|
|
124
|
+
single.read_dict({section: {key: value.replace("%", "%%")}})
|
|
125
|
+
probe = ConfigData.__new__(ConfigData)
|
|
126
|
+
probe._set_defaults()
|
|
127
|
+
try:
|
|
128
|
+
with warnings.catch_warnings():
|
|
129
|
+
warnings.simplefilter("ignore") # enc_password's deprecation notice
|
|
130
|
+
probe._apply_file(single, variable_pool, current_script)
|
|
131
|
+
except ConfigError as exc:
|
|
132
|
+
return str(exc)
|
|
133
|
+
except ValueError as exc:
|
|
134
|
+
if section in ("include_required", "include_optional"):
|
|
135
|
+
return f"keys in [{section}] must be numbers, not {key!r}"
|
|
136
|
+
return str(exc)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
return f"{type(exc).__name__}: {exc}"
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_rule_codes(value: str) -> str | None:
|
|
143
|
+
"""Rule codes in ``[lint] select`` / ``ignore``, checked as ``execsql lint`` checks them."""
|
|
144
|
+
from execsql.cli.lint import resolve_selectors
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
resolve_selectors([value])
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
return str(exc)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_secret(key: str) -> bool:
|
|
154
|
+
"""Whether *key* holds a password (``password``, ``enc_password``, ``passwd``)."""
|
|
155
|
+
return "passw" in key.lower()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_file(
|
|
159
|
+
path: str,
|
|
160
|
+
known: dict[str, set[str]],
|
|
161
|
+
variable_pool: _VariablePool,
|
|
162
|
+
current_script: str,
|
|
163
|
+
) -> tuple[list[ConfigProblem], list[str]]:
|
|
164
|
+
"""Every problem in one file, and the config files it chains to (in queue order)."""
|
|
165
|
+
problems: list[ConfigProblem] = []
|
|
166
|
+
|
|
167
|
+
def problem(line: int, severity: str, message: str) -> None:
|
|
168
|
+
problems.append(ConfigProblem(path, line, severity, message))
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
with open(path) as f: # the encoding ConfigParser.read uses
|
|
172
|
+
text = f.read()
|
|
173
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
174
|
+
problem(0, "error", f"cannot read: {exc}")
|
|
175
|
+
return problems, []
|
|
176
|
+
|
|
177
|
+
cp = configparser.ConfigParser()
|
|
178
|
+
try:
|
|
179
|
+
cp.read_string(text, source=path)
|
|
180
|
+
except configparser.ParsingError as exc:
|
|
181
|
+
if isinstance(exc, configparser.MissingSectionHeaderError):
|
|
182
|
+
line, text = describe_parse_error(exc)
|
|
183
|
+
problem(line, "error", text)
|
|
184
|
+
else: # every unparsable line, not only the first
|
|
185
|
+
for err_line, err_text in exc.errors:
|
|
186
|
+
problem(err_line, "error", f"cannot parse line: {err_text.strip()}")
|
|
187
|
+
return problems, []
|
|
188
|
+
except configparser.Error as exc:
|
|
189
|
+
line, text = describe_parse_error(exc)
|
|
190
|
+
problem(line, "error", text)
|
|
191
|
+
return problems, []
|
|
192
|
+
|
|
193
|
+
section_lines, key_lines = _line_numbers(text)
|
|
194
|
+
|
|
195
|
+
for section in cp.sections():
|
|
196
|
+
if section not in known:
|
|
197
|
+
hint = _suggest(section, known)
|
|
198
|
+
problem(
|
|
199
|
+
section_lines.get(section, 0),
|
|
200
|
+
"warning",
|
|
201
|
+
f"unknown section [{section}]" + (f"; did you mean [{hint}]?" if hint else "; a run ignores it"),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
chained: list[str] = []
|
|
205
|
+
for (section, key), line in key_lines.items():
|
|
206
|
+
if section not in known and section != configparser.DEFAULTSECT:
|
|
207
|
+
continue # reported once, as an unknown section
|
|
208
|
+
if section not in _FREE_SECTIONS and key not in known[section]:
|
|
209
|
+
elsewhere = sorted(s for s, keys in known.items() if key in keys)
|
|
210
|
+
if elsewhere:
|
|
211
|
+
message = f"{key} belongs in [{elsewhere[0]}], not [{section}]"
|
|
212
|
+
else:
|
|
213
|
+
hint = _suggest(key, known[section])
|
|
214
|
+
message = f"unknown key {key!r} in [{section}]" + (
|
|
215
|
+
f"; did you mean {hint!r}?" if hint else "; a run ignores it"
|
|
216
|
+
)
|
|
217
|
+
problem(line, "warning", message)
|
|
218
|
+
continue
|
|
219
|
+
if section == configparser.DEFAULTSECT:
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
value = cp.get(section, key)
|
|
223
|
+
except configparser.Error as exc:
|
|
224
|
+
problem(line, "error", describe_parse_error(exc)[1])
|
|
225
|
+
continue
|
|
226
|
+
if key in _CHAIN_KEYS:
|
|
227
|
+
platform = _CHAIN_KEYS[key]
|
|
228
|
+
if platform and not sys.platform.startswith(platform):
|
|
229
|
+
continue # another platform's file; a run here never reads it
|
|
230
|
+
expand_home = os.name == "posix" if key == "config_file" else True
|
|
231
|
+
target = ConfigData._chain_target(value, variable_pool, expand_home=expand_home)
|
|
232
|
+
if Path(target).is_file():
|
|
233
|
+
chained.append(target)
|
|
234
|
+
else:
|
|
235
|
+
problem(line, "warning", f"{key} = {value}: no such file; a run skips it")
|
|
236
|
+
continue
|
|
237
|
+
failure = _check_value(section, key, value, variable_pool, current_script)
|
|
238
|
+
if failure is None and section == ConfigData._LINT_SECTION:
|
|
239
|
+
failure = _check_rule_codes(value)
|
|
240
|
+
if failure:
|
|
241
|
+
if _is_secret(key):
|
|
242
|
+
# Never echo a password, even an obfuscated one, into logs.
|
|
243
|
+
failure = failure.replace(value, "***") if value else failure
|
|
244
|
+
value = "***"
|
|
245
|
+
problem(line, "error", f"{key} = {value}: {failure}")
|
|
246
|
+
|
|
247
|
+
return problems, chained
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def validate_config(
|
|
251
|
+
script_path: str,
|
|
252
|
+
variable_pool: _VariablePool,
|
|
253
|
+
config_file: str | None = None,
|
|
254
|
+
) -> tuple[list[str], list[ConfigProblem]]:
|
|
255
|
+
"""Check every config file a run from *script_path* would read.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
script_path: Directory of the script a run would execute; its
|
|
259
|
+
``execsql.conf`` is included, as for a run.
|
|
260
|
+
variable_pool: Substitution variables for expanding chained file paths
|
|
261
|
+
and checking ``[variables]`` names.
|
|
262
|
+
config_file: The file ``--config`` names, read last.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The files checked, in the order a run reads them, and every problem
|
|
266
|
+
found, in file order and then line order.
|
|
267
|
+
"""
|
|
268
|
+
known = known_options()
|
|
269
|
+
current_script = str(Path(sys.argv[0]).resolve())
|
|
270
|
+
queue: deque[str] = deque(ConfigData._search_paths(script_path, config_file))
|
|
271
|
+
checked: list[str] = []
|
|
272
|
+
problems: list[ConfigProblem] = []
|
|
273
|
+
while queue and len(checked) < ConfigData._MAX_CONFIG_CHAIN:
|
|
274
|
+
path = queue.popleft()
|
|
275
|
+
if path in checked or not Path(path).is_file():
|
|
276
|
+
continue
|
|
277
|
+
checked.append(path)
|
|
278
|
+
found, chained = _check_file(path, known, variable_pool, current_script)
|
|
279
|
+
problems.extend(sorted(found, key=lambda p: p.line))
|
|
280
|
+
for target in chained:
|
|
281
|
+
queue.appendleft(target)
|
|
282
|
+
return checked, problems
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# 4. Working directory: current directory
|
|
13
13
|
#
|
|
14
14
|
# Later files override earlier ones. CLI arguments override everything.
|
|
15
|
-
# Generate this template with: execsql --init
|
|
15
|
+
# Generate this template with: execsql config --init > execsql.conf
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
[connect]
|
|
@@ -349,6 +349,29 @@
|
|
|
349
349
|
#win_config_file=
|
|
350
350
|
|
|
351
351
|
|
|
352
|
+
[format]
|
|
353
|
+
# Layout for `execsql format`. A flag on the command line wins over these.
|
|
354
|
+
# A run ignores this section.
|
|
355
|
+
|
|
356
|
+
# Spaces per indent level, for metacommand blocks and SQL.
|
|
357
|
+
#indent=4
|
|
358
|
+
# Put commas at the start of lines instead of the end.
|
|
359
|
+
#leading_comma=No
|
|
360
|
+
# Reformat SQL with sqlglot (needs the [formatter] extra). No = metacommands only.
|
|
361
|
+
#sql=Yes
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
[lint]
|
|
365
|
+
# Rules for `execsql lint`: codes or prefixes, comma-separated (e.g. V001,F).
|
|
366
|
+
# --select or --ignore on the command line replaces the value here.
|
|
367
|
+
# A run ignores this section.
|
|
368
|
+
|
|
369
|
+
#select=
|
|
370
|
+
#ignore=
|
|
371
|
+
# Yes = exit 1 on warnings too, not only on errors (--strict / --no-strict).
|
|
372
|
+
#strict=No
|
|
373
|
+
|
|
374
|
+
|
|
352
375
|
[variables]
|
|
353
376
|
# Substitution variables defined at startup. No pre-defined keys.
|
|
354
377
|
# Example:
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- {name}
|
|
2
|
+
--
|
|
3
|
+
-- PURPOSE
|
|
4
|
+
--
|
|
5
|
+
--
|
|
6
|
+
-- NOTES
|
|
7
|
+
-- 1.
|
|
8
|
+
--
|
|
9
|
+
-- PROJECT
|
|
10
|
+
--
|
|
11
|
+
--
|
|
12
|
+
-- COPYRIGHT
|
|
13
|
+
-- Copyright (c) {year},
|
|
14
|
+
--
|
|
15
|
+
-- AUTHORS
|
|
16
|
+
--
|
|
17
|
+
--
|
|
18
|
+
-- HISTORY
|
|
19
|
+
-- Date Remarks
|
|
20
|
+
-- ---------- -----------------------------------------------------------------
|
|
21
|
+
-- {date} Created.
|
|
22
|
+
--
|
|
23
|
+
-- ==============================================================================
|
execsql/db/factory.py
CHANGED
|
@@ -84,7 +84,7 @@ def db_SQLite(
|
|
|
84
84
|
if not Path(sqlite_fn).exists():
|
|
85
85
|
raise ErrInfo(
|
|
86
86
|
type="error",
|
|
87
|
-
other_msg=f'SQLite database file "{sqlite_fn}" does not exist.',
|
|
87
|
+
other_msg=f'SQLite database file "{sqlite_fn}" does not exist. Use -n (--new-db) to create it.',
|
|
88
88
|
)
|
|
89
89
|
return SQLiteDatabase(sqlite_fn)
|
|
90
90
|
|
|
@@ -129,7 +129,7 @@ def db_DuckDB(
|
|
|
129
129
|
if not Path(duckdb_fn).exists():
|
|
130
130
|
raise ErrInfo(
|
|
131
131
|
type="error",
|
|
132
|
-
other_msg=f'DuckDB database file "{duckdb_fn}" does not exist.',
|
|
132
|
+
other_msg=f'DuckDB database file "{duckdb_fn}" does not exist. Use -n (--new-db) to create it.',
|
|
133
133
|
)
|
|
134
134
|
return DuckDBDatabase(duckdb_fn)
|
|
135
135
|
|
execsql/debug/repl.py
CHANGED
|
@@ -1,49 +1,34 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
"""
|
|
3
|
+
"""The debug REPL a ``BREAKPOINT`` (or ``run --debug``) opens.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Input runs as if it were written where the script paused, through
|
|
6
|
+
:mod:`execsql.interactive`: SQL (ending with ``;``, results shown as tables),
|
|
7
|
+
metacommands and blocks, in the paused script's variable scope and
|
|
8
|
+
transaction. A ``SUB`` typed here is still set
|
|
9
|
+
when the script resumes; a ``BREAK`` inside a LOOP leaves the loop; ``HALT``
|
|
10
|
+
ends the run. An error ends only the input that caused it.
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
Commands to the REPL itself start with ``.``: ``.continue`` and ``.next``
|
|
13
|
+
resume the script, ``.where`` and ``.stack`` show where it paused, ``.quit``
|
|
14
|
+
halts it; ``.vars``, ``.set``, ``.scripts`` and ``.cancel`` inspect and
|
|
15
|
+
change the run.
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- Step through the script one statement at a time.
|
|
13
|
-
- Resume or abort execution.
|
|
14
|
-
|
|
15
|
-
Dispatch is two-way (psql-style): input starting with ``.`` is a REPL
|
|
16
|
-
command (``.continue``, ``.vars [VAR]``, ``.next``, etc.), everything
|
|
17
|
-
else is SQL. Multi-line SQL is supported: any non-``.`` input opens a
|
|
18
|
-
buffer whose continuation prompt is `` ... > ``, accumulating
|
|
19
|
-
until a line ends with ``;``. ``.cancel`` (or Ctrl-C / EOF) discards
|
|
20
|
-
the partial buffer.
|
|
21
|
-
|
|
22
|
-
Variable lookup is explicit — ``.vars LOGFILE`` prints one variable;
|
|
23
|
-
``.vars`` lists them all. There is no bare-identifier lookup, so any
|
|
24
|
-
SQL keyword you type starts a buffer the moment you press Enter.
|
|
25
|
-
|
|
26
|
-
The trailing ``;`` is the SQL terminator both within one line and across
|
|
27
|
-
multiple lines — the REPL has no read-only mode and DDL on most adapters
|
|
28
|
-
is irreversible, so requiring ``;`` is a small intent gate against
|
|
29
|
-
accidental DML/DDL on mistyped input. Use SQL ``BEGIN; … ROLLBACK;`` to
|
|
30
|
-
bracket exploratory DML if you need recoverability.
|
|
31
|
-
|
|
32
|
-
Errors raised by any REPL helper — bad SQL, malformed dot-commands, etc. —
|
|
33
|
-
are caught at the loop level so the session re-prompts instead of escaping
|
|
34
|
-
through ``x_breakpoint`` and being stamped as a "Metacommand error".
|
|
17
|
+
This module also holds the REPL's display helpers (colors, tables, variable
|
|
18
|
+
listings).
|
|
35
19
|
|
|
36
20
|
In non-interactive environments (CI, piped input, ``sys.stdin.isatty()`` is
|
|
37
21
|
``False``) the metacommand is silently skipped so automated pipelines are not
|
|
38
22
|
blocked.
|
|
39
23
|
"""
|
|
40
24
|
|
|
41
|
-
import os
|
|
42
25
|
import sys
|
|
43
26
|
from pathlib import Path
|
|
44
27
|
from typing import Any
|
|
45
28
|
|
|
46
29
|
import execsql.state as _state
|
|
30
|
+
from execsql.interactive import Prompt, common_dot_command, read_eval_loop, unknown_dot_command
|
|
31
|
+
from execsql.utils.color import color_disabled_by_env
|
|
47
32
|
|
|
48
33
|
__all__ = ["x_breakpoint"]
|
|
49
34
|
|
|
@@ -77,7 +62,7 @@ def _use_color() -> bool:
|
|
|
77
62
|
global _color_cache # noqa: PLW0603
|
|
78
63
|
if _color_cache is not None:
|
|
79
64
|
return _color_cache
|
|
80
|
-
if
|
|
65
|
+
if color_disabled_by_env():
|
|
81
66
|
_color_cache = False
|
|
82
67
|
else:
|
|
83
68
|
output = _state.output
|
|
@@ -123,18 +108,20 @@ _HELP_COMMANDS = [
|
|
|
123
108
|
(".quit", ".q", "Halt the script (exit 1)"),
|
|
124
109
|
(".vars", ".v", "List all execsql substitution variables"),
|
|
125
110
|
(".vars VAR", ".v VAR", "Print the value of a single variable (e.g. .vars logfile)"),
|
|
111
|
+
(".vars all", "", "Also list environment (&) variables"),
|
|
126
112
|
(".next", ".n", "Execute the next statement then pause again (step mode)"),
|
|
127
113
|
(".where", ".w", "Show the current script location and upcoming statement"),
|
|
128
114
|
(".stack", "", "Show the command-list stack (script name, line, depth)"),
|
|
129
115
|
(".set VAR VAL", ".s", "Set or update a substitution variable"),
|
|
130
116
|
(".scripts", "", "List all registered SCRIPT definitions"),
|
|
131
117
|
(".scripts NAME", "", "Show detail for a specific SCRIPT"),
|
|
132
|
-
(".cancel", "", "Discard a
|
|
118
|
+
(".cancel", "", "Discard a statement or block you are typing"),
|
|
133
119
|
(".help", ".h", "Show this help text"),
|
|
134
120
|
]
|
|
135
121
|
|
|
136
122
|
_HELP_OTHER = [
|
|
137
|
-
("SELECT ...;", "Run SQL
|
|
123
|
+
("SELECT ...;", "Run SQL, as the script would here; it may span lines and ends with ';'"),
|
|
124
|
+
("-- !x! SUB ...", "Run a metacommand (!x! SUB ... works too); blocks stay open until closed"),
|
|
138
125
|
]
|
|
139
126
|
|
|
140
127
|
_HELP_CMD_WIDTH = 13 # width of the command column
|
|
@@ -196,8 +183,8 @@ def _write_rule(label: str) -> None:
|
|
|
196
183
|
def _debug_repl(*, step: bool = False) -> None:
|
|
197
184
|
"""Interactive read-eval-print loop for script debugging.
|
|
198
185
|
|
|
199
|
-
Reads
|
|
200
|
-
|
|
186
|
+
Reads input until ``.continue``, ``.next`` or ``.quit``, or until Ctrl-D /
|
|
187
|
+
Ctrl-C on an empty line, which resume the script.
|
|
201
188
|
|
|
202
189
|
Args:
|
|
203
190
|
step: When ``True``, the entry banner says "Step" instead of
|
|
@@ -232,125 +219,37 @@ def _debug_repl(*, step: bool = False) -> None:
|
|
|
232
219
|
_hint_c = _c(_DIM, "'.c'")
|
|
233
220
|
_write(f" Type {_hint_help} for commands, {_hint_c} to resume.\n\n")
|
|
234
221
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
while True:
|
|
238
|
-
prompt = " ... > " if sql_buffer else "execsql debug> "
|
|
239
|
-
try:
|
|
240
|
-
line = input(prompt).strip()
|
|
241
|
-
except EOFError:
|
|
242
|
-
if sql_buffer:
|
|
243
|
-
sql_buffer.clear()
|
|
244
|
-
_write("\n (input discarded)\n")
|
|
245
|
-
continue
|
|
246
|
-
_write("\n")
|
|
247
|
-
return
|
|
248
|
-
except KeyboardInterrupt:
|
|
249
|
-
if sql_buffer:
|
|
250
|
-
sql_buffer.clear()
|
|
251
|
-
_write("\n (input discarded)\n")
|
|
252
|
-
continue
|
|
253
|
-
_write("\n")
|
|
254
|
-
return
|
|
222
|
+
read_eval_loop(_DebugPrompt())
|
|
255
223
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if cmd in ("next", "n"):
|
|
273
|
-
return
|
|
274
|
-
continue
|
|
275
|
-
|
|
276
|
-
if sql_buffer:
|
|
277
|
-
sql_buffer.append(line)
|
|
278
|
-
joined = " ".join(sql_buffer)
|
|
279
|
-
if joined.rstrip().endswith(";"):
|
|
280
|
-
_run_sql(joined)
|
|
281
|
-
sql_buffer.clear()
|
|
282
|
-
continue
|
|
283
|
-
|
|
284
|
-
if line.rstrip().endswith(";"):
|
|
285
|
-
_run_sql(line)
|
|
286
|
-
continue
|
|
287
|
-
|
|
288
|
-
sql_buffer.append(line)
|
|
289
|
-
except SystemExit:
|
|
290
|
-
raise
|
|
291
|
-
except KeyboardInterrupt:
|
|
292
|
-
sql_buffer.clear()
|
|
293
|
-
_write("\n (interrupted)\n")
|
|
294
|
-
continue
|
|
295
|
-
except Exception as exc:
|
|
296
|
-
sql_buffer.clear()
|
|
297
|
-
_write(f" {_c(_RED, 'Error:')} {exc}\n")
|
|
298
|
-
continue
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def _handle_dot_command(line: str) -> None:
|
|
302
|
-
"""Dispatch a dot-prefixed REPL command."""
|
|
303
|
-
# Strip the leading dot and normalize
|
|
304
|
-
cmd = line[1:].strip().lower()
|
|
305
|
-
|
|
306
|
-
if cmd in ("continue", "c"):
|
|
307
|
-
return # caller checks and returns from _debug_repl
|
|
308
|
-
elif cmd in ("abort", "q", "quit"):
|
|
224
|
+
|
|
225
|
+
class _DebugPrompt(Prompt):
|
|
226
|
+
name = "execsql debug"
|
|
227
|
+
source = "<breakpoint>"
|
|
228
|
+
|
|
229
|
+
def dot_command(self, command: str, argument: str, line: str) -> bool:
|
|
230
|
+
return _handle_dot_command(line)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _handle_dot_command(line: str) -> bool:
|
|
234
|
+
"""Dispatch a ``.`` command; return ``False`` when the script should resume."""
|
|
235
|
+
command, _, argument = line.strip()[1:].partition(" ")
|
|
236
|
+
command = command.lower()
|
|
237
|
+
if command in ("continue", "c"):
|
|
238
|
+
return False
|
|
239
|
+
if command in ("abort", "q", "quit"):
|
|
309
240
|
raise SystemExit(1)
|
|
310
|
-
|
|
241
|
+
if command in ("next", "n"):
|
|
242
|
+
_enable_step_mode()
|
|
243
|
+
return False
|
|
244
|
+
if command in ("help", "h"):
|
|
311
245
|
_write(_format_help())
|
|
312
|
-
elif
|
|
313
|
-
_print_all_vars()
|
|
314
|
-
elif cmd.startswith("vars ") or cmd.startswith("v "):
|
|
315
|
-
rest = cmd.split(None, 1)[1].strip() if " " in cmd else ""
|
|
316
|
-
if rest:
|
|
317
|
-
_print_var(rest)
|
|
318
|
-
else:
|
|
319
|
-
_print_all_vars()
|
|
320
|
-
elif cmd in ("where", "w"):
|
|
246
|
+
elif command in ("where", "w"):
|
|
321
247
|
_print_where()
|
|
322
|
-
elif
|
|
248
|
+
elif command == "stack":
|
|
323
249
|
_print_stack()
|
|
324
|
-
elif
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
# .set VAR VAL — set or update a substitution variable
|
|
328
|
-
rest = cmd[4:].strip() if cmd.startswith("set ") else ""
|
|
329
|
-
if not rest:
|
|
330
|
-
_write(" Usage: .set VAR VALUE\n")
|
|
331
|
-
else:
|
|
332
|
-
parts = rest.split(None, 1)
|
|
333
|
-
varname = parts[0]
|
|
334
|
-
value = parts[1] if len(parts) > 1 else ""
|
|
335
|
-
_set_var(varname, value)
|
|
336
|
-
elif cmd.startswith("s ") or cmd == "s":
|
|
337
|
-
# .s VAR VAL — shorthand for .set
|
|
338
|
-
rest = cmd[2:].strip() if cmd.startswith("s ") else ""
|
|
339
|
-
if not rest:
|
|
340
|
-
_write(" Usage: .s VAR VALUE\n")
|
|
341
|
-
else:
|
|
342
|
-
parts = rest.split(None, 1)
|
|
343
|
-
varname = parts[0]
|
|
344
|
-
value = parts[1] if len(parts) > 1 else ""
|
|
345
|
-
_set_var(varname, value)
|
|
346
|
-
elif cmd.startswith("scripts"):
|
|
347
|
-
rest = cmd[7:].strip()
|
|
348
|
-
if rest:
|
|
349
|
-
_print_script_detail(rest)
|
|
350
|
-
else:
|
|
351
|
-
_print_scripts()
|
|
352
|
-
else:
|
|
353
|
-
_write(f" {_c(_RED, 'Unknown command:')} {line!r}. Type '.help' for available commands.\n")
|
|
250
|
+
elif not common_dot_command(command, argument.strip(), line):
|
|
251
|
+
unknown_dot_command(line)
|
|
252
|
+
return True
|
|
354
253
|
|
|
355
254
|
|
|
356
255
|
# ---------------------------------------------------------------------------
|
|
@@ -371,7 +270,7 @@ def _write(text: str) -> None:
|
|
|
371
270
|
def _print_where() -> None:
|
|
372
271
|
"""Print the current script location and the upcoming statement.
|
|
373
272
|
|
|
374
|
-
Reads ``_state.last_command``
|
|
273
|
+
Reads ``_state.last_command`` and displays the
|
|
375
274
|
filename, line number, command type, and (truncated) statement text.
|
|
376
275
|
If ``last_command`` is ``None``, reports that the position is unknown.
|
|
377
276
|
"""
|
|
@@ -531,49 +430,8 @@ def _print_stack() -> None:
|
|
|
531
430
|
_write("\n")
|
|
532
431
|
|
|
533
432
|
|
|
534
|
-
def
|
|
535
|
-
"""
|
|
536
|
-
dbs = _state.dbs
|
|
537
|
-
if dbs is None:
|
|
538
|
-
_write(" (no database connection is active)\n")
|
|
539
|
-
return
|
|
540
|
-
db = dbs.current()
|
|
541
|
-
if db is None:
|
|
542
|
-
_write(" (no database connection is active)\n")
|
|
543
|
-
return
|
|
544
|
-
|
|
545
|
-
try:
|
|
546
|
-
with db._cursor() as curs:
|
|
547
|
-
try:
|
|
548
|
-
curs.execute(sql)
|
|
549
|
-
except Exception as exc:
|
|
550
|
-
try:
|
|
551
|
-
db.rollback()
|
|
552
|
-
except Exception:
|
|
553
|
-
pass
|
|
554
|
-
_write(f" {_c(_RED, 'SQL error:')} {exc}\n")
|
|
555
|
-
return
|
|
556
|
-
|
|
557
|
-
try:
|
|
558
|
-
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
|
559
|
-
except Exception:
|
|
560
|
-
pass
|
|
561
|
-
|
|
562
|
-
if curs.description is None:
|
|
563
|
-
rowcount = curs.rowcount if curs.rowcount is not None else -1
|
|
564
|
-
if rowcount >= 0:
|
|
565
|
-
row_word = "row" if rowcount == 1 else "rows"
|
|
566
|
-
_write(f" {_c(_DIM, f'({rowcount} {row_word} affected)')}\n")
|
|
567
|
-
else:
|
|
568
|
-
_write(f" {_c(_DIM, '(statement executed)')}\n")
|
|
569
|
-
return
|
|
570
|
-
|
|
571
|
-
colnames = [d[0] for d in curs.description]
|
|
572
|
-
rows = curs.fetchall()
|
|
573
|
-
except Exception as exc:
|
|
574
|
-
_write(f" {_c(_RED, 'SQL error:')} {exc}\n")
|
|
575
|
-
return
|
|
576
|
-
|
|
433
|
+
def _print_table(colnames: list[str], rows: list[Any]) -> None:
|
|
434
|
+
"""Print query results as a boxed text table, NULLs dimmed."""
|
|
577
435
|
if not colnames:
|
|
578
436
|
_write(" (query returned no columns)\n")
|
|
579
437
|
return
|