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.
Files changed (92) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +19 -586
  3. execsql/cli/application.py +309 -0
  4. execsql/cli/commands/__init__.py +13 -0
  5. execsql/cli/commands/config.py +205 -0
  6. execsql/cli/commands/format.py +100 -0
  7. execsql/cli/commands/init.py +135 -0
  8. execsql/cli/commands/lint.py +225 -0
  9. execsql/cli/commands/listing.py +81 -0
  10. execsql/cli/commands/run.py +459 -0
  11. execsql/cli/dispatch.py +66 -0
  12. execsql/cli/dsn.py +11 -4
  13. execsql/cli/help.py +175 -24
  14. execsql/cli/lint.py +780 -100
  15. execsql/cli/options.py +108 -0
  16. execsql/cli/run.py +96 -32
  17. execsql/config.py +256 -173
  18. execsql/config_validation.py +282 -0
  19. execsql/data/execsql.conf.template +38 -5
  20. execsql/data/script_header.sql +23 -0
  21. execsql/db/access.py +29 -8
  22. execsql/db/base.py +40 -8
  23. execsql/db/dsn.py +13 -4
  24. execsql/db/duckdb.py +3 -1
  25. execsql/db/factory.py +13 -4
  26. execsql/db/firebird.py +21 -7
  27. execsql/db/mysql.py +37 -16
  28. execsql/db/oracle.py +49 -16
  29. execsql/db/postgres.py +30 -6
  30. execsql/db/sqlite.py +7 -9
  31. execsql/db/sqlserver.py +25 -8
  32. execsql/debug/repl.py +51 -193
  33. execsql/exceptions.py +9 -0
  34. execsql/exporters/delimited.py +87 -28
  35. execsql/exporters/xlsx.py +24 -9
  36. execsql/exporters/zip.py +1 -2
  37. execsql/format.py +115 -76
  38. execsql/gui/desktop.py +1 -3
  39. execsql/gui/tui.py +2 -2
  40. execsql/importers/base.py +35 -1
  41. execsql/importers/csv.py +3 -1
  42. execsql/interactive.py +232 -0
  43. execsql/manifest.py +200 -0
  44. execsql/metacommands/conditions.py +7 -8
  45. execsql/metacommands/control.py +2 -3
  46. execsql/metacommands/data.py +4 -1
  47. execsql/metacommands/debug.py +2 -4
  48. execsql/metacommands/effects.py +124 -0
  49. execsql/metacommands/io_fileops.py +62 -37
  50. execsql/metacommands/io_import.py +0 -1
  51. execsql/metacommands/prompt.py +2 -0
  52. execsql/metacommands/upsert.py +5 -2
  53. execsql/models.py +3 -2
  54. execsql/parser.py +14 -5
  55. execsql/plugins.py +24 -0
  56. execsql/script/__init__.py +0 -9
  57. execsql/script/ast.py +1 -1
  58. execsql/script/engine.py +46 -104
  59. execsql/script/executor.py +162 -39
  60. execsql/script/parser.py +167 -118
  61. execsql/state.py +34 -11
  62. execsql/state.pyi +11 -0
  63. execsql/types.py +22 -13
  64. execsql/utils/auth.py +169 -24
  65. execsql/utils/color.py +18 -0
  66. execsql/utils/datetime.py +55 -17
  67. execsql/utils/errors.py +82 -10
  68. execsql/utils/fileio.py +404 -117
  69. execsql/utils/gui.py +3 -0
  70. execsql/utils/mail.py +37 -12
  71. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
  72. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
  73. execsql2-2.25.0.dist-info/RECORD +140 -0
  74. execsql2-2.25.0.dist-info/entry_points.txt +2 -0
  75. execsql2-2.23.1.dist-info/RECORD +0 -124
  76. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  77. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  78. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  79. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  80. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  81. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  82. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  83. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  84. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  85. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  86. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  87. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  88. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  89. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  90. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  91. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  92. {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", "main", "parse_keyword"]
20
+ __all__ = ["collect_paths", "format_file", "open_dollar_quote", "parse_keyword", "run_formatter"]
21
21
 
22
22
 
23
23
  _SQLGLOT_MISSING_MSG = (
24
- "execsql-format requires sqlglot for SQL reformatting.\n"
25
- " Install with: pip install execsql2[formatter]\n"
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 execsql-format doesn't pull in the AST parser
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-format)
1234
+ # Entry point (execsql format)
1208
1235
  # ---------------------------------------------------------------------------
1209
1236
 
1210
1237
 
1211
- def main() -> None:
1212
- """CLI entry point for the execsql-format console script."""
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
- app = typer.Typer(
1222
- name="execsql-format",
1223
- help="Format execsql scripts: normalize metacommand indentation and uppercase keywords.",
1224
- rich_markup_mode="rich",
1225
- no_args_is_help=True,
1226
- add_completion=False,
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
- @app.command(context_settings={"allow_extra_args": False})
1230
- def _cmd(
1231
- targets: list[Path] = typer.Argument(
1232
- ...,
1233
- metavar="FILE_OR_DIR",
1234
- help="Files or directories to format. Directories are searched recursively for *.sql files.",
1235
- ),
1236
- check: bool = typer.Option(False, "--check", help="Exit 1 if any file needs changes (don't write)."),
1237
- in_place: bool = typer.Option(False, "-i", "--in-place", help="Modify files in place."),
1238
- no_sql: bool = typer.Option(False, "--no-sql", help="Skip SQL formatting via sqlglot."),
1239
- indent: int = typer.Option(4, "--indent", metavar="N", help="Spaces per indent level."),
1240
- leading_comma: bool = typer.Option(
1241
- False,
1242
- "--leading-comma",
1243
- help="Place commas at the start of lines instead of the end.",
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
- formatted = format_file(source, indent=indent, use_sql=use_sql, leading_comma=leading_comma)
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
- if any_errors or (check and any_changed):
1291
- raise typer.Exit(code=1)
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
- app()
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
- "`pip install execsql2[map]` — showing tabular data)"
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
- Install with: pip install "execsql2[tui]"
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. Install it with: pip install 'execsql2[tui]'",
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()