execsql2 2.23.1__py3-none-any.whl → 2.25.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/api.py +169 -160
- execsql/cli/__init__.py +19 -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 +225 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +459 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/dsn.py +11 -4
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +780 -100
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +96 -32
- execsql/config.py +256 -173
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +38 -5
- execsql/data/script_header.sql +23 -0
- execsql/db/access.py +29 -8
- execsql/db/base.py +40 -8
- execsql/db/dsn.py +13 -4
- execsql/db/duckdb.py +3 -1
- execsql/db/factory.py +13 -4
- execsql/db/firebird.py +21 -7
- execsql/db/mysql.py +37 -16
- execsql/db/oracle.py +49 -16
- execsql/db/postgres.py +30 -6
- execsql/db/sqlite.py +7 -9
- execsql/db/sqlserver.py +25 -8
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +115 -76
- execsql/gui/desktop.py +1 -3
- execsql/gui/tui.py +2 -2
- execsql/importers/base.py +35 -1
- execsql/importers/csv.py +3 -1
- execsql/interactive.py +232 -0
- execsql/manifest.py +200 -0
- execsql/metacommands/conditions.py +7 -8
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/effects.py +124 -0
- execsql/metacommands/io_fileops.py +62 -37
- execsql/metacommands/io_import.py +0 -1
- execsql/metacommands/prompt.py +2 -0
- execsql/metacommands/upsert.py +5 -2
- execsql/models.py +3 -2
- execsql/parser.py +14 -5
- execsql/plugins.py +24 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +46 -104
- execsql/script/executor.py +162 -39
- execsql/script/parser.py +167 -118
- execsql/state.py +34 -11
- execsql/state.pyi +11 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/color.py +18 -0
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +82 -10
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
- execsql2-2.25.0.dist-info/RECORD +140 -0
- execsql2-2.25.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.1.dist-info/RECORD +0 -124
- execsql2-2.23.1.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
execsql/format.py
CHANGED
|
@@ -17,12 +17,12 @@ import re
|
|
|
17
17
|
from collections.abc import Iterator
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
__all__ = ["collect_paths", "format_file", "
|
|
20
|
+
__all__ = ["collect_paths", "format_file", "open_dollar_quote", "parse_keyword", "run_formatter"]
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
_SQLGLOT_MISSING_MSG = (
|
|
24
|
-
"execsql
|
|
25
|
-
|
|
24
|
+
"execsql format requires sqlglot for SQL reformatting.\n"
|
|
25
|
+
' Install the formatter extra: uv tool install "execsql2[formatter]" (or pip install "execsql2[formatter]")\n'
|
|
26
26
|
" Or skip SQL reformatting with the --no-sql flag."
|
|
27
27
|
)
|
|
28
28
|
|
|
@@ -122,7 +122,7 @@ CONTINUATION = frozenset({"ANDIF", "ORIF"}) # emit at depth-1, no depth change
|
|
|
122
122
|
# Inline IF: "IF (cond) { command }" — self-contained, no ENDIF, no depth
|
|
123
123
|
# change. Pattern must accept the same payloads as
|
|
124
124
|
# src/execsql/script/parser.py:_IF_INLINE_RX. Kept as a separate compiled
|
|
125
|
-
# pattern (not an import) so
|
|
125
|
+
# pattern (not an import) so the formatter doesn't pull in the AST parser
|
|
126
126
|
# module graph at startup; tests/test_format.py has a drift check that
|
|
127
127
|
# asserts both regexes recognise the same inputs.
|
|
128
128
|
_IF_INLINE_RE = re.compile(r"^\s*IF\s*\(\s*.+\s*\)\s*\{.+\}\s*$", re.I)
|
|
@@ -286,6 +286,33 @@ def _iter_sql_spans(sql: str) -> Iterator[tuple[int, int, str]]:
|
|
|
286
286
|
i += 1
|
|
287
287
|
|
|
288
288
|
|
|
289
|
+
def open_dollar_quote(sql: str, open_tag: str | None = None) -> str | None:
|
|
290
|
+
"""The dollar-quote tag (``$$`` or ``$tag$``) still open at the end of *sql*, or ``None``.
|
|
291
|
+
|
|
292
|
+
*open_tag* is a tag already open where *sql* starts, as when a script is
|
|
293
|
+
split at a ``;`` inside a dollar-quoted body and *sql* is a later piece.
|
|
294
|
+
Comments, quoted identifiers and ``'...'`` literals are skipped, so a
|
|
295
|
+
``$$`` inside them is not taken for a marker.
|
|
296
|
+
"""
|
|
297
|
+
start = 0
|
|
298
|
+
if open_tag is not None:
|
|
299
|
+
close = sql.find(open_tag)
|
|
300
|
+
if close < 0:
|
|
301
|
+
return open_tag
|
|
302
|
+
start = close + len(open_tag)
|
|
303
|
+
rest = sql[start:]
|
|
304
|
+
for span_start, span_stop, kind in _iter_sql_spans(rest):
|
|
305
|
+
if kind != "dollar":
|
|
306
|
+
continue
|
|
307
|
+
m = _DOLLAR_QUOTE_RE.match(rest, span_start)
|
|
308
|
+
assert m is not None
|
|
309
|
+
tag = m.group(0)
|
|
310
|
+
body = rest[span_start:span_stop]
|
|
311
|
+
if len(body) < 2 * len(tag) or not body.endswith(tag):
|
|
312
|
+
return tag
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
289
316
|
def _iter_sql_literals(sql: str) -> Iterator[tuple[int, int, str]]:
|
|
290
317
|
"""Yield ``(start, end, kind)`` spans for every string literal in *sql*.
|
|
291
318
|
|
|
@@ -1204,90 +1231,102 @@ def collect_paths(inputs: list[Path]) -> list[Path]:
|
|
|
1204
1231
|
|
|
1205
1232
|
|
|
1206
1233
|
# ---------------------------------------------------------------------------
|
|
1207
|
-
# Entry point (execsql
|
|
1234
|
+
# Entry point (execsql format)
|
|
1208
1235
|
# ---------------------------------------------------------------------------
|
|
1209
1236
|
|
|
1210
1237
|
|
|
1211
|
-
def
|
|
1212
|
-
|
|
1238
|
+
def run_formatter(
|
|
1239
|
+
targets: list[Path],
|
|
1240
|
+
*,
|
|
1241
|
+
check: bool = False,
|
|
1242
|
+
in_place: bool = False,
|
|
1243
|
+
no_sql: bool = False,
|
|
1244
|
+
indent: int = 4,
|
|
1245
|
+
leading_comma: bool = False,
|
|
1246
|
+
encoding: str = "utf-8",
|
|
1247
|
+
diff: bool = False,
|
|
1248
|
+
) -> int:
|
|
1249
|
+
"""Format or check *targets*; return the process exit code.
|
|
1250
|
+
|
|
1251
|
+
The options are declared once, on the ``execsql format`` command, and
|
|
1252
|
+
arrive here as arguments. Previously they lived on a Typer app built
|
|
1253
|
+
inside ``main()``, which meant the command that wrapped it had no options
|
|
1254
|
+
of its own to show and ``execsql format --help`` listed nothing.
|
|
1255
|
+
|
|
1256
|
+
With *diff*, nothing is written: each file that would change is printed
|
|
1257
|
+
as a unified diff, and the exit code is 1 when any would, as for *check*.
|
|
1258
|
+
"""
|
|
1213
1259
|
import sys
|
|
1214
1260
|
|
|
1215
|
-
import typer
|
|
1216
1261
|
from rich.console import Console
|
|
1217
1262
|
|
|
1218
1263
|
_console = Console()
|
|
1219
1264
|
_err_console = Console(stderr=True)
|
|
1220
1265
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1266
|
+
use_sql = not no_sql
|
|
1267
|
+
paths = collect_paths(targets)
|
|
1268
|
+
if not paths:
|
|
1269
|
+
_err_console.print("[bold red]Error:[/bold red] No .sql files found.")
|
|
1270
|
+
return 1
|
|
1271
|
+
|
|
1272
|
+
any_changed = False
|
|
1273
|
+
any_errors = False
|
|
1274
|
+
for path in paths:
|
|
1275
|
+
stdin = str(path) == "-"
|
|
1276
|
+
label = "<stdin>" if stdin else str(path)
|
|
1277
|
+
try:
|
|
1278
|
+
source = sys.stdin.buffer.read().decode(encoding) if stdin else path.read_text(encoding=encoding)
|
|
1279
|
+
except OSError as exc:
|
|
1280
|
+
_err_console.print(f"[bold red]Error:[/bold red] reading {label}: {exc}")
|
|
1281
|
+
any_errors = True
|
|
1282
|
+
# Collect read errors instead of short-circuiting so a single
|
|
1283
|
+
# unreadable file doesn't hide the rest of the report.
|
|
1284
|
+
continue
|
|
1285
|
+
except UnicodeDecodeError as exc:
|
|
1286
|
+
_err_console.print(
|
|
1287
|
+
f"[bold red]Error:[/bold red] decoding {label} as {encoding}: {exc}. "
|
|
1288
|
+
f"Try [bold]--script-encoding cp1252[/bold] or another text encoding.",
|
|
1289
|
+
)
|
|
1290
|
+
any_errors = True
|
|
1291
|
+
continue
|
|
1228
1292
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
encoding: str = typer.Option(
|
|
1246
|
-
"utf-8",
|
|
1247
|
-
"--encoding",
|
|
1248
|
-
metavar="NAME",
|
|
1249
|
-
help="Text encoding used to read and write SQL files (default utf-8).",
|
|
1250
|
-
),
|
|
1251
|
-
) -> None:
|
|
1252
|
-
use_sql = not no_sql
|
|
1253
|
-
paths = collect_paths(targets)
|
|
1254
|
-
if not paths:
|
|
1255
|
-
_err_console.print("[bold red]Error:[/bold red] No .sql files found.")
|
|
1256
|
-
raise typer.Exit(code=1)
|
|
1257
|
-
|
|
1258
|
-
any_changed = False
|
|
1259
|
-
any_errors = False
|
|
1260
|
-
for path in paths:
|
|
1261
|
-
try:
|
|
1262
|
-
source = path.read_text(encoding=encoding)
|
|
1263
|
-
except OSError as exc:
|
|
1264
|
-
_err_console.print(f"[bold red]Error:[/bold red] reading {path}: {exc}")
|
|
1265
|
-
any_errors = True
|
|
1266
|
-
# Collect read errors instead of short-circuiting so a single
|
|
1267
|
-
# unreadable file doesn't hide the rest of the report.
|
|
1268
|
-
continue
|
|
1269
|
-
except UnicodeDecodeError as exc:
|
|
1270
|
-
_err_console.print(
|
|
1271
|
-
f"[bold red]Error:[/bold red] decoding {path} as {encoding}: {exc}. "
|
|
1272
|
-
f"Try [bold]--encoding cp1252[/bold] or another text encoding.",
|
|
1273
|
-
)
|
|
1274
|
-
any_errors = True
|
|
1275
|
-
continue
|
|
1293
|
+
formatted = format_file(source, indent=indent, use_sql=use_sql, leading_comma=leading_comma)
|
|
1294
|
+
|
|
1295
|
+
if diff:
|
|
1296
|
+
if formatted != source:
|
|
1297
|
+
any_changed = True
|
|
1298
|
+
_write_diff(source, formatted, label)
|
|
1299
|
+
elif check:
|
|
1300
|
+
if formatted != source:
|
|
1301
|
+
_console.print(f"would reformat {label}")
|
|
1302
|
+
any_changed = True
|
|
1303
|
+
elif in_place:
|
|
1304
|
+
if formatted != source:
|
|
1305
|
+
path.write_text(formatted, encoding=encoding)
|
|
1306
|
+
_console.print(f"reformatted {path}")
|
|
1307
|
+
else:
|
|
1308
|
+
sys.stdout.write(formatted)
|
|
1276
1309
|
|
|
1277
|
-
|
|
1310
|
+
return 1 if (any_errors or ((check or diff) and any_changed)) else 0
|
|
1278
1311
|
|
|
1279
|
-
if check:
|
|
1280
|
-
if formatted != source:
|
|
1281
|
-
_console.print(f"would reformat {path}")
|
|
1282
|
-
any_changed = True
|
|
1283
|
-
elif in_place:
|
|
1284
|
-
if formatted != source:
|
|
1285
|
-
path.write_text(formatted, encoding=encoding)
|
|
1286
|
-
_console.print(f"reformatted {path}")
|
|
1287
|
-
else:
|
|
1288
|
-
sys.stdout.write(formatted)
|
|
1289
1312
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1313
|
+
def _write_diff(before: str, after: str, label: str) -> None:
|
|
1314
|
+
"""Print a unified diff of one file, colored when stdout is a terminal."""
|
|
1315
|
+
import difflib
|
|
1316
|
+
import sys
|
|
1317
|
+
|
|
1318
|
+
from execsql.utils.color import color_disabled_by_env
|
|
1292
1319
|
|
|
1293
|
-
|
|
1320
|
+
lines = difflib.unified_diff(
|
|
1321
|
+
before.splitlines(keepends=True),
|
|
1322
|
+
after.splitlines(keepends=True),
|
|
1323
|
+
fromfile=label,
|
|
1324
|
+
tofile=label,
|
|
1325
|
+
)
|
|
1326
|
+
color = sys.stdout.isatty() and not color_disabled_by_env()
|
|
1327
|
+
styles = {"+": "\033[32m", "-": "\033[31m", "@": "\033[36m"}
|
|
1328
|
+
for line in lines:
|
|
1329
|
+
if not line.endswith("\n"):
|
|
1330
|
+
line += "\n\\n"
|
|
1331
|
+
style = styles.get(line[0]) if color and not line.startswith(("+++", "---")) else None
|
|
1332
|
+
sys.stdout.write(f"{style}{line.rstrip(chr(10))}\033[0m\n" if style else line)
|
execsql/gui/desktop.py
CHANGED
|
@@ -162,7 +162,6 @@ class MsgDialog:
|
|
|
162
162
|
class PauseDialog:
|
|
163
163
|
def __init__(self, root: tk.Tk, args: dict) -> None:
|
|
164
164
|
self.result: dict = {}
|
|
165
|
-
self._quit = False
|
|
166
165
|
|
|
167
166
|
win = tk.Toplevel(root)
|
|
168
167
|
win.title(args.get("title", "Pause"))
|
|
@@ -177,7 +176,6 @@ class PauseDialog:
|
|
|
177
176
|
)
|
|
178
177
|
|
|
179
178
|
countdown = args.get("countdown")
|
|
180
|
-
self._remaining = countdown
|
|
181
179
|
|
|
182
180
|
status_var = tk.StringVar(value="")
|
|
183
181
|
ttk.Label(frame, textvariable=status_var).pack()
|
|
@@ -897,7 +895,7 @@ class MapDialog:
|
|
|
897
895
|
frame,
|
|
898
896
|
text=(
|
|
899
897
|
"(Interactive map requires the tkintermapview package — "
|
|
900
|
-
"
|
|
898
|
+
"install the map extra, execsql2[map] — showing tabular data)"
|
|
901
899
|
),
|
|
902
900
|
wraplength=580,
|
|
903
901
|
justify="left",
|
execsql/gui/tui.py
CHANGED
|
@@ -12,7 +12,7 @@ SQL script in a background thread and keeps the main thread for the Textual
|
|
|
12
12
|
``_state.gui_manager_queue``; the conductor polls that queue and serves each
|
|
13
13
|
request by pushing the appropriate ``ModalScreen`` via ``push_screen_wait()``.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Textual is a dependency of execsql2, so it is installed with the package.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
@@ -33,7 +33,7 @@ try:
|
|
|
33
33
|
import textual # noqa: F401
|
|
34
34
|
except ImportError as _e:
|
|
35
35
|
raise ImportError(
|
|
36
|
-
"Textual is not installed.
|
|
36
|
+
"Textual is not installed. It is a dependency of execsql2; reinstall execsql2 to restore it.",
|
|
37
37
|
) from _e
|
|
38
38
|
|
|
39
39
|
from textual import on
|
execsql/importers/base.py
CHANGED
|
@@ -16,7 +16,39 @@ from execsql.exceptions import ErrInfo
|
|
|
16
16
|
from execsql.db.base import Database
|
|
17
17
|
import execsql.state as _state
|
|
18
18
|
|
|
19
|
-
__all__ = ["import_data_table"]
|
|
19
|
+
__all__ = ["import_data_table", "refuse_existing_table", "table_exists_if_known"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def table_exists_if_known(db: Database, table_name: str, schema_name: str | None = None) -> bool | None:
|
|
23
|
+
"""Whether the table exists, or ``None`` when the database cannot say (no catalog access, ...)."""
|
|
24
|
+
try:
|
|
25
|
+
return db.table_exists(table_name, schema_name)
|
|
26
|
+
except Exception:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def refuse_existing_table(db: Database, schema_name: str | None, table_name: str) -> None:
|
|
31
|
+
"""Stop a ``TO NEW`` before any DDL when the target table already exists.
|
|
32
|
+
|
|
33
|
+
Only the schema the CREATE TABLE will use counts: for an unqualified
|
|
34
|
+
name, the database's current schema (MySQL: database). Best effort:
|
|
35
|
+
when the database cannot say, the command goes ahead and the CREATE
|
|
36
|
+
TABLE reports any conflict itself.
|
|
37
|
+
"""
|
|
38
|
+
lookup_schema = schema_name
|
|
39
|
+
if schema_name is None and not db.unqualified_lookup_is_scoped:
|
|
40
|
+
try:
|
|
41
|
+
lookup_schema = db.current_schema()
|
|
42
|
+
except Exception:
|
|
43
|
+
lookup_schema = None
|
|
44
|
+
if lookup_schema is None:
|
|
45
|
+
return # An unqualified lookup would search every schema.
|
|
46
|
+
if table_exists_if_known(db, table_name, lookup_schema):
|
|
47
|
+
name = db.schema_qualified_table_name(schema_name, table_name)
|
|
48
|
+
raise ErrInfo(
|
|
49
|
+
type="cmd",
|
|
50
|
+
other_msg=f"Table {name} already exists",
|
|
51
|
+
)
|
|
20
52
|
|
|
21
53
|
|
|
22
54
|
def import_data_table(
|
|
@@ -74,6 +106,8 @@ def import_data_table(
|
|
|
74
106
|
return tablespec_cache
|
|
75
107
|
|
|
76
108
|
if is_new:
|
|
109
|
+
if is_new == 1:
|
|
110
|
+
refuse_existing_table(db, schemaname, tablename)
|
|
77
111
|
if is_new == 2:
|
|
78
112
|
tblspec = db.schema_qualified_table_name(schemaname, tablename)
|
|
79
113
|
try:
|
execsql/importers/csv.py
CHANGED
|
@@ -14,6 +14,7 @@ from typing import Any
|
|
|
14
14
|
|
|
15
15
|
from execsql.exceptions import ErrInfo
|
|
16
16
|
from execsql.db.base import Database
|
|
17
|
+
from execsql.importers.base import refuse_existing_table
|
|
17
18
|
import execsql.state as _state
|
|
18
19
|
from execsql.types import dbt_firebird
|
|
19
20
|
|
|
@@ -26,7 +27,6 @@ def importtable(
|
|
|
26
27
|
tablename: str,
|
|
27
28
|
filename: str,
|
|
28
29
|
is_new: Any,
|
|
29
|
-
skip_header_line: bool = True,
|
|
30
30
|
quotechar: str | None = None,
|
|
31
31
|
delimchar: str | None = None,
|
|
32
32
|
encoding: str | None = None,
|
|
@@ -49,6 +49,8 @@ def importtable(
|
|
|
49
49
|
quotechar = None
|
|
50
50
|
inf.lineformat(delimchar, quotechar, None)
|
|
51
51
|
if is_new in (1, 2):
|
|
52
|
+
if is_new == 1:
|
|
53
|
+
refuse_existing_table(db, schemaname, tablename)
|
|
52
54
|
inf.evaluate_column_types()
|
|
53
55
|
sql = inf.create_table(db.type, schemaname, tablename)
|
|
54
56
|
if is_new == 2:
|
execsql/interactive.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""The engine behind the debug REPL a ``BREAKPOINT`` (or ``run --debug``) opens.
|
|
2
|
+
|
|
3
|
+
Input runs as the next lines of the paused script
|
|
4
|
+
(:func:`~execsql.script.executor.execute_input`): where the script paused, in
|
|
5
|
+
its variable scope and transaction.
|
|
6
|
+
|
|
7
|
+
What runs, and how it runs, follows script rules:
|
|
8
|
+
|
|
9
|
+
- SQL ends with ``;`` and may span lines. Variables are substituted, and each
|
|
10
|
+
statement is committed unless ``AUTOCOMMIT OFF`` or a batch is in effect.
|
|
11
|
+
Results print as tables.
|
|
12
|
+
- A metacommand is typed as in a script (``-- !x! EXPORT …``) or without the
|
|
13
|
+
comment marker (``!x! EXPORT …``).
|
|
14
|
+
- A block (``IF`` … ``ENDIF``, ``LOOP``, ``BEGIN BATCH``, ``BEGIN SCRIPT``)
|
|
15
|
+
keeps the prompt reading until it is closed, then runs as a whole.
|
|
16
|
+
|
|
17
|
+
The one difference from a script: an error that would halt the run ends only
|
|
18
|
+
the input that caused it. The prompt reports it and reads the next input.
|
|
19
|
+
``HALT`` still ends the run.
|
|
20
|
+
|
|
21
|
+
A line starting with ``.`` is a command to the prompt itself. :class:`Prompt`
|
|
22
|
+
holds a prompt's name and its own commands; ``.vars``, ``.set``, ``.scripts``
|
|
23
|
+
and ``.cancel`` are handled here for any prompt.
|
|
24
|
+
|
|
25
|
+
Display helpers (colors, tables, variable listings) live in
|
|
26
|
+
:mod:`execsql.debug.repl` and are reached through the module, so tests that
|
|
27
|
+
patch them there see every caller.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
import execsql.state as _state
|
|
34
|
+
from execsql.exceptions import ErrInfo
|
|
35
|
+
|
|
36
|
+
__all__ = ["Prompt", "read_eval_loop", "run_input", "uncommitted"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Prompt:
|
|
40
|
+
"""What differs between the prompts; subclassed by each.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
name: The prompt text before ``>``, e.g. ``execsql``.
|
|
44
|
+
source: The name input is given in messages and ``$CURRENT_SCRIPT``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
name = "execsql"
|
|
48
|
+
source = "<prompt>"
|
|
49
|
+
|
|
50
|
+
def dot_command(self, command: str, argument: str, line: str) -> bool:
|
|
51
|
+
"""Handle a ``.`` command; return ``False`` to leave the prompt.
|
|
52
|
+
|
|
53
|
+
*command* is the lowercased word after the dot, *argument* the rest
|
|
54
|
+
of the line as typed.
|
|
55
|
+
"""
|
|
56
|
+
if not common_dot_command(command, argument, line):
|
|
57
|
+
unknown_dot_command(line)
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def uncommitted() -> bool:
|
|
62
|
+
"""Whether SQL run now waits for a commit: ``AUTOCOMMIT OFF`` or an open batch."""
|
|
63
|
+
status = _state.status
|
|
64
|
+
if status is not None and status.batch.in_batch():
|
|
65
|
+
return True
|
|
66
|
+
dbs = _state.dbs
|
|
67
|
+
if dbs is None:
|
|
68
|
+
return False
|
|
69
|
+
try:
|
|
70
|
+
db = dbs.current()
|
|
71
|
+
except Exception:
|
|
72
|
+
return False
|
|
73
|
+
return db is not None and not db.autocommit
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _prompts(prompt: Prompt) -> tuple[str, str]:
|
|
77
|
+
"""The prompt and continuation-line strings; ``*`` marks uncommitted mode."""
|
|
78
|
+
first = f"{prompt.name}{'*' if uncommitted() else ''}> "
|
|
79
|
+
return first, "...> ".rjust(len(first))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_metacommand(line: str) -> bool:
|
|
83
|
+
stripped = line.strip()
|
|
84
|
+
return stripped.startswith("--") and stripped[2:].lstrip().lower().startswith("!x!")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _complete(line: str) -> bool:
|
|
88
|
+
"""Whether input ending with *line* may be whole: it ends with ``;`` or is a metacommand."""
|
|
89
|
+
return line.rstrip().endswith(";") or _is_metacommand(line)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _one_line(text: str) -> str:
|
|
93
|
+
return " ".join(text.split())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def describe_error(exc: BaseException) -> str:
|
|
97
|
+
"""One line for a prompt: the driver's or handler's own message, and where it came from."""
|
|
98
|
+
from execsql.debug import repl as _ui
|
|
99
|
+
|
|
100
|
+
label = "Error:"
|
|
101
|
+
if isinstance(exc, ErrInfo):
|
|
102
|
+
cause = exc.__cause__
|
|
103
|
+
if isinstance(cause, Exception) and not isinstance(cause, ErrInfo) and str(cause):
|
|
104
|
+
message = str(cause)
|
|
105
|
+
else:
|
|
106
|
+
message = exc.other or exc.exception or exc.type
|
|
107
|
+
if exc.command and exc.command not in message:
|
|
108
|
+
message = f"{message}: {exc.command}"
|
|
109
|
+
if exc.cmdtype == "sql":
|
|
110
|
+
label = "SQL error:"
|
|
111
|
+
source = exc.script_file
|
|
112
|
+
if source and not (source.startswith("<") and source.endswith(">")) and exc.script_line_no:
|
|
113
|
+
message = f"{message} (line {exc.script_line_no} of {source})"
|
|
114
|
+
else:
|
|
115
|
+
message = str(exc) or type(exc).__name__
|
|
116
|
+
return f"{_ui._c(_ui._RED, label)} {_one_line(message)}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _show_result(result: tuple[list[str], list] | None) -> None:
|
|
120
|
+
"""Print a statement's rows as a table, or how many rows it changed."""
|
|
121
|
+
from execsql.debug import repl as _ui
|
|
122
|
+
|
|
123
|
+
if result is not None:
|
|
124
|
+
_ui._print_table(*result)
|
|
125
|
+
return
|
|
126
|
+
count = -1
|
|
127
|
+
if _state.subvars is not None:
|
|
128
|
+
try:
|
|
129
|
+
count = int(_state.subvars.varvalue("$LAST_ROWCOUNT") or -1)
|
|
130
|
+
except (TypeError, ValueError):
|
|
131
|
+
count = -1
|
|
132
|
+
word = "row" if count == 1 else "rows"
|
|
133
|
+
_ui._write(f" {_ui._c(_ui._DIM, f'({count} {word} affected)' if count >= 0 else '(statement executed)')}\n")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run_input(text: str, source: str) -> None:
|
|
137
|
+
"""Parse *text* and run it as the next lines of the run; errors are raised."""
|
|
138
|
+
from execsql.script.executor import execute_input
|
|
139
|
+
from execsql.script.parser import parse_string
|
|
140
|
+
|
|
141
|
+
execute_input(parse_string(text + "\n", source), _show_result)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def common_dot_command(command: str, argument: str, line: str) -> bool:
|
|
145
|
+
"""Handle a command both prompts share; return ``False`` if *command* is not one."""
|
|
146
|
+
from execsql.debug import repl as _ui
|
|
147
|
+
|
|
148
|
+
if command in ("vars", "v"):
|
|
149
|
+
if argument.lower() == "all":
|
|
150
|
+
_ui._print_all_vars(include_env=True)
|
|
151
|
+
elif argument:
|
|
152
|
+
_ui._print_var(argument)
|
|
153
|
+
else:
|
|
154
|
+
_ui._print_all_vars()
|
|
155
|
+
elif command in ("set", "s"):
|
|
156
|
+
parts = argument.split(None, 1)
|
|
157
|
+
if not parts:
|
|
158
|
+
_ui._write(f" Usage: .{command} VAR VALUE\n")
|
|
159
|
+
else:
|
|
160
|
+
_ui._set_var(parts[0], parts[1] if len(parts) > 1 else "")
|
|
161
|
+
elif command == "scripts":
|
|
162
|
+
if argument:
|
|
163
|
+
_ui._print_script_detail(argument)
|
|
164
|
+
else:
|
|
165
|
+
_ui._print_scripts()
|
|
166
|
+
else:
|
|
167
|
+
return False
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def unknown_dot_command(line: str) -> None:
|
|
172
|
+
from execsql.debug import repl as _ui
|
|
173
|
+
|
|
174
|
+
_ui._write(f" {_ui._c(_ui._RED, 'Unknown command:')} {line!r}. Type '.help' for available commands.\n")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def read_eval_loop(prompt: Prompt) -> None:
|
|
178
|
+
"""Read and run inputs until the prompt's own command ends it, or input ends.
|
|
179
|
+
|
|
180
|
+
Lines are read with ``input()`` (line editing, a prompt). Ctrl-D or
|
|
181
|
+
Ctrl-C discards a half-typed input, and on an empty line leaves the
|
|
182
|
+
prompt.
|
|
183
|
+
"""
|
|
184
|
+
from execsql.debug import repl as _ui
|
|
185
|
+
|
|
186
|
+
buffer: list[str] = []
|
|
187
|
+
while True:
|
|
188
|
+
first, more = _prompts(prompt)
|
|
189
|
+
try:
|
|
190
|
+
line = input(more if buffer else first)
|
|
191
|
+
except (EOFError, KeyboardInterrupt):
|
|
192
|
+
if buffer:
|
|
193
|
+
buffer.clear()
|
|
194
|
+
_ui._write("\n (input discarded)\n")
|
|
195
|
+
continue
|
|
196
|
+
_ui._write("\n")
|
|
197
|
+
return
|
|
198
|
+
stripped = line.strip()
|
|
199
|
+
if not stripped and not buffer:
|
|
200
|
+
continue
|
|
201
|
+
if stripped.startswith("."):
|
|
202
|
+
command, _, argument = stripped[1:].partition(" ")
|
|
203
|
+
command = command.lower()
|
|
204
|
+
if command == "cancel":
|
|
205
|
+
if buffer:
|
|
206
|
+
buffer.clear()
|
|
207
|
+
_ui._write(" (input discarded)\n")
|
|
208
|
+
continue
|
|
209
|
+
try:
|
|
210
|
+
if not prompt.dot_command(command, argument.strip(), stripped):
|
|
211
|
+
return
|
|
212
|
+
except (SystemExit, KeyboardInterrupt):
|
|
213
|
+
raise
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
_ui._write(f" {describe_error(exc)}\n")
|
|
216
|
+
continue
|
|
217
|
+
if stripped.lower().startswith("!x!"):
|
|
218
|
+
line = "-- " + stripped
|
|
219
|
+
buffer.append(line)
|
|
220
|
+
if not _complete(line):
|
|
221
|
+
continue
|
|
222
|
+
try:
|
|
223
|
+
run_input("\n".join(buffer), prompt.source)
|
|
224
|
+
except ErrInfo as exc:
|
|
225
|
+
if "at end of file" in str(exc):
|
|
226
|
+
continue # a block is still open; keep reading
|
|
227
|
+
_ui._write(f" {describe_error(exc)}\n")
|
|
228
|
+
except KeyboardInterrupt:
|
|
229
|
+
_ui._write("\n (interrupted)\n")
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
_ui._write(f" {describe_error(exc)}\n")
|
|
232
|
+
buffer.clear()
|