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.
Files changed (63) hide show
  1. execsql/cli/__init__.py +17 -586
  2. execsql/cli/application.py +309 -0
  3. execsql/cli/commands/__init__.py +13 -0
  4. execsql/cli/commands/config.py +205 -0
  5. execsql/cli/commands/format.py +100 -0
  6. execsql/cli/commands/init.py +135 -0
  7. execsql/cli/commands/lint.py +233 -0
  8. execsql/cli/commands/listing.py +81 -0
  9. execsql/cli/commands/run.py +451 -0
  10. execsql/cli/dispatch.py +66 -0
  11. execsql/cli/help.py +175 -24
  12. execsql/cli/lint.py +513 -76
  13. execsql/cli/options.py +108 -0
  14. execsql/cli/run.py +41 -14
  15. execsql/config.py +251 -154
  16. execsql/config_validation.py +282 -0
  17. execsql/data/execsql.conf.template +24 -1
  18. execsql/data/script_header.sql +23 -0
  19. execsql/db/access.py +22 -6
  20. execsql/db/base.py +105 -1
  21. execsql/db/dsn.py +2 -0
  22. execsql/db/duckdb.py +95 -2
  23. execsql/db/factory.py +2 -2
  24. execsql/db/mysql.py +11 -0
  25. execsql/db/oracle.py +12 -4
  26. execsql/db/sqlite.py +16 -0
  27. execsql/debug/repl.py +51 -193
  28. execsql/exceptions.py +9 -0
  29. execsql/format.py +87 -75
  30. execsql/interactive.py +232 -0
  31. execsql/manifest.py +189 -0
  32. execsql/metacommands/conditions.py +1 -1
  33. execsql/metacommands/control.py +2 -3
  34. execsql/metacommands/effects.py +124 -0
  35. execsql/script/__init__.py +0 -9
  36. execsql/script/ast.py +1 -1
  37. execsql/script/engine.py +6 -93
  38. execsql/script/executor.py +153 -34
  39. execsql/state.py +11 -2
  40. execsql/state.pyi +3 -0
  41. execsql/utils/color.py +18 -0
  42. execsql/utils/errors.py +11 -1
  43. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
  44. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
  45. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/RECORD +62 -46
  46. execsql2-2.24.0.dist-info/entry_points.txt +2 -0
  47. execsql2-2.23.0.dist-info/entry_points.txt +0 -3
  48. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  49. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  50. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  51. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  52. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  53. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  54. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  55. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  56. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  57. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
  58. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  59. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  60. {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  61. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
  62. {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
  63. {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", "main", "parse_keyword"]
20
+ __all__ = ["collect_paths", "format_file", "parse_keyword", "run_formatter"]
21
21
 
22
22
 
23
23
  _SQLGLOT_MISSING_MSG = (
24
- "execsql-format requires sqlglot for SQL reformatting.\n"
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 execsql-format doesn't pull in the AST parser
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-format)
1207
+ # Entry point (execsql format)
1208
1208
  # ---------------------------------------------------------------------------
1209
1209
 
1210
1210
 
1211
- def main() -> None:
1212
- """CLI entry point for the execsql-format console script."""
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
- app = typer.Typer(
1222
- name="execsql-format",
1223
- help="Format execsql scripts: normalize metacommand indentation and uppercase keywords.",
1224
- rich_markup_mode="rich",
1225
- no_args_is_help=True,
1226
- add_completion=False,
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
- @app.command(context_settings={"allow_extra_args": False})
1230
- def _cmd(
1231
- targets: list[Path] = typer.Argument(
1232
- ...,
1233
- metavar="FILE_OR_DIR",
1234
- help="Files or directories to format. Directories are searched recursively for *.sql files.",
1235
- ),
1236
- check: bool = typer.Option(False, "--check", help="Exit 1 if any file needs changes (don't write)."),
1237
- in_place: bool = typer.Option(False, "-i", "--in-place", help="Modify files in place."),
1238
- no_sql: bool = typer.Option(False, "--no-sql", help="Skip SQL formatting via sqlglot."),
1239
- indent: int = typer.Option(4, "--indent", metavar="N", help="Spaces per indent level."),
1240
- leading_comma: bool = typer.Option(
1241
- False,
1242
- "--leading-comma",
1243
- help="Place commas at the start of lines instead of the end.",
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
- formatted = format_file(source, indent=indent, use_sql=use_sql, leading_comma=leading_comma)
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
- if any_errors or (check and any_changed):
1291
- raise typer.Exit(code=1)
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
- app()
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 --dump-keywords``.
25
+ The full list is also surfaced by ``execsql list keywords``.
26
26
  """
27
27
 
28
28
  import os
@@ -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 ``--dump-keywords``,
81
- the VS Code grammar generator, and ``--list-keywords`` still see the
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