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/db/duckdb.py CHANGED
@@ -8,18 +8,55 @@ analytics databases via the ``duckdb`` package. Corresponds to ``-t k``
8
8
  on the CLI.
9
9
  """
10
10
 
11
+ import re
11
12
  from pathlib import Path
13
+ from typing import Any
12
14
 
13
- from execsql.db.base import Database
15
+ from execsql.db.base import Database, statement_start
14
16
  from execsql.exceptions import ErrInfo
15
17
  from execsql.utils.errors import exception_desc, fatal_error
16
18
  import execsql.state as _state
17
19
 
18
20
  __all__ = ["DuckDBDatabase"]
19
21
 
22
+ # Statements that return rows; any other statement DuckDB answers with a
23
+ # one-column "Count" result (rows inserted, updated or deleted).
24
+ _QUERY_RX = re.compile(r"(?:SELECT|WITH|VALUES|FROM|TABLE|SHOW|DESCRIBE|SUMMARIZE|EXPLAIN|PRAGMA|CALL)\b|\(", re.I)
25
+
26
+ # A statement that opens a transaction, or one that ends it.
27
+ _TRANSACTION_RX = re.compile(r"(?P<begin>BEGIN|START\s+TRANSACTION)\b|(?P<end>COMMIT|END|ROLLBACK|ABORT)\b", re.I)
28
+
29
+
30
+ class _SharedCursor:
31
+ """The connection, used as a cursor so a statement joins its open transaction.
32
+
33
+ A DuckDB cursor is a separate connection with a transaction of its own,
34
+ and closing it rolls that transaction back. ``close()`` therefore leaves
35
+ the connection open.
36
+ """
37
+
38
+ def __init__(self, conn: Any) -> None:
39
+ self._conn = conn
40
+
41
+ def __getattr__(self, name: str) -> Any:
42
+ return getattr(self._conn, name)
43
+
44
+ def close(self) -> None:
45
+ pass
46
+
20
47
 
21
48
  class DuckDBDatabase(Database):
22
- """DuckDB in-process analytics adapter using the duckdb package."""
49
+ """DuckDB in-process analytics adapter using the duckdb package.
50
+
51
+ The duckdb driver commits each statement unless a transaction was begun,
52
+ and has no way to ask whether one is open, so this adapter begins one
53
+ itself while statements are held (AUTOCOMMIT OFF, BEGIN BATCH) and keeps
54
+ track of it. While it is open, every statement runs on the connection
55
+ itself rather than on a cursor (see :class:`_SharedCursor`).
56
+ """
57
+
58
+ #: A transaction this adapter or a script's own BEGIN opened, not yet ended.
59
+ in_transaction: bool = False
23
60
 
24
61
  def __init__(self, DuckDB_fn: str) -> None:
25
62
  try:
@@ -43,6 +80,62 @@ class DuckDBDatabase(Database):
43
80
  def __repr__(self) -> str:
44
81
  return f"DuckDBDatabase({self.db_name!r})"
45
82
 
83
+ def cursor(self) -> Any:
84
+ """A cursor, or while a transaction is open, the connection itself."""
85
+ if self.conn is None:
86
+ self.open_db()
87
+ if self.in_transaction:
88
+ return _SharedCursor(self.conn)
89
+ return self.conn.cursor()
90
+
91
+ def begin_transaction(self) -> None:
92
+ """Open a transaction if none is open."""
93
+ if self.conn is None:
94
+ self.open_db()
95
+ if not self.in_transaction:
96
+ # A second BEGIN would abort the open transaction, hence the tracking.
97
+ self.conn.execute("BEGIN TRANSACTION")
98
+ self.in_transaction = True
99
+
100
+ def execute(self, sql: Any, paramlist: list | None = None, *, fetch: bool = False) -> tuple[list[str], list] | None:
101
+ """Execute *sql*, noting a transaction the statement itself begins or ends.
102
+
103
+ The driver's ``rowcount`` is always -1: DuckDB answers an INSERT,
104
+ UPDATE or DELETE with a one-column ``Count`` result instead, which is
105
+ read here to set ``$LAST_ROWCOUNT`` as other drivers do.
106
+ """
107
+ text = " ".join(sql) if type(sql) in (tuple, list) else sql
108
+ start = statement_start(text)
109
+ m = _TRANSACTION_RX.match(start)
110
+ if m and m.group("begin"):
111
+ self.in_transaction = True # so the BEGIN itself runs on the connection
112
+ query = bool(_QUERY_RX.match(start))
113
+ result = super().execute(sql, paramlist, fetch=fetch or not query)
114
+ if m and m.group("end"):
115
+ self.in_transaction = False
116
+ if not query and result is not None and result[0] == ["Count"]:
117
+ rows = result[1]
118
+ if _state.subvars is not None:
119
+ _state.subvars.add_substitution("$LAST_ROWCOUNT", rows[0][0] if rows else -1)
120
+ return None
121
+ return result if fetch else None
122
+
123
+ def commit(self) -> None:
124
+ """Commit the open transaction if autocommit is enabled."""
125
+ super().commit()
126
+ if self.autocommit:
127
+ self.in_transaction = False
128
+
129
+ def rollback(self) -> None:
130
+ """Roll back the open transaction."""
131
+ super().rollback()
132
+ self.in_transaction = False
133
+
134
+ def close(self) -> None:
135
+ """Close the connection; an open transaction is rolled back."""
136
+ super().close()
137
+ self.in_transaction = False
138
+
46
139
  def open_db(self) -> None:
47
140
  """Open a connection to the DuckDB database file."""
48
141
  import duckdb
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/db/mysql.py CHANGED
@@ -49,6 +49,17 @@ def _crlf_terminated(filename: str) -> bool:
49
49
  class MySQLDatabase(Database):
50
50
  """MySQL and MariaDB adapter using the pymysql package."""
51
51
 
52
+ #: Statements MySQL commits at once, together with everything before them
53
+ #: ("Statements That Cause an Implicit Commit" in the MySQL manual).
54
+ #: CREATE and DROP TEMPORARY TABLE are the exception.
55
+ implicit_commit_rx = re.compile(
56
+ r"(?:CREATE|ALTER|DROP)\s+(?!TEMPORARY\b)(?:OR\s+REPLACE\s+)?(?:UNIQUE\s+|FULLTEXT\s+|SPATIAL\s+)?\w+"
57
+ r"|RENAME\s+\w+|TRUNCATE\b|GRANT\b|REVOKE\b|LOCK\s+TABLES?\b|UNLOCK\s+TABLES?\b"
58
+ r"|BEGIN\b|START\s+TRANSACTION\b|(?:ANALYZE|CHECK|OPTIMIZE|REPAIR)\s+TABLE\b|FLUSH\b"
59
+ r"|(?:UN)?INSTALL\s+PLUGIN\b|SET\s+PASSWORD\b|SET\s+(?:SESSION\s+|@@)?autocommit\b",
60
+ re.I,
61
+ )
62
+
52
63
  def __init__(
53
64
  self,
54
65
  server_name: str,
execsql/db/oracle.py CHANGED
@@ -8,6 +8,7 @@ via the ``oracledb`` driver (python-oracledb). Corresponds to ``-t o``
8
8
  on the CLI.
9
9
  """
