execsql2 2.11.0__py3-none-any.whl → 2.12.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 (37) hide show
  1. execsql/cli/__init__.py +6 -0
  2. execsql/cli/lint.py +1 -1
  3. execsql/cli/run.py +18 -12
  4. execsql/debug/__init__.py +6 -0
  5. execsql/debug/repl.py +472 -0
  6. execsql/exporters/xlsx.py +5 -0
  7. execsql/exporters/yaml.py +2 -0
  8. execsql/metacommands/__init__.py +1 -1
  9. execsql/metacommands/conditions.py +1 -1
  10. execsql/metacommands/control.py +4 -10
  11. execsql/metacommands/debug.py +1 -1
  12. execsql/metacommands/dispatch.py +1 -1
  13. execsql/script/engine.py +15 -5
  14. execsql/state.py +2 -2
  15. execsql/utils/gui.py +26 -4
  16. {execsql2-2.11.0.dist-info → execsql2-2.12.0.dist-info}/METADATA +5 -2
  17. {execsql2-2.11.0.dist-info → execsql2-2.12.0.dist-info}/RECORD +36 -35
  18. execsql/metacommands/debug_repl.py +0 -288
  19. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/README.md +0 -0
  20. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  21. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  22. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/execsql.conf +0 -0
  23. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  24. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  25. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  26. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  27. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  28. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  29. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  30. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/script_template.sql +0 -0
  31. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  32. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  33. {execsql2-2.11.0.data → execsql2-2.12.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  34. {execsql2-2.11.0.dist-info → execsql2-2.12.0.dist-info}/WHEEL +0 -0
  35. {execsql2-2.11.0.dist-info → execsql2-2.12.0.dist-info}/entry_points.txt +0 -0
  36. {execsql2-2.11.0.dist-info → execsql2-2.12.0.dist-info}/licenses/LICENSE.txt +0 -0
  37. {execsql2-2.11.0.dist-info → execsql2-2.12.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/__init__.py CHANGED
@@ -278,6 +278,11 @@ def main(
278
278
  "--profile",
279
279
  help="Record per-statement execution times and print a timing summary after the script completes.",
280
280
  ),
281
+ profile_limit: int = typer.Option(
282
+ 20,
283
+ "--profile-limit",
284
+ help="Number of top statements to show in the --profile timing summary (default: 20).",
285
+ ),
281
286
  debug: bool = typer.Option(
282
287
  False,
283
288
  "--debug",
@@ -449,6 +454,7 @@ def main(
449
454
  output_dir=output_dir,
450
455
  progress=progress,
451
456
  profile=profile,
457
+ profile_limit=profile_limit,
452
458
  ping=ping,
453
459
  lint=lint,
454
460
  debug=debug,
execsql/cli/lint.py CHANGED
@@ -194,7 +194,7 @@ def _lint_cmdlist(
194
194
  for cmd in cmdlist.cmdlist:
195
195
  src = cmd.source
196
196
  lno = cmd.line_no
197
- stmt = cmd.command.statement if cmd.command_type == "sql" else cmd.command.statement
197
+ stmt = cmd.command.statement
198
198
 
199
199
  if cmd.command_type == "sql":
200
200
  # SQL statements: check for variable references only
execsql/cli/run.py CHANGED
@@ -8,6 +8,7 @@ and drives the main execution loop. Separated from argument parsing
8
8
  from __future__ import annotations
9
9
 
10
10
  import atexit
11
+ from typing import Any
11
12
  import datetime
12
13
  import getpass
13
14
  import os
@@ -70,12 +71,14 @@ def _print_dry_run(cmdlist: object) -> None:
70
71
  # ---------------------------------------------------------------------------
71
72
 
72
73
 
73
- def _print_profile(profile_data: list[tuple]) -> None:
74
+ def _print_profile(profile_data: list[tuple], limit: int = 20) -> None:
74
75
  """Print a per-statement timing summary to stdout.
75
76
 
76
77
  Args:
77
78
  profile_data: List of ``(source, line_no, command_type, elapsed_secs,
78
79
  command_text_preview)`` tuples collected during execution.
80
+ limit: Maximum number of top statements to display (default: 20).
81
+ All statements are included in totals regardless of this value.
79
82
  """
80
83
  if not profile_data:
81
84
  _console.print("[dim]Profile: no statements recorded.[/dim]")
@@ -84,9 +87,9 @@ def _print_profile(profile_data: list[tuple]) -> None:
84
87
  total_secs = sum(row[3] for row in profile_data)
85
88
  n = len(profile_data)
86
89
 
87
- # Sort descending by elapsed time; show top 20 (or all if <= 20).
90
+ # Sort descending by elapsed time; show top `limit` (or all if <= limit).
88
91
  sorted_data = sorted(profile_data, key=lambda r: r[3], reverse=True)
89
- display = sorted_data[:20]
92
+ display = sorted_data[:limit]
90
93
 
91
94
  _console.print()
92
95
  _console.print(f"[bold cyan]Profile:[/bold cyan] {n} statement{'s' if n != 1 else ''} in {total_secs:.3f}s")
@@ -114,10 +117,10 @@ def _print_profile(profile_data: list[tuple]) -> None:
114
117
  f"{preview_short}",
115
118
  )
116
119
 
117
- if len(sorted_data) > 20:
118
- omitted = len(sorted_data) - 20
120
+ if len(sorted_data) > limit:
121
+ omitted = len(sorted_data) - limit
119
122
  _console.print(
120
- f"[dim] ... {omitted} more statement{'s' if omitted != 1 else ''} not shown (top 20 by time)[/dim]",
123
+ f"[dim] ... {omitted} more statement{'s' if omitted != 1 else ''} not shown (top {limit} by time)[/dim]",
121
124
  )
122
125
 
123
126
  _console.print()
@@ -128,7 +131,7 @@ def _print_profile(profile_data: list[tuple]) -> None:
128
131
  # ---------------------------------------------------------------------------
129
132
 
130
133
 
131
- def _ping_db(db) -> None:
134
+ def _ping_db(db: Any) -> None:
132
135
  """Test connectivity for *db*, print connection details, and exit.
133
136
 
134
137
  Attempts to execute ``SELECT version()`` (or ``SELECT sqlite_version()``
@@ -156,7 +159,7 @@ def _ping_db(db) -> None:
156
159
  curs.close()
157
160
  if row and row[0]:
158
161
  version_str = str(row[0]).split("\n")[0].strip()
159
- break
162
+ break
160
163
  except Exception:
161
164
  continue
162
165
 
@@ -212,6 +215,7 @@ def _run(
212
215
  output_dir: str | None = None,
213
216
  progress: bool = False,
214
217
  profile: bool = False,
218
+ profile_limit: int = 20,
215
219
  ping: bool = False,
216
220
  lint: bool = False,
217
221
  debug: bool = False,
@@ -551,7 +555,7 @@ def _run(
551
555
  if debug:
552
556
  _state.step_mode = True
553
557
 
554
- _execute_script_direct(conf, profile=profile)
558
+ _execute_script_direct(conf, profile=profile, profile_limit=profile_limit)
555
559
 
556
560
 
557
561
  # ---------------------------------------------------------------------------
@@ -610,7 +614,7 @@ def _execute_script_textual_console(conf: ConfigData) -> None:
610
614
  _state.exec_log.log_exit_end()
611
615
 
612
616
 
613
- def _execute_script_direct(conf: ConfigData, *, profile: bool = False) -> None:
617
+ def _execute_script_direct(conf: ConfigData, *, profile: bool = False, profile_limit: int = 20) -> None:
614
618
  """Run runscripts() in the current (main) thread — used when Textual is not active.
615
619
 
616
620
  Args:
@@ -618,6 +622,8 @@ def _execute_script_direct(conf: ConfigData, *, profile: bool = False) -> None:
618
622
  profile: When ``True``, print a per-statement timing summary after the
619
623
  script completes. Timing data must already have been activated on
620
624
  ``_state.profile_data`` before this function is called.
625
+ profile_limit: Maximum number of top statements to display in the
626
+ profile summary (default: 20).
621
627
  """
622
628
  import execsql.state as _state
623
629
  import execsql.utils.gui as _gui
@@ -644,7 +650,7 @@ def _execute_script_direct(conf: ConfigData, *, profile: bool = False) -> None:
644
650
  gui_console_off()
645
651
  _state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
646
652
  if profile and _state.profile_data is not None:
647
- _print_profile(_state.profile_data)
653
+ _print_profile(_state.profile_data, limit=profile_limit)
648
654
  sys.exit(exc.code)
649
655
  except ConfigError:
650
656
  raise
@@ -672,7 +678,7 @@ def _execute_script_direct(conf: ConfigData, *, profile: bool = False) -> None:
672
678
  gui_console_off()
673
679
  _state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
674
680
  if profile and _state.profile_data is not None:
675
- _print_profile(_state.profile_data)
681
+ _print_profile(_state.profile_data, limit=profile_limit)
676
682
  _state.exec_log.log_exit_end()
677
683
 
678
684
 
@@ -0,0 +1,6 @@
1
+ """Debug utilities for execsql.
2
+
3
+ This package contains interactive debugging tools for execsql scripts:
4
+
5
+ - :mod:`execsql.debug.repl` — the ``BREAKPOINT`` metacommand REPL.
6
+ """
execsql/debug/repl.py ADDED
@@ -0,0 +1,472 @@
1
+ from __future__ import annotations
2
+
3
+ """Interactive debug REPL metacommand handler for execsql.
4
+
5
+ Implements ``x_breakpoint`` — the ``BREAKPOINT`` metacommand — which pauses
6
+ script execution and drops into an interactive read-eval-print loop.
7
+
8
+ The REPL allows the user to:
9
+
10
+ - Inspect and print substitution variables.
11
+ - Run ad-hoc SQL queries against the current database.
12
+ - Step through the script one statement at a time.
13
+ - Resume or abort execution.
14
+
15
+ All REPL commands are dot-prefixed (``.continue``, ``.vars``, ``.next``)
16
+ to avoid ambiguity with variable names and SQL. Anything not starting
17
+ with ``.`` is treated as either a variable lookup (if it matches a known
18
+ variable) or SQL (if it ends with ``;``).
19
+
20
+ In non-interactive environments (CI, piped input, ``sys.stdin.isatty()`` is
21
+ ``False``) the metacommand is silently skipped so automated pipelines are not
22
+ blocked.
23
+ """
24
+
25
+ import os
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ import execsql.state as _state
31
+
32
+ __all__ = ["x_breakpoint"]
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # ANSI color support — auto-detected, respects NO_COLOR / EXECSQL_NO_COLOR
36
+ # ---------------------------------------------------------------------------
37
+
38
+ _RESET = "\033[0m"
39
+ _BOLD = "\033[1m"
40
+ _DIM = "\033[2m"
41
+ _ITALIC = "\033[3m"
42
+ _RED = "\033[31m"
43
+ _GREEN = "\033[32m"
44
+ _YELLOW = "\033[33m"
45
+ _CYAN = "\033[36m"
46
+
47
+
48
+ def _use_color() -> bool:
49
+ """Return True if the output stream supports ANSI color.
50
+
51
+ Checks ``NO_COLOR`` and ``EXECSQL_NO_COLOR`` environment variables first
52
+ (either set → color off). Then tests whether the active output stream
53
+ reports itself as a TTY.
54
+ """
55
+ if os.environ.get("NO_COLOR") is not None:
56
+ return False
57
+ if os.environ.get("EXECSQL_NO_COLOR") is not None:
58
+ return False
59
+ output = _state.output
60
+ if output is not None and hasattr(output, "isatty"):
61
+ return output.isatty()
62
+ # WriteHooks (the default _state.output) has no isatty — fall through
63
+ # to check the underlying stream it would write to.
64
+ return sys.stdout.isatty()
65
+
66
+
67
+ def _c(code: str, text: str) -> str:
68
+ """Wrap *text* in an ANSI escape sequence when color is enabled.
69
+
70
+ Returns plain *text* unchanged when ``_use_color()`` is False so that
71
+ tests and non-TTY environments receive undecorated strings.
72
+
73
+ Args:
74
+ code: One or more ANSI escape codes (e.g. ``_BOLD + _CYAN``).
75
+ text: The text to colorize.
76
+
77
+ Returns:
78
+ ``f"{code}{text}{_RESET}"`` when color is on, else *text* unchanged.
79
+ """
80
+ if not _use_color():
81
+ return text
82
+ return f"{code}{text}{_RESET}"
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Public handler
87
+ # ---------------------------------------------------------------------------
88
+
89
+ _HELP_COMMANDS = [
90
+ (".continue", ".c", "Resume script execution"),
91
+ (".abort", ".q", "Halt the script (exit 1)"),
92
+ (".vars", ".v", "List user, system, local, and counter variables"),
93
+ (".vars all", ".v all", "Include environment variables (&) in the listing"),
94
+ (".next", ".n", "Execute the next statement then pause again (step mode)"),
95
+ (".where", ".w", "Show the current script location and upcoming statement"),
96
+ (".stack", "", "Show the command-list stack (script name, line, depth)"),
97
+ (".set VAR VAL", ".s", "Set or update a substitution variable"),
98
+ (".help", ".h", "Show this help text"),
99
+ ]
100
+
101
+ _HELP_OTHER = [
102
+ ("varname", "Print a variable's value (e.g. logfile, $ARG_1, &HOME)"),
103
+ ("SELECT ...;", "Run SQL ending with ';' (expects columns returned, e.g. SELECT)"),
104
+ ]
105
+
106
+ _HELP_CMD_WIDTH = 13 # width of the command column
107
+ _HELP_SHORT_WIDTH = 7 # width of the shortcut column
108
+
109
+
110
+ def _format_help() -> str:
111
+ """Build the help text with optional ANSI color."""
112
+ lines: list[str] = []
113
+ lines.append(f"{_c(_BOLD, 'execsql debug REPL')} {_c(_DIM, '— all commands start with')} {_c(_CYAN, '.')}")
114
+ lines.append("")
115
+ for cmd, short, desc in _HELP_COMMANDS:
116
+ cmd_col = _c(_CYAN, cmd.ljust(_HELP_CMD_WIDTH))
117
+ short_col = _c(_CYAN, short.ljust(_HELP_SHORT_WIDTH)) if short else " " * _HELP_SHORT_WIDTH
118
+ lines.append(f" {cmd_col} {short_col} {desc}")
119
+ lines.append("")
120
+ lines.append(f"{_c(_BOLD, 'Everything else:')}")
121
+ for cmd, desc in _HELP_OTHER:
122
+ cmd_col = _c(_DIM, cmd.ljust(_HELP_CMD_WIDTH + _HELP_SHORT_WIDTH + 2))
123
+ lines.append(f" {cmd_col} {desc}")
124
+ lines.append("")
125
+ return "\n".join(lines) + "\n"
126
+
127
+
128
+ _WHERE_TRUNCATE = 120
129
+
130
+
131
+ def x_breakpoint(**kwargs: Any) -> None:
132
+ """Pause execution and enter the interactive debug REPL.
133
+
134
+ If ``sys.stdin`` is not a TTY (CI, piped input), the metacommand is
135
+ silently skipped — scripts will not hang in automation.
136
+
137
+ Args:
138
+ **kwargs: Keyword arguments injected by the dispatch table (unused).
139
+ """
140
+ if not sys.stdin.isatty():
141
+ return
142
+ _debug_repl()
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # REPL core
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ def _write_rule(label: str) -> None:
151
+ """Print a horizontal rule with an embedded label.
152
+
153
+ The rule is ``──<label>`` followed by a fixed-width suffix of dashes,
154
+ giving a consistent visual separator regardless of terminal width.
155
+
156
+ Args:
157
+ label: Text (may include ANSI codes) to embed in the rule.
158
+ """
159
+ _write(f"{_c(_DIM, '──')}{label}{_c(_DIM, '─' * 40)}\n")
160
+
161
+
162
+ def _debug_repl(*, step: bool = False) -> None:
163
+ """Interactive read-eval-print loop for script debugging.
164
+
165
+ Reads commands from stdin until the user types ``.continue`` or ``.abort``,
166
+ or until EOF / KeyboardInterrupt.
167
+
168
+ Args:
169
+ step: When ``True``, the entry banner says "Step" instead of
170
+ "Breakpoint" to indicate the REPL was re-entered via step mode.
171
+ """
172
+ try:
173
+ import readline as _readline # noqa: F401 — side-effect: enables history/arrow keys
174
+ except ImportError:
175
+ pass # readline not available on Windows; continue without it
176
+
177
+ label_word = "Step" if step else "Breakpoint"
178
+ lc = _state.last_command
179
+ if lc is not None:
180
+ location = f"{Path(lc.source).name}:{lc.line_no}"
181
+ type_tag = lc.command_type
182
+ text = lc.command.commandline()
183
+ if len(text) > _WHERE_TRUNCATE:
184
+ text = text[:_WHERE_TRUNCATE] + "..."
185
+ rule_label = f" {_c(_BOLD + _YELLOW, label_word)} {_c(_DIM, '──')} {_c(_CYAN, location)} "
186
+ else:
187
+ type_tag = None
188
+ text = None
189
+ location = "(position unknown)"
190
+ rule_label = f" {_c(_BOLD + _YELLOW, label_word)} "
191
+
192
+ _write("\n")
193
+ _write_rule(rule_label)
194
+ if type_tag and text:
195
+ _write(f" {_c(_DIM + _GREEN, '(' + type_tag + ')')} {text}\n")
196
+ _hint_help = _c(_DIM, "'.help'")
197
+ _hint_c = _c(_DIM, "'.c'")
198
+ _write(f" Type {_hint_help} for commands, {_hint_c} to resume.\n\n")
199
+
200
+ while True:
201
+ try:
202
+ line = input("execsql debug> ").strip()
203
+ except EOFError:
204
+ _write("\n")
205
+ return # Ctrl-D → continue
206
+ except KeyboardInterrupt:
207
+ _write("\n")
208
+ return # Ctrl-C → continue
209
+
210
+ if not line:
211
+ continue
212
+
213
+ # Dot-prefixed → REPL command
214
+ if line.startswith("."):
215
+ cmd = line[1:].strip().lower()
216
+ _handle_dot_command(line)
217
+ if cmd in ("continue", "c"):
218
+ return
219
+ if cmd in ("abort", "q", "quit"):
220
+ # _handle_dot_command already raised SystemExit, but guard anyway
221
+ return
222
+ if cmd in ("next", "n"):
223
+ return
224
+ continue
225
+
226
+ # SQL (ends with semicolon)
227
+ if line.rstrip().endswith(";"):
228
+ _run_sql(line)
229
+ continue
230
+
231
+ # Everything else → variable lookup
232
+ _print_var(line)
233
+
234
+
235
+ def _handle_dot_command(line: str) -> None:
236
+ """Dispatch a dot-prefixed REPL command."""
237
+ # Strip the leading dot and normalize
238
+ cmd = line[1:].strip().lower()
239
+
240
+ if cmd in ("continue", "c"):
241
+ return # caller checks and returns from _debug_repl
242
+ elif cmd in ("abort", "q", "quit"):
243
+ raise SystemExit(1)
244
+ elif cmd in ("help", "h"):
245
+ _write(_format_help())
246
+ elif cmd in ("vars all", "v all"):
247
+ _print_all_vars(include_env=True)
248
+ elif cmd in ("vars", "v"):
249
+ _print_all_vars()
250
+ elif cmd in ("where", "w"):
251
+ _print_where()
252
+ elif cmd == "stack":
253
+ _print_stack()
254
+ elif cmd in ("next", "n"):
255
+ _enable_step_mode()
256
+ elif cmd.startswith("set ") or cmd == "set":
257
+ # .set VAR VAL — set or update a substitution variable
258
+ rest = cmd[4:].strip() if cmd.startswith("set ") else ""
259
+ if not rest:
260
+ _write(" Usage: .set VAR VALUE\n")
261
+ else:
262
+ parts = rest.split(None, 1)
263
+ varname = parts[0]
264
+ value = parts[1] if len(parts) > 1 else ""
265
+ _set_var(varname, value)
266
+ elif cmd.startswith("s ") or cmd == "s":
267
+ # .s VAR VAL — shorthand for .set
268
+ rest = cmd[2:].strip() if cmd.startswith("s ") else ""
269
+ if not rest:
270
+ _write(" Usage: .s VAR VALUE\n")
271
+ else:
272
+ parts = rest.split(None, 1)
273
+ varname = parts[0]
274
+ value = parts[1] if len(parts) > 1 else ""
275
+ _set_var(varname, value)
276
+ else:
277
+ _write(f" {_c(_RED, 'Unknown command:')} {line!r}. Type '.help' for available commands.\n")
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # REPL command implementations
282
+ # ---------------------------------------------------------------------------
283
+
284
+
285
+ def _write(text: str) -> None:
286
+ """Write *text* to the execsql output stream (falls back to stdout)."""
287
+ output = _state.output
288
+ if output is not None:
289
+ output.write(text)
290
+ else:
291
+ sys.stdout.write(text)
292
+ sys.stdout.flush()
293
+
294
+
295
+ def _print_where() -> None:
296
+ """Print the current script location and the upcoming statement.
297
+
298
+ Reads ``_state.last_command`` (a :class:`ScriptCmd`) and displays the
299
+ filename, line number, command type, and (truncated) statement text.
300
+ If ``last_command`` is ``None``, reports that the position is unknown.
301
+ """
302
+ lc = _state.last_command
303
+ if lc is None:
304
+ _write(" (position unknown)\n")
305
+ return
306
+ filename = Path(lc.source).name
307
+ location = f"{filename}:{lc.line_no}"
308
+ rule_label = f" {_c(_BOLD + _YELLOW, 'Location')} {_c(_DIM, '──')} {_c(_CYAN, location)} "
309
+ _write_rule(rule_label)
310
+ text = lc.command.commandline()
311
+ if len(text) > _WHERE_TRUNCATE:
312
+ text = text[:_WHERE_TRUNCATE] + "..."
313
+ _write(f" {_c(_DIM + _GREEN, '(' + lc.command_type + ')')} {text}\n\n")
314
+
315
+
316
+ def _print_all_vars(*, include_env: bool = False) -> None:
317
+ """Print substitution variables grouped by type."""
318
+ subvars = _state.subvars
319
+ if subvars is None:
320
+ _write(" (no substitution variables defined)\n\n")
321
+ return
322
+ items = subvars.substitutions # list of (name, value) tuples
323
+ if not items:
324
+ _write(" (no substitution variables defined)\n\n")
325
+ return
326
+
327
+ # Group by prefix.
328
+ user_vars: list[tuple[str, str]] = []
329
+ system_vars: list[tuple[str, str]] = []
330
+ counter_vars: list[tuple[str, str]] = []
331
+ local_vars: list[tuple[str, str]] = []
332
+ env_vars: list[tuple[str, str]] = []
333
+
334
+ for name, value in sorted(items):
335
+ if name.startswith("&"):
336
+ env_vars.append((name, value))
337
+ elif name.startswith("~"):
338
+ local_vars.append((name, value))
339
+ elif name.startswith("@"):
340
+ counter_vars.append((name, value))
341
+ elif name.startswith("$"):
342
+ system_vars.append((name, value))
343
+ else:
344
+ user_vars.append((name, value))
345
+
346
+ _write_rule(f" {_c(_BOLD + _YELLOW, 'Variables')} ")
347
+
348
+ def _print_group(label: str, group: list[tuple[str, str]]) -> None:
349
+ if not group:
350
+ return
351
+ _write(f" {_c(_BOLD, label)}:\n")
352
+ max_name = max(len(n) for n, _ in group)
353
+ for name, value in group:
354
+ _write(f" {_c(_CYAN, name):<{max_name}} {_c(_DIM, '=')} {value}\n")
355
+
356
+ _print_group("User", user_vars)
357
+ _print_group("System ($)", system_vars)
358
+ _print_group("Local (~)", local_vars)
359
+ _print_group("Counter (@)", counter_vars)
360
+ if include_env:
361
+ _print_group("Environment (&)", env_vars)
362
+
363
+ if not any([user_vars, system_vars, local_vars, counter_vars]):
364
+ if env_vars:
365
+ _write(" (no script variables defined — use '.vars all' to see environment variables)\n")
366
+ else:
367
+ _write(" (no variables defined)\n")
368
+ _write("\n")
369
+
370
+
371
+ def _print_var(varname: str) -> None:
372
+ """Print the value of a single substitution variable.
373
+
374
+ Tries the name as typed, then with the sigil prefix stripped.
375
+ """
376
+ subvars = _state.subvars
377
+ if subvars is None:
378
+ _write(f" {varname}: (substitution variables not initialised)\n")
379
+ return
380
+ # Try the name as typed first, then without the sigil prefix ($, &, @, #, ~).
381
+ # SUB creates variables without a prefix (e.g., "logfile"), but users
382
+ # may type "$logfile" at the prompt.
383
+ value = subvars.varvalue(varname)
384
+ if value is None and len(varname) > 1 and varname[0] in "$&@#~":
385
+ value = subvars.varvalue(varname[1:])
386
+ if value is None:
387
+ _write(f" {_c(_CYAN, varname)}: {_c(_DIM, '(undefined)')}\n")
388
+ else:
389
+ _write(f" {_c(_CYAN, varname)} {_c(_DIM, '=')} {value}\n")
390
+
391
+
392
+ def _print_stack() -> None:
393
+ """Print the current command-list stack (script name, line number, depth)."""
394
+ stack = _state.commandliststack
395
+ if not stack:
396
+ _write(" (command list stack is empty)\n\n")
397
+ return
398
+ _write_rule(f" {_c(_BOLD + _YELLOW, 'Stack')} ")
399
+ _write(f" {_c(_DIM, 'depth:')} {len(stack)}\n")
400
+ for depth, cmdlist in enumerate(stack):
401
+ listname = getattr(cmdlist, "listname", "<unknown>")
402
+ cmdptr = getattr(cmdlist, "cmdptr", 0)
403
+ _write(f" [{depth}] {listname} {_c(_DIM, f'(cursor at index {cmdptr})')}\n")
404
+ _write("\n")
405
+
406
+
407
+ def _run_sql(sql: str) -> None:
408
+ """Execute ad-hoc SQL against the current database and pretty-print the results."""
409
+ dbs = _state.dbs
410
+ if dbs is None:
411
+ _write(" (no database connection is active)\n")
412
+ return
413
+ db = dbs.current()
414
+ if db is None:
415
+ _write(" (no database connection is active)\n")
416
+ return
417
+ try:
418
+ colnames, rows = db.select_data(sql)
419
+ except Exception as exc:
420
+ _write(f" {_c(_RED, 'SQL error:')} {exc}\n")
421
+ return
422
+
423
+ if not colnames:
424
+ _write(" (query returned no columns)\n")
425
+ return
426
+
427
+ # Build a simple text table.
428
+ col_widths = [len(c) for c in colnames]
429
+ str_rows: list[list[str]] = []
430
+ for row in rows:
431
+ str_row = ["NULL" if v is None else str(v) for v in row]
432
+ str_rows.append(str_row)
433
+ for i, cell in enumerate(str_row):
434
+ col_widths[i] = max(col_widths[i], len(cell))
435
+
436
+ sep = _c(_DIM, "+-" + "-+-".join("-" * w for w in col_widths) + "-+")
437
+ # Header: column names in bold
438
+ header_cells = " | ".join(_c(_BOLD, c.ljust(col_widths[i])) for i, c in enumerate(colnames))
439
+ header = _c(_DIM, "| ") + header_cells + _c(_DIM, " |")
440
+ _write(" " + sep + "\n")
441
+ _write(" " + header + "\n")
442
+ _write(" " + sep + "\n")
443
+ for str_row in str_rows:
444
+ cells = " | ".join(
445
+ _c(_DIM + _ITALIC, "NULL".ljust(col_widths[i])) if cell == "NULL" else cell.ljust(col_widths[i])
446
+ for i, cell in enumerate(str_row)
447
+ )
448
+ data_line = _c(_DIM, "| ") + cells + _c(_DIM, " |")
449
+ _write(" " + data_line + "\n")
450
+ _write(" " + sep + "\n")
451
+ row_word = "row" if len(str_rows) == 1 else "rows"
452
+ _write(f" {_c(_DIM, f'({len(str_rows)} {row_word})')}\n")
453
+
454
+
455
+ def _enable_step_mode() -> None:
456
+ """Activate step mode so the engine re-enters the REPL after the next statement."""
457
+ _state.step_mode = True
458
+
459
+
460
+ def _set_var(varname: str, value: str) -> None:
461
+ """Set or update a substitution variable in the current session.
462
+
463
+ Args:
464
+ varname: The variable name (without sigil prefix for user variables).
465
+ value: The value to assign to the variable.
466
+ """
467
+ subvars = _state.subvars
468
+ if subvars is None:
469
+ _write(" Error: substitution variables are not initialised.\n")
470
+ return
471
+ subvars.add_substitution(varname, value)
472
+ _write(f" {_c(_CYAN, varname)} {_c(_DIM, '=')} {value}\n")
execsql/exporters/xlsx.py CHANGED
@@ -178,6 +178,11 @@ def write_query_to_xlsx(
178
178
  wb.save(outfile)
179
179
  wb.close()
180
180
 
181
+ if _state.export_metadata is not None:
182
+ _state.export_metadata.add(
183
+ ExportRecord(queryname=select_stmt, outfile=outfile, zipfile=None, description=desc),
184
+ )
185
+
181
186
 
182
187
  def write_queries_to_xlsx(
183
188
  table_list: str,
execsql/exporters/yaml.py CHANGED
@@ -82,6 +82,8 @@ def write_query_to_yaml(
82
82
  f = ZipWriter(zipfile, outfile, append)
83
83
 
84
84
  try:
85
+ if append:
86
+ f.write("---\n")
85
87
  f.write(yaml_text)
86
88
  finally:
87
89
  f.close()
@@ -100,7 +100,7 @@ from execsql.metacommands.debug import (
100
100
  x_debug_write_subvars,
101
101
  x_debug_write_config,
102
102
  )
103
- from execsql.metacommands.debug_repl import x_breakpoint
103
+ from execsql.debug.repl import x_breakpoint
104
104
  from execsql.metacommands.io import (
105
105
  x_export,
106
106
  x_export_query,
@@ -1,5 +1,4 @@
1
1
  from __future__ import annotations
2
- from execsql.exceptions import ErrInfo
3
2
 
4
3
  """
5
4
  Conditional test handler functions for execsql.
@@ -15,6 +14,7 @@ at registration time.
15
14
  """
16
15
 
17
16
  import os
17
+ from execsql.exceptions import ErrInfo
18
18
  import time
19
19
  from pathlib import Path
20
20
  from typing import Any