execsql2 2.23.0__py3-none-any.whl → 2.24.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- execsql/cli/__init__.py +17 -586
- execsql/cli/application.py +309 -0
- execsql/cli/commands/__init__.py +13 -0
- execsql/cli/commands/config.py +205 -0
- execsql/cli/commands/format.py +100 -0
- execsql/cli/commands/init.py +135 -0
- execsql/cli/commands/lint.py +233 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +451 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +513 -76
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +41 -14
- execsql/config.py +251 -154
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +24 -1
- execsql/data/script_header.sql +23 -0
- execsql/db/access.py +22 -6
- execsql/db/base.py +105 -1
- execsql/db/dsn.py +2 -0
- execsql/db/duckdb.py +95 -2
- execsql/db/factory.py +2 -2
- execsql/db/mysql.py +11 -0
- execsql/db/oracle.py +12 -4
- execsql/db/sqlite.py +16 -0
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/format.py +87 -75
- execsql/interactive.py +232 -0
- execsql/manifest.py +189 -0
- execsql/metacommands/conditions.py +1 -1
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/effects.py +124 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +6 -93
- execsql/script/executor.py +153 -34
- execsql/state.py +11 -2
- execsql/state.pyi +3 -0
- execsql/utils/color.py +18 -0
- execsql/utils/errors.py +11 -1
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/RECORD +62 -46
- execsql2-2.24.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.0.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.0.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.0.dist-info → execsql2-2.24.0.dist-info}/licenses/NOTICE +0 -0
execsql/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
|
-
|
|
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
|
-
"""
|
|
3
|
+
"""The debug REPL a ``BREAKPOINT`` (or ``run --debug``) opens.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
248
|
+
elif command == "stack":
|
|
323
249
|
_print_stack()
|
|
324
|
-
elif
|
|
325
|
-
|
|
326
|
-
|
|
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``
|
|
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
|
|
535
|
-
"""
|
|
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
|
|