execsql2 2.23.0__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/access.py +22 -6
- execsql/db/base.py +105 -1
- execsql/db/dsn.py +2 -0
- execsql/db/duckdb.py +95 -2
- execsql/db/factory.py +2 -2
- execsql/db/mysql.py +11 -0
- execsql/db/oracle.py +12 -4
- execsql/db/sqlite.py +16 -0
- 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.0.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/RECORD +62 -46
- execsql2-2.24.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.0.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/licenses/NOTICE +0 -0
execsql/format.py
CHANGED
|
@@ -17,11 +17,11 @@ import re
|
|
|
17
17
|
from collections.abc import Iterator
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
__all__ = ["collect_paths", "format_file", "
|
|
20
|
+
__all__ = ["collect_paths", "format_file", "parse_keyword", "run_formatter"]
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
_SQLGLOT_MISSING_MSG = (
|
|
24
|
-
"execsql
|
|
24
|
+
"execsql format requires sqlglot for SQL reformatting.\n"
|
|
25
25
|
" Install with: pip install execsql2[formatter]\n"
|
|
26
26
|
" Or skip SQL reformatting with the --no-sql flag."
|
|
27
27
|
)
|
|
@@ -122,7 +122,7 @@ CONTINUATION = frozenset({"ANDIF", "ORIF"}) # emit at depth-1, no depth change
|
|
|
122
122
|
# Inline IF: "IF (cond) { command }" — self-contained, no ENDIF, no depth
|
|
123
123
|
# change. Pattern must accept the same payloads as
|
|
124
124
|
# src/execsql/script/parser.py:_IF_INLINE_RX. Kept as a separate compiled
|
|
125
|
-
# pattern (not an import) so
|
|
125
|
+
# pattern (not an import) so the formatter doesn't pull in the AST parser
|
|
126
126
|
# module graph at startup; tests/test_format.py has a drift check that
|
|
127
127
|
# asserts both regexes recognise the same inputs.
|
|
128
128
|
_IF_INLINE_RE = re.compile(r"^\s*IF\s*\(\s*.+\s*\)\s*\{.+\}\s*$", re.I)
|
|
@@ -1204,90 +1204,102 @@ def collect_paths(inputs: list[Path]) -> list[Path]:
|
|
|
1204
1204
|
|
|
1205
1205
|
|
|
1206
1206
|
# ---------------------------------------------------------------------------
|
|
1207
|
-
# Entry point (execsql
|
|
1207
|
+
# Entry point (execsql format)
|
|
1208
1208
|
# ---------------------------------------------------------------------------
|
|
1209
1209
|
|
|
1210
1210
|
|
|
1211
|
-
def
|
|
1212
|
-
|
|
1211
|
+
def run_formatter(
|
|
1212
|
+
targets: list[Path],
|
|
1213
|
+
*,
|
|
1214
|
+
check: bool = False,
|
|
1215
|
+
in_place: bool = False,
|
|
1216
|
+
no_sql: bool = False,
|
|
1217
|
+
indent: int = 4,
|
|
1218
|
+
leading_comma: bool = False,
|
|
1219
|
+
encoding: str = "utf-8",
|
|
1220
|
+
diff: bool = False,
|
|
1221
|
+
) -> int:
|
|
1222
|
+
"""Format or check *targets*; return the process exit code.
|
|
1223
|
+
|
|
1224
|
+
The options are declared once, on the ``execsql format`` command, and
|
|
1225
|
+
arrive here as arguments. Previously they lived on a Typer app built
|
|
1226
|
+
inside ``main()``, which meant the command that wrapped it had no options
|
|
1227
|
+
of its own to show and ``execsql format --help`` listed nothing.
|
|
1228
|
+
|
|
1229
|
+
With *diff*, nothing is written: each file that would change is printed
|
|
1230
|
+
as a unified diff, and the exit code is 1 when any would, as for *check*.
|
|
1231
|
+
"""
|
|
1213
1232
|
import sys
|
|
1214
1233
|
|
|
1215
|
-
import typer
|
|
1216
1234
|
from rich.console import Console
|
|
1217
1235
|
|
|
1218
1236
|
_console = Console()
|
|
1219
1237
|
_err_console = Console(stderr=True)
|
|
1220
1238
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1239
|
+
use_sql = not no_sql
|
|
1240
|
+
paths = collect_paths(targets)
|
|
1241
|
+
if not paths:
|
|
1242
|
+
_err_console.print("[bold red]Error:[/bold red] No .sql files found.")
|
|
1243
|
+
return 1
|
|
1244
|
+
|
|
1245
|
+
any_changed = False
|
|
1246
|
+
any_errors = False
|
|
1247
|
+
for path in paths:
|
|
1248
|
+
stdin = str(path) == "-"
|
|
1249
|
+
label = "<stdin>" if stdin else str(path)
|
|
1250
|
+
try:
|
|
1251
|
+
source = sys.stdin.buffer.read().decode(encoding) if stdin else path.read_text(encoding=encoding)
|
|
1252
|
+
except OSError as exc:
|
|
1253
|
+
_err_console.print(f"[bold red]Error:[/bold red] reading {label}: {exc}")
|
|
1254
|
+
any_errors = True
|
|
1255
|
+
# Collect read errors instead of short-circuiting so a single
|
|
1256
|
+
# unreadable file doesn't hide the rest of the report.
|
|
1257
|
+
continue
|
|
1258
|
+
except UnicodeDecodeError as exc:
|
|
1259
|
+
_err_console.print(
|
|
1260
|
+
f"[bold red]Error:[/bold red] decoding {label} as {encoding}: {exc}. "
|
|
1261
|
+
f"Try [bold]--script-encoding cp1252[/bold] or another text encoding.",
|
|
1262
|
+
)
|
|
1263
|
+
any_errors = True
|
|
1264
|
+
continue
|
|
1228
1265
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
encoding: str = typer.Option(
|
|
1246
|
-
"utf-8",
|
|
1247
|
-
"--encoding",
|
|
1248
|
-
metavar="NAME",
|
|
1249
|
-
help="Text encoding used to read and write SQL files (default utf-8).",
|
|
1250
|
-
),
|
|
1251
|
-
) -> None:
|
|
1252
|
-
use_sql = not no_sql
|
|
1253
|
-
paths = collect_paths(targets)
|
|
1254
|
-
if not paths:
|
|
1255
|
-
_err_console.print("[bold red]Error:[/bold red] No .sql files found.")
|
|
1256
|
-
raise typer.Exit(code=1)
|
|
1257
|
-
|
|
1258
|
-
any_changed = False
|
|
1259
|
-
any_errors = False
|
|
1260
|
-
for path in paths:
|
|
1261
|
-
try:
|
|
1262
|
-
source = path.read_text(encoding=encoding)
|
|
1263
|
-
except OSError as exc:
|
|
1264
|
-
_err_console.print(f"[bold red]Error:[/bold red] reading {path}: {exc}")
|
|
1265
|
-
any_errors = True
|
|
1266
|
-
# Collect read errors instead of short-circuiting so a single
|
|
1267
|
-
# unreadable file doesn't hide the rest of the report.
|
|
1268
|
-
continue
|
|
1269
|
-
except UnicodeDecodeError as exc:
|
|
1270
|
-
_err_console.print(
|
|
1271
|
-
f"[bold red]Error:[/bold red] decoding {path} as {encoding}: {exc}. "
|
|
1272
|
-
f"Try [bold]--encoding cp1252[/bold] or another text encoding.",
|
|
1273
|
-
)
|
|
1274
|
-
any_errors = True
|
|
1275
|
-
continue
|
|
1266
|
+
formatted = format_file(source, indent=indent, use_sql=use_sql, leading_comma=leading_comma)
|
|
1267
|
+
|
|
1268
|
+
if diff:
|
|
1269
|
+
if formatted != source:
|
|
1270
|
+
any_changed = True
|
|
1271
|
+
_write_diff(source, formatted, label)
|
|
1272
|
+
elif check:
|
|
1273
|
+
if formatted != source:
|
|
1274
|
+
_console.print(f"would reformat {label}")
|
|
1275
|
+
any_changed = True
|
|
1276
|
+
elif in_place:
|
|
1277
|
+
if formatted != source:
|
|
1278
|
+
path.write_text(formatted, encoding=encoding)
|
|
1279
|
+
_console.print(f"reformatted {path}")
|
|
1280
|
+
else:
|
|
1281
|
+
sys.stdout.write(formatted)
|
|
1276
1282
|
|
|
1277
|
-
|
|
1283
|
+
return 1 if (any_errors or ((check or diff) and any_changed)) else 0
|
|
1278
1284
|
|
|
1279
|
-
if check:
|
|
1280
|
-
if formatted != source:
|
|
1281
|
-
_console.print(f"would reformat {path}")
|
|
1282
|
-
any_changed = True
|
|
1283
|
-
elif in_place:
|
|
1284
|
-
if formatted != source:
|
|
1285
|
-
path.write_text(formatted, encoding=encoding)
|
|
1286
|
-
_console.print(f"reformatted {path}")
|
|
1287
|
-
else:
|
|
1288
|
-
sys.stdout.write(formatted)
|
|
1289
1285
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1286
|
+
def _write_diff(before: str, after: str, label: str) -> None:
|
|
1287
|
+
"""Print a unified diff of one file, colored when stdout is a terminal."""
|
|
1288
|
+
import difflib
|
|
1289
|
+
import sys
|
|
1290
|
+
|
|
1291
|
+
from execsql.utils.color import color_disabled_by_env
|
|
1292
1292
|
|
|
1293
|
-
|
|
1293
|
+
lines = difflib.unified_diff(
|
|
1294
|
+
before.splitlines(keepends=True),
|
|
1295
|
+
after.splitlines(keepends=True),
|
|
1296
|
+
fromfile=label,
|
|
1297
|
+
tofile=label,
|
|
1298
|
+
)
|
|
1299
|
+
color = sys.stdout.isatty() and not color_disabled_by_env()
|
|
1300
|
+
styles = {"+": "\033[32m", "-": "\033[31m", "@": "\033[36m"}
|
|
1301
|
+
for line in lines:
|
|
1302
|
+
if not line.endswith("\n"):
|
|
1303
|
+
line += "\n\\n"
|
|
1304
|
+
style = styles.get(line[0]) if color and not line.startswith(("+++", "---")) else None
|
|
1305
|
+
sys.stdout.write(f"{style}{line.rstrip(chr(10))}\033[0m\n" if style else line)
|
execsql/interactive.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""The engine behind the debug REPL a ``BREAKPOINT`` (or ``run --debug``) opens.
|
|
2
|
+
|
|
3
|
+
Input runs as the next lines of the paused script
|
|
4
|
+
(:func:`~execsql.script.executor.execute_input`): where the script paused, in
|
|
5
|
+
its variable scope and transaction.
|
|
6
|
+
|
|
7
|
+
What runs, and how it runs, follows script rules:
|
|
8
|
+
|
|
9
|
+
- SQL ends with ``;`` and may span lines. Variables are substituted, and each
|
|
10
|
+
statement is committed unless ``AUTOCOMMIT OFF`` or a batch is in effect.
|
|
11
|
+
Results print as tables.
|
|
12
|
+
- A metacommand is typed as in a script (``-- !x! EXPORT …``) or without the
|
|
13
|
+
comment marker (``!x! EXPORT …``).
|
|
14
|
+
- A block (``IF`` … ``ENDIF``, ``LOOP``, ``BEGIN BATCH``, ``BEGIN SCRIPT``)
|
|
15
|
+
keeps the prompt reading until it is closed, then runs as a whole.
|
|
16
|
+
|
|
17
|
+
The one difference from a script: an error that would halt the run ends only
|
|
18
|
+
the input that caused it. The prompt reports it and reads the next input.
|
|
19
|
+
``HALT`` still ends the run.
|
|
20
|
+
|
|
21
|
+
A line starting with ``.`` is a command to the prompt itself. :class:`Prompt`
|
|
22
|
+
holds a prompt's name and its own commands; ``.vars``, ``.set``, ``.scripts``
|
|
23
|
+
and ``.cancel`` are handled here for any prompt.
|
|
24
|
+
|
|
25
|
+
Display helpers (colors, tables, variable listings) live in
|
|
26
|
+
:mod:`execsql.debug.repl` and are reached through the module, so tests that
|
|
27
|
+
patch them there see every caller.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
import execsql.state as _state
|
|
34
|
+
from execsql.exceptions import ErrInfo
|
|
35
|
+
|
|
36
|
+
__all__ = ["Prompt", "read_eval_loop", "run_input", "uncommitted"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Prompt:
|
|
40
|
+
"""What differs between the prompts; subclassed by each.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
name: The prompt text before ``>``, e.g. ``execsql``.
|
|
44
|
+
source: The name input is given in messages and ``$CURRENT_SCRIPT``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
name = "execsql"
|
|
48
|
+
source = "<prompt>"
|
|
49
|
+
|
|
50
|
+
def dot_command(self, command: str, argument: str, line: str) -> bool:
|
|
51
|
+
"""Handle a ``.`` command; return ``False`` to leave the prompt.
|
|
52
|
+
|
|
53
|
+
*command* is the lowercased word after the dot, *argument* the rest
|
|
54
|
+
of the line as typed.
|
|
55
|
+
"""
|
|
56
|
+
if not common_dot_command(command, argument, line):
|
|
57
|
+
unknown_dot_command(line)
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def uncommitted() -> bool:
|
|
62
|
+
"""Whether SQL run now waits for a commit: ``AUTOCOMMIT OFF`` or an open batch."""
|
|
63
|
+
status = _state.status
|
|
64
|
+
if status is not None and status.batch.in_batch():
|
|
65
|
+
return True
|
|
66
|
+
dbs = _state.dbs
|
|
67
|
+
if dbs is None:
|
|
68
|
+
return False
|
|
69
|
+
try:
|
|
70
|
+
db = dbs.current()
|
|
71
|
+
except Exception:
|
|
72
|
+
return False
|
|
73
|
+
return db is not None and not db.autocommit
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _prompts(prompt: Prompt) -> tuple[str, str]:
|
|
77
|
+
"""The prompt and continuation-line strings; ``*`` marks uncommitted mode."""
|
|
78
|
+
first = f"{prompt.name}{'*' if uncommitted() else ''}> "
|
|
79
|
+
return first, "...> ".rjust(len(first))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_metacommand(line: str) -> bool:
|
|
83
|
+
stripped = line.strip()
|
|
84
|
+
return stripped.startswith("--") and stripped[2:].lstrip().lower().startswith("!x!")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _complete(line: str) -> bool:
|
|
88
|
+
"""Whether input ending with *line* may be whole: it ends with ``;`` or is a metacommand."""
|
|
89
|
+
return line.rstrip().endswith(";") or _is_metacommand(line)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _one_line(text: str) -> str:
|
|
93
|
+
return " ".join(text.split())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def describe_error(exc: BaseException) -> str:
|
|
97
|
+
"""One line for a prompt: the driver's or handler's own message, and where it came from."""
|
|
98
|
+
from execsql.debug import repl as _ui
|
|
99
|
+
|
|
100
|
+
label = "Error:"
|
|
101
|
+
if isinstance(exc, ErrInfo):
|
|
102
|
+
cause = exc.__cause__
|
|
103
|
+
if isinstance(cause, Exception) and not isinstance(cause, ErrInfo) and str(cause):
|
|
104
|
+
message = str(cause)
|
|
105
|
+
else:
|
|
106
|
+
message = exc.other or exc.exception or exc.type
|
|
107
|
+
if exc.command and exc.command not in message:
|
|
108
|
+
message = f"{message}: {exc.command}"
|
|
109
|
+
if exc.cmdtype == "sql":
|
|
110
|
+
label = "SQL error:"
|
|
111
|
+
source = exc.script_file
|
|
112
|
+
if source and not (source.startswith("<") and source.endswith(">")) and exc.script_line_no:
|
|
113
|
+
message = f"{message} (line {exc.script_line_no} of {source})"
|
|
114
|
+
else:
|
|
115
|
+
message = str(exc) or type(exc).__name__
|
|
116
|
+
return f"{_ui._c(_ui._RED, label)} {_one_line(message)}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _show_result(result: tuple[list[str], list] | None) -> None:
|
|
120
|
+
"""Print a statement's rows as a table, or how many rows it changed."""
|
|
121
|
+
from execsql.debug import repl as _ui
|
|
122
|
+
|
|
123
|
+
if result is not None:
|
|
124
|
+
_ui._print_table(*result)
|
|
125
|
+
return
|
|
126
|
+
count = -1
|
|
127
|
+
if _state.subvars is not None:
|
|
128
|
+
try:
|
|
129
|
+
count = int(_state.subvars.varvalue("$LAST_ROWCOUNT") or -1)
|
|
130
|
+
except (TypeError, ValueError):
|
|
131
|
+
count = -1
|
|
132
|
+
word = "row" if count == 1 else "rows"
|
|
133
|
+
_ui._write(f" {_ui._c(_ui._DIM, f'({count} {word} affected)' if count >= 0 else '(statement executed)')}\n")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run_input(text: str, source: str) -> None:
|
|
137
|
+
"""Parse *text* and run it as the next lines of the run; errors are raised."""
|
|
138
|
+
from execsql.script.executor import execute_input
|
|
139
|
+
from execsql.script.parser import parse_string
|
|
140
|
+
|
|
141
|
+
execute_input(parse_string(text + "\n", source), _show_result)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def common_dot_command(command: str, argument: str, line: str) -> bool:
|
|
145
|
+
"""Handle a command both prompts share; return ``False`` if *command* is not one."""
|
|
146
|
+
from execsql.debug import repl as _ui
|
|
147
|
+
|
|
148
|
+
if command in ("vars", "v"):
|
|
149
|
+
if argument.lower() == "all":
|
|
150
|
+
_ui._print_all_vars(include_env=True)
|
|
151
|
+
elif argument:
|
|
152
|
+
_ui._print_var(argument)
|
|
153
|
+
else:
|
|
154
|
+
_ui._print_all_vars()
|
|
155
|
+
elif command in ("set", "s"):
|
|
156
|
+
parts = argument.split(None, 1)
|
|
157
|
+
if not parts:
|
|
158
|
+
_ui._write(f" Usage: .{command} VAR VALUE\n")
|
|
159
|
+
else:
|
|
160
|
+
_ui._set_var(parts[0], parts[1] if len(parts) > 1 else "")
|
|
161
|
+
elif command == "scripts":
|
|
162
|
+
if argument:
|
|
163
|
+
_ui._print_script_detail(argument)
|
|
164
|
+
else:
|
|
165
|
+
_ui._print_scripts()
|
|
166
|
+
else:
|
|
167
|
+
return False
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def unknown_dot_command(line: str) -> None:
|
|
172
|
+
from execsql.debug import repl as _ui
|
|
173
|
+
|
|
174
|
+
_ui._write(f" {_ui._c(_ui._RED, 'Unknown command:')} {line!r}. Type '.help' for available commands.\n")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def read_eval_loop(prompt: Prompt) -> None:
|
|
178
|
+
"""Read and run inputs until the prompt's own command ends it, or input ends.
|
|
179
|
+
|
|
180
|
+
Lines are read with ``input()`` (line editing, a prompt). Ctrl-D or
|
|
181
|
+
Ctrl-C discards a half-typed input, and on an empty line leaves the
|
|
182
|
+
prompt.
|
|
183
|
+
"""
|
|
184
|
+
from execsql.debug import repl as _ui
|
|
185
|
+
|
|
186
|
+
buffer: list[str] = []
|
|
187
|
+
while True:
|
|
188
|
+
first, more = _prompts(prompt)
|
|
189
|
+
try:
|
|
190
|
+
line = input(more if buffer else first)
|
|
191
|
+
except (EOFError, KeyboardInterrupt):
|
|
192
|
+
if buffer:
|
|
193
|
+
buffer.clear()
|
|
194
|
+
_ui._write("\n (input discarded)\n")
|
|
195
|
+
continue
|
|
196
|
+
_ui._write("\n")
|
|
197
|
+
return
|
|
198
|
+
stripped = line.strip()
|
|
199
|
+
if not stripped and not buffer:
|
|
200
|
+
continue
|
|
201
|
+
if stripped.startswith("."):
|
|
202
|
+
command, _, argument = stripped[1:].partition(" ")
|
|
203
|
+
command = command.lower()
|
|
204
|
+
if command == "cancel":
|
|
205
|
+
if buffer:
|
|
206
|
+
buffer.clear()
|
|
207
|
+
_ui._write(" (input discarded)\n")
|
|
208
|
+
continue
|
|
209
|
+
try:
|
|
210
|
+
if not prompt.dot_command(command, argument.strip(), stripped):
|
|
211
|
+
return
|
|
212
|
+
except (SystemExit, KeyboardInterrupt):
|
|
213
|
+
raise
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
_ui._write(f" {describe_error(exc)}\n")
|
|
216
|
+
continue
|
|
217
|
+
if stripped.lower().startswith("!x!"):
|
|
218
|
+
line = "-- " + stripped
|
|
219
|
+
buffer.append(line)
|
|
220
|
+
if not _complete(line):
|
|
221
|
+
continue
|
|
222
|
+
try:
|
|
223
|
+
run_input("\n".join(buffer), prompt.source)
|
|
224
|
+
except ErrInfo as exc:
|
|
225
|
+
if "at end of file" in str(exc):
|
|
226
|
+
continue # a block is still open; keep reading
|
|
227
|
+
_ui._write(f" {describe_error(exc)}\n")
|
|
228
|
+
except KeyboardInterrupt:
|
|
229
|
+
_ui._write("\n (interrupted)\n")
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
_ui._write(f" {describe_error(exc)}\n")
|
|
232
|
+
buffer.clear()
|
execsql/manifest.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""The run manifest: a JSON record of one ``execsql run``, for ``--manifest FILE``.
|
|
2
|
+
|
|
3
|
+
The manifest says what a run did — the script, the database, how many
|
|
4
|
+
statements ran, the files it read, wrote and deleted, the connections it
|
|
5
|
+
opened, and how it ended — in a form an orchestrator or an audit can read
|
|
6
|
+
without parsing the log. It is written when the run ends, whether it
|
|
7
|
+
succeeded, failed or was cancelled.
|
|
8
|
+
|
|
9
|
+
Files are recorded as the executor runs each metacommand, after variable
|
|
10
|
+
substitution, so paths are the ones actually used. What a metacommand touches
|
|
11
|
+
is decided by :mod:`execsql.metacommands.effects`. Variable values and passwords are never written:
|
|
12
|
+
the run log hides ``-a`` and ``--var`` values because they can be secrets, and
|
|
13
|
+
the manifest lists only their names.
|
|
14
|
+
|
|
15
|
+
One recorder is active per run; :func:`current` returns it, or ``None`` when
|
|
16
|
+
no manifest was asked for, so the executor's hooks cost one check. Writing the
|
|
17
|
+
manifest ends the recording, so a later run in the same process starts clean.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import atexit
|
|
23
|
+
import datetime
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from execsql import __version__
|
|
32
|
+
|
|
33
|
+
__all__ = ["RunManifest", "check_writable", "current", "start"]
|
|
34
|
+
|
|
35
|
+
_current: RunManifest | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def current() -> RunManifest | None:
|
|
39
|
+
"""The active manifest recorder, or ``None`` when ``--manifest`` was not given."""
|
|
40
|
+
return _current
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_writable(path: str) -> str | None:
|
|
44
|
+
"""Why a manifest cannot be written to *path*, or ``None`` if it can.
|
|
45
|
+
|
|
46
|
+
Checked before a run starts, so a bad ``--manifest`` path fails before any
|
|
47
|
+
SQL runs rather than after.
|
|
48
|
+
"""
|
|
49
|
+
target = Path(path)
|
|
50
|
+
if target.is_dir():
|
|
51
|
+
return f"{path} is a directory"
|
|
52
|
+
try:
|
|
53
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
fd, probe = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp")
|
|
55
|
+
os.close(fd)
|
|
56
|
+
os.unlink(probe)
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
return exc.strerror or str(exc)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def start(path: str, script: str | None, variables: list[str]) -> RunManifest:
|
|
63
|
+
"""Begin recording a run; the manifest is written to *path* when it ends.
|
|
64
|
+
|
|
65
|
+
If nothing else writes it (``exit_now`` on an error, the end of a run), an
|
|
66
|
+
``atexit`` fallback does.
|
|
67
|
+
"""
|
|
68
|
+
global _current
|
|
69
|
+
_current = RunManifest(path, script, variables)
|
|
70
|
+
atexit.register(_current.finish, None)
|
|
71
|
+
return _current
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _now() -> datetime.datetime:
|
|
75
|
+
return datetime.datetime.now(tz=datetime.timezone.utc)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RunManifest:
|
|
79
|
+
"""Collects what one run does and writes it as JSON when the run ends."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, path: str, script: str | None, variables: list[str]) -> None:
|
|
82
|
+
self.path = Path(path)
|
|
83
|
+
self.script = script or "<inline>"
|
|
84
|
+
self.variables = variables
|
|
85
|
+
self.started = _now()
|
|
86
|
+
self.config_files: list[str] = []
|
|
87
|
+
self.database: dict[str, Any] | None = None
|
|
88
|
+
self.sql_statements = 0
|
|
89
|
+
self.metacommands = 0
|
|
90
|
+
self.reads: list[dict[str, Any]] = []
|
|
91
|
+
self.writes: list[dict[str, Any]] = []
|
|
92
|
+
self.deletes: list[dict[str, Any]] = []
|
|
93
|
+
self.connections: list[dict[str, Any]] = []
|
|
94
|
+
self.errors: list[dict[str, Any]] = []
|
|
95
|
+
self.written = False
|
|
96
|
+
|
|
97
|
+
# -- recording -------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def set_database(self, db: Any) -> None:
|
|
100
|
+
"""Record the initial connection: DBMS, server, database or file, user — no password."""
|
|
101
|
+
self.database = {
|
|
102
|
+
"dbms": db.type.dbms_id if getattr(db, "type", None) else None,
|
|
103
|
+
"server": getattr(db, "server_name", None),
|
|
104
|
+
"database": getattr(db, "db_name", None),
|
|
105
|
+
"user": getattr(db, "user", None),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
def record_sql(self) -> None:
|
|
109
|
+
self.sql_statements += 1
|
|
110
|
+
|
|
111
|
+
def record_metacommand(self, command: str, source: str, line: int, metacommandlist: Any) -> None:
|
|
112
|
+
"""Count a metacommand that ran, and record the files and connections it touched.
|
|
113
|
+
|
|
114
|
+
*command* is the text after substitution, so paths are the ones used.
|
|
115
|
+
"""
|
|
116
|
+
from execsql.metacommands.effects import effects
|
|
117
|
+
|
|
118
|
+
self.metacommands += 1
|
|
119
|
+
hit = metacommandlist.get_match(command)
|
|
120
|
+
if hit is None:
|
|
121
|
+
return
|
|
122
|
+
mc, m = hit
|
|
123
|
+
by = mc.description or command.split(None, 1)[0].upper()
|
|
124
|
+
for effect in effects(mc.exec_fn.__name__, m.groupdict()):
|
|
125
|
+
entry = {"source": source, "line": line, "by": by}
|
|
126
|
+
if effect.role in ("connect", "use"):
|
|
127
|
+
self.connections.append(
|
|
128
|
+
{**entry, "by": effect.role.upper(), "target": effect.target, "detail": effect.detail},
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
{"read": self.reads, "write": self.writes, "delete": self.deletes}[effect.role].append(
|
|
132
|
+
{**entry, "path": effect.target},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def record_include(self, path: str, source: str, line: int) -> None:
|
|
136
|
+
self.reads.append({"source": source, "line": line, "by": "INCLUDE", "path": path})
|
|
137
|
+
|
|
138
|
+
# -- finishing -------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def finish(self, exit_status: int | None, error: Any = None) -> None:
|
|
141
|
+
"""Write the manifest once. Later calls (the atexit fallback) do nothing."""
|
|
142
|
+
if self.written:
|
|
143
|
+
return
|
|
144
|
+
if error is not None:
|
|
145
|
+
# str(ErrInfo) is its concise message; errmsg() is the multi-line
|
|
146
|
+
# console block with a timestamp, which a reader of JSON does not want.
|
|
147
|
+
self.errors.append(
|
|
148
|
+
{
|
|
149
|
+
"message": " ".join(str(error).split()),
|
|
150
|
+
"type": getattr(error, "type", None),
|
|
151
|
+
"source": getattr(error, "script_file", None),
|
|
152
|
+
"line": getattr(error, "script_line_no", None),
|
|
153
|
+
"command": getattr(error, "cmd", None) or getattr(error, "command", None),
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
finished = _now()
|
|
157
|
+
data = {
|
|
158
|
+
"execsql_version": __version__,
|
|
159
|
+
"script": self.script,
|
|
160
|
+
"started": self.started.isoformat(timespec="seconds"),
|
|
161
|
+
"finished": finished.isoformat(timespec="seconds"),
|
|
162
|
+
"duration_s": round((finished - self.started).total_seconds(), 3),
|
|
163
|
+
"exit_status": exit_status,
|
|
164
|
+
"config_files": self.config_files,
|
|
165
|
+
"database": self.database,
|
|
166
|
+
"variables": self.variables,
|
|
167
|
+
"statements": {"sql": self.sql_statements, "metacommands": self.metacommands},
|
|
168
|
+
"files_read": self.reads,
|
|
169
|
+
"files_written": self.writes,
|
|
170
|
+
"files_deleted": self.deletes,
|
|
171
|
+
"connections": self.connections,
|
|
172
|
+
"errors": self.errors,
|
|
173
|
+
}
|
|
174
|
+
self.written = True
|
|
175
|
+
global _current
|
|
176
|
+
if _current is self:
|
|
177
|
+
_current = None
|
|
178
|
+
atexit.unregister(self.finish)
|
|
179
|
+
try:
|
|
180
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
# Write beside the target and rename, so a reader never sees half a file.
|
|
182
|
+
fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=f".{self.path.name}.", suffix=".tmp")
|
|
183
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
184
|
+
json.dump(data, f, indent=2, default=str)
|
|
185
|
+
f.write("\n")
|
|
186
|
+
os.replace(tmp, self.path)
|
|
187
|
+
except OSError as exc:
|
|
188
|
+
# The run itself is over; report the lost manifest without a traceback.
|
|
189
|
+
sys.stderr.write(f"execsql: could not write --manifest {self.path}: {exc.strerror or exc}\n")
|
|
@@ -22,7 +22,7 @@ registration time for quoted, unquoted, and bracketed arguments):
|
|
|
22
22
|
``xf_row_count_eq``, ``xf_row_count_lt``.
|
|
23
23
|
- Runtime state: ``xf_sqlerror``, ``xf_dialogcanceled``, ``xf_dbms``.
|
|
24
24
|
|
|
25
|
-
The full list is also surfaced by ``execsql
|
|
25
|
+
The full list is also surfaced by ``execsql list keywords``.
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
import os
|
execsql/metacommands/control.py
CHANGED
|
@@ -77,9 +77,8 @@ def x_assert(**kwargs: Any) -> None:
|
|
|
77
77
|
def _ast_only_stub(name: str):
|
|
78
78
|
"""Return an ErrInfo for a metacommand the AST executor handles structurally.
|
|
79
79
|
|
|
80
|
-
These handlers stay registered in the dispatch table so
|
|
81
|
-
the VS Code grammar generator
|
|
82
|
-
keyword. The AST parser converts the source form (``IF`` / ``ENDIF`` /
|
|
80
|
+
These handlers stay registered in the dispatch table so ``execsql list
|
|
81
|
+
keywords`` and the VS Code grammar generator still see the keyword. The AST parser converts the source form (``IF`` / ``ENDIF`` /
|
|
83
82
|
``LOOP`` / ``BEGIN BATCH`` / ``BREAK`` / ``ELSE`` / ``ELSEIF`` / ``ANDIF``
|
|
84
83
|
/ ``ORIF``) into structural AST nodes that the executor walks directly —
|
|
85
84
|
none of these dispatch handlers fire for parsed scripts. Reaching one
|