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.
Files changed (56) 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/factory.py +2 -2
  20. execsql/debug/repl.py +51 -193
  21. execsql/exceptions.py +9 -0
  22. execsql/format.py +87 -75
  23. execsql/interactive.py +232 -0
  24. execsql/manifest.py +189 -0
  25. execsql/metacommands/conditions.py +1 -1
  26. execsql/metacommands/control.py +2 -3
  27. execsql/metacommands/effects.py +124 -0
  28. execsql/script/__init__.py +0 -9
  29. execsql/script/ast.py +1 -1
  30. execsql/script/engine.py +6 -93
  31. execsql/script/executor.py +153 -34
  32. execsql/state.py +11 -2
  33. execsql/state.pyi +3 -0
  34. execsql/utils/color.py +18 -0
  35. execsql/utils/errors.py +11 -1
  36. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
  37. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
  38. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/RECORD +55 -39
  39. execsql2-2.24.0.dist-info/entry_points.txt +2 -0
  40. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  41. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  42. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  43. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  44. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  45. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  46. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  47. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  48. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  49. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  50. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
  51. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  52. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  53. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  54. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
  55. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
  56. {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-config > execsql.conf
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
- """Interactive debug REPL metacommand handler for execsql.
3
+ """The debug REPL a ``BREAKPOINT`` (or ``run --debug``) opens.
4
4
 
5
- Implements ``x_breakpoint`` — the ``BREAKPOINT`` metacommand — which pauses
6
- script execution and drops into an interactive read-eval-print loop.
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
- The REPL allows the user to:
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
- - 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
- 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 os.environ.get("NO_COLOR") is not None or os.environ.get("EXECSQL_NO_COLOR") is not None:
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 partial multi-line SQL buffer"),
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 — multi-line accepted, terminate with ';' to execute"),
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 commands from stdin until the user types ``.continue`` or ``.abort``,
200
- or until EOF / KeyboardInterrupt.
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
- sql_buffer: list[str] = []
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
- if not line:
257
- continue
258
-
259
- try:
260
- if line.startswith("."):
261
- cmd = line[1:].strip().lower()
262
- if cmd == "cancel":
263
- if sql_buffer:
264
- sql_buffer.clear()
265
- _write(" (input discarded)\n")
266
- continue
267
- _handle_dot_command(line)
268
- if cmd in ("continue", "c"):
269
- return
270
- if cmd in ("abort", "q", "quit"):
271
- return
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
- elif cmd in ("help", "h"):
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 cmd in ("vars", "v"):
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 cmd == "stack":
248
+ elif command == "stack":
323
249
  _print_stack()
324
- elif cmd in ("next", "n"):
325
- _enable_step_mode()
326
- elif cmd.startswith("set ") or cmd == "set":
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`` (a :class:`ScriptCmd`) and displays the
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 _run_sql(sql: str) -> None:
535
- """Execute ad-hoc SQL and pretty-print results or affected rowcount."""
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