10
10
 
11
+ import re
11
12
  from typing import Any
12
13
 
13
14
  from execsql.db.base import Database
@@ -23,6 +24,14 @@ __all__ = ["OracleDatabase"]
23
24
  class OracleDatabase(Database):
24
25
  """Oracle adapter using the cx_Oracle (python-oracledb) driver."""
25
26
 
27
+ #: Oracle commits every DDL statement at once, together with everything
28
+ #: before it, including CREATE GLOBAL TEMPORARY TABLE.
29
+ implicit_commit_rx = re.compile(
30
+ r"(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:GLOBAL\s+TEMPORARY\s+|UNIQUE\s+|BITMAP\s+)?\w+"
31
+ r"|TRUNCATE\s+\w+|RENAME\b|GRANT\b|REVOKE\b|COMMENT\s+ON\b|(?:NO)?AUDIT\b|ANALYZE\b|PURGE\b|FLASHBACK\b",
32
+ re.I,
33
+ )
34
+
26
35
  #: No Oracle server runs in CI; this adapter has no tests.
27
36
  support_tier = SupportTier.BEST_EFFORT
28
37
  support_tier_name = "Oracle"
@@ -110,13 +119,12 @@ class OracleDatabase(Database):
110
119
  msg = f"Failed to open Oracle database {self.db_name} on {self.server_name}"
111
120
  raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg) from e
112
121
 
113
- def execute(self, sql: Any, paramlist: list | None = None) -> None:
122
+ def execute(self, sql: Any, paramlist: list | None = None, *, fetch: bool = False) -> tuple[list[str], list] | None:
114
123
  """Execute a SQL command, stripping any trailing semicolon for Oracle."""
115
124
  # Strip any semicolon off the end and pass to the parent method.
116
125
  if sql[-1:] == ";":
117
- super().execute(sql[:-1], paramlist)
118
- else:
119
- super().execute(sql, paramlist)
126
+ return super().execute(sql[:-1], paramlist, fetch=fetch)
127
+ return super().execute(sql, paramlist, fetch=fetch)
120
128
 
121
129
  def select_data(self, sql: str) -> tuple[list[str], list]:
122
130
  """Return column names and all rows from a SELECT statement."""
execsql/db/sqlite.py CHANGED
@@ -26,6 +26,10 @@ DEFAULT_CONNECT_TIMEOUT = 30 # seconds
26
26
  class SQLiteDatabase(Database):
27
27
  """SQLite adapter using the Python standard-library sqlite3 module."""
28
28
 
29
+ #: SQLite refuses VACUUM, ATTACH and DETACH inside a transaction, and
30
+ #: ignores some PRAGMAs there (``foreign_keys``).
31
+ no_transaction_rx = re.compile(r"(?:VACUUM|ATTACH|DETACH|PRAGMA)\b", re.I)
32
+
29
33
  def __init__(self, SQLite_fn: str, timeout: float = DEFAULT_CONNECT_TIMEOUT) -> None:
30
34
  try:
31
35
  import sqlite3 # noqa: F401
@@ -48,6 +52,18 @@ class SQLiteDatabase(Database):
48
52
  def __repr__(self) -> str:
49
53
  return f"SQLiteDatabase({self.db_name!r})"
50
54
 
55
+ def begin_transaction(self) -> None:
56
+ """Open a transaction if none is open.
57
+
58
+ Python's sqlite3 module opens one on its own only before INSERT,
59
+ UPDATE, DELETE and REPLACE. Without this, a CREATE TABLE or DROP run
60
+ under AUTOCOMMIT OFF or in a batch would be committed at once.
61
+ """
62
+ if self.conn is None:
63
+ self.open_db()
64
+ if not self.conn.in_transaction:
65
+ self.conn.execute("BEGIN")
66
+
51
67
  def open_db(self) -> None:
52
68
  """Open a connection to the SQLite database file."""
53
69
  import sqlite3
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
execsql/exceptions.py CHANGED
@@ -35,6 +35,7 @@ Note:
35
35
  __all__ = [
36
36
  "ExecSqlError",
37
37
  "ConfigError",
38
+ "ConfigFileError",
38
39
  "ExecSqlTimeoutError",
39
40
  "ErrInfo",
40
41
  "DataTypeError",
@@ -74,6 +75,14 @@ class ConfigError(ExecSqlError):
74
75
  """Raised for invalid or missing execsql configuration values."""
75
76
 
76
77
 
78
+ class ConfigFileError(ConfigError):
79
+ """A :class:`ConfigError` found while reading one config file.
80
+
81
+ The message starts with the file's path. ``execsql config --validate``
82
+ reports every problem in the file, with line numbers.
83
+ """
84
+
85
+
77
86
  class ExecSqlTimeoutError(ExecSqlError):
78
87
  """Timeout during alarm-timer operations.
79
88