execsql2 2.23.1__py3-none-any.whl → 2.24.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. execsql/cli/__init__.py +17 -586
  2. execsql/cli/application.py +309 -0
  3. execsql/cli/commands/__init__.py +13 -0
  4. execsql/cli/commands/config.py +205 -0
  5. execsql/cli/commands/format.py +100 -0
  6. execsql/cli/commands/init.py +135 -0
  7. execsql/cli/commands/lint.py +233 -0
  8. execsql/cli/commands/listing.py +81 -0
  9. execsql/cli/commands/run.py +451 -0
  10. execsql/cli/dispatch.py +66 -0
  11. execsql/cli/help.py +175 -24
  12. execsql/cli/lint.py +513 -76
  13. execsql/cli/options.py +108 -0
  14. execsql/cli/run.py +41 -14
  15. execsql/config.py +251 -154
  16. execsql/config_validation.py +282 -0
  17. execsql/data/execsql.conf.template +24 -1
  18. execsql/data/script_header.sql +23 -0
  19. execsql/db/factory.py +2 -2
  20. execsql/debug/repl.py +51 -193
  21. execsql/exceptions.py +9 -0
  22. execsql/format.py +87 -75
  23. execsql/interactive.py +232 -0
  24. execsql/manifest.py +189 -0
  25. execsql/metacommands/conditions.py +1 -1
  26. execsql/metacommands/control.py +2 -3
  27. execsql/metacommands/effects.py +124 -0
  28. execsql/script/__init__.py +0 -9
  29. execsql/script/ast.py +1 -1
  30. execsql/script/engine.py +6 -93
  31. execsql/script/executor.py +153 -34
  32. execsql/state.py +11 -2
  33. execsql/state.pyi +3 -0
  34. execsql/utils/color.py +18 -0
  35. execsql/utils/errors.py +11 -1
  36. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/README.md +1 -1
  37. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/METADATA +111 -58
  38. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/RECORD +55 -39
  39. execsql2-2.24.0.dist-info/entry_points.txt +2 -0
  40. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  41. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  42. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  43. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  44. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  45. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  46. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  47. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  48. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  49. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  50. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/script_template.sql +0 -0
  51. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  52. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  53. {execsql2-2.23.1.data → execsql2-2.24.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  54. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/WHEEL +0 -0
  55. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/licenses/LICENSE.txt +0 -0
  56. {execsql2-2.23.1.dist-info → execsql2-2.24.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,124 @@
1
+ """What a matched metacommand touches: files it reads, writes or deletes, and connections.
2
+
3
+ Used by ``run --manifest``, which records the metacommands a run executed.
4
+ It classifies from the handler a metacommand dispatched to and the groups its
5
+ regex captured, so any other reader of a script (a static inspector) can share
6
+ the same answer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ __all__ = ["Effect", "effects"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Effect:
18
+ """One thing a metacommand touches.
19
+
20
+ Attributes:
21
+ role: ``"read"``, ``"write"`` or ``"delete"`` for a file;
22
+ ``"connect"`` or ``"use"`` for a database.
23
+ target: The file path, or for a connection ``alias: server/db`` or
24
+ ``alias: file``.
25
+ detail: For a connection, the DBMS and user. Never a password.
26
+ """
27
+
28
+ role: str
29
+ target: str
30
+ detail: str = ""
31
+
32
+
33
+ _IMPORTS = (
34
+ "x_import",
35
+ "x_import_file",
36
+ "x_import_feather",
37
+ "x_import_json",
38
+ "x_import_ods",
39
+ "x_import_ods_pattern",
40
+ "x_import_parquet",
41
+ "x_import_xls",
42
+ "x_import_xls_pattern",
43
+ "x_sub_ini",
44
+ "x_serve",
45
+ )
46
+ _EXPORTS = (
47
+ "x_export",
48
+ "x_export_query",
49
+ "x_export_metadata",
50
+ "x_export_with_template",
51
+ "x_export_query_with_template",
52
+ "x_export_ods_multiple",
53
+ "x_export_xlsx_multiple",
54
+ )
55
+ _WRITES = (
56
+ "x_write",
57
+ "x_writescript",
58
+ "x_consolesave",
59
+ "x_cancel_halt_write",
60
+ "x_error_halt_write",
61
+ "x_debug_write_config",
62
+ "x_debug_write_metacommands",
63
+ "x_debug_write_odbc_drivers",
64
+ "x_debug_write_subvars",
65
+ )
66
+
67
+ #: Handler name → {regex group: role}. Groups not listed (passwords,
68
+ #: encodings, prompt paths) are never reported.
69
+ FILE_ROLES: dict[str, dict[str, str]] = {
70
+ **{name: {"filename": "read"} for name in _IMPORTS},
71
+ **{name: {"filename": "write", "zipfilename": "write", "template": "read"} for name in _EXPORTS},
72
+ **{name: {"filename": "write"} for name in _WRITES},
73
+ **{
74
+ name: {"filename": "read", "outfile": "write"}
75
+ for name in ("x_write_create_table", "x_write_create_table_ods", "x_write_create_table_xls")
76
+ },
77
+ "x_write_create_table_alias": {"filename": "read"},
78
+ **{
79
+ name: {"att_file": "read", "msg_file": "read"}
80
+ for name in ("x_email", "x_cancel_halt_email", "x_error_halt_email")
81
+ },
82
+ "x_zip": {"filename": "read", "zipfilename": "write"},
83
+ "x_rm_file": {"filename": "delete"},
84
+ }
85
+
86
+ DBMS = {
87
+ "pg": "PostgreSQL",
88
+ "mysql": "MySQL",
89
+ "ssvr": "SQL Server",
90
+ "ora": "Oracle",
91
+ "fb": "Firebird",
92
+ "sqlite": "SQLite",
93
+ "duckdb": "DuckDB",
94
+ "access": "MS Access",
95
+ "dsn": "ODBC DSN",
96
+ }
97
+
98
+
99
+ def _connection(handler: str, groups: dict[str, str | None]) -> Effect | None:
100
+ if handler == "x_use":
101
+ return Effect("use", groups.get("db_alias") or "")
102
+ if not handler.startswith("x_connect_"):
103
+ return None
104
+ kind = handler.removeprefix("x_connect_").removeprefix("user_")
105
+ target = groups.get("filename") or groups.get("dsn") or ""
106
+ if groups.get("server"):
107
+ target = f"{groups['server']}/{groups.get('db_name') or ''}"
108
+ detail = DBMS.get(kind, kind)
109
+ if groups.get("user"):
110
+ detail += f", user {groups['user']}"
111
+ return Effect("connect", f"{groups.get('db_alias') or ''}: {target}".strip(), detail)
112
+
113
+
114
+ def effects(handler: str, groups: dict[str, str | None]) -> list[Effect]:
115
+ """Everything a metacommand dispatched to *handler* with *groups* touches."""
116
+ connection = _connection(handler, groups)
117
+ if connection is not None:
118
+ return [connection]
119
+ found = []
120
+ for group, role in FILE_ROLES.get(handler, {}).items():
121
+ value = groups.get(group)
122
+ if value:
123
+ found.append(Effect(role, value.strip().strip('"')))
124
+ return found
@@ -23,9 +23,6 @@ Key classes:
23
23
  handler function + flags).
24
24
  - :class:`MetaCommandList` — ordered list of :class:`MetaCommand` entries
25
25
  with a keyword index for fast dispatch.
26
- - :class:`SqlStmt` / :class:`MetacommandStmt` / :class:`ScriptCmd` —
27
- statement wrappers carried in the AST and used by ``ctx.last_command``
28
- for source-location tracking.
29
26
  - :class:`ScriptExecSpec` — specification for deferred script execution
30
27
  (used by ``ON ERROR_HALT`` / ``ON CANCEL_HALT EXECUTE SCRIPT``).
31
28
 
@@ -46,10 +43,7 @@ from execsql.script.control import BatchLevels
46
43
  from execsql.script.engine import (
47
44
  MetaCommand,
48
45
  MetaCommandList,
49
- MetacommandStmt,
50
- ScriptCmd,
51
46
  ScriptExecSpec,
52
- SqlStmt,
53
47
  current_script_line,
54
48
  set_dynamic_system_vars,
55
49
  set_static_system_vars,
@@ -84,9 +78,6 @@ __all__ = [
84
78
  "ScriptArgSubVarSet",
85
79
  "MetaCommand",
86
80
  "MetaCommandList",
87
- "SqlStmt",
88
- "MetacommandStmt",
89
- "ScriptCmd",
90
81
  "ScriptExecSpec",
91
82
  "set_dynamic_system_vars",
92
83
  "set_static_system_vars",
execsql/script/ast.py CHANGED
@@ -6,7 +6,7 @@ Defines the node types that make up the execsql AST.
6
6
 
7
7
  Design principles:
8
8
  - Every node carries a :class:`SourceSpan` so that error messages,
9
- ``--lint``, and the formatter can report precise source locations.
9
+ ``execsql lint``, and the formatter can report precise source locations.
10
10
  - Block structures (IF, LOOP, BATCH, SCRIPT) are represented as nodes
11
11
  whose ``body`` (and optional ``else_body``, ``elseif_clauses``) contain
12
12
  child nodes, forming the tree structure.
execsql/script/engine.py CHANGED
@@ -11,9 +11,6 @@ live in :mod:`execsql.script.parser` and
11
11
  Classes:
12
12
  - :class:`MetaCommand` — one entry in the metacommand dispatch table.
13
13
  - :class:`MetaCommandList` — ordered list of :class:`MetaCommand` entries with a keyword index.
14
- - :class:`SqlStmt` — wraps a single SQL string for execution.
15
- - :class:`MetacommandStmt` — wraps a metacommand line for dispatch.
16
- - :class:`ScriptCmd` — pairs a statement with its source-file location.
17
14
  - :class:`ScriptExecSpec` — specification for deferred script execution.
18
15
 
19
16
  Functions:
@@ -28,7 +25,7 @@ import os
28
25
  import re
29
26
  import uuid
30
27
  from pathlib import Path
31
- from typing import Any, cast
28
+ from typing import Any
32
29
 
33
30
  import execsql.state as _state
34
31
  from execsql.exceptions import ErrInfo
@@ -38,9 +35,6 @@ from execsql.utils.errors import exception_desc
38
35
  __all__ = [
39
36
  "MetaCommand",
40
37
  "MetaCommandList",
41
- "SqlStmt",
42
- "MetacommandStmt",
43
- "ScriptCmd",
44
38
  "ScriptExecSpec",
45
39
  "set_system_vars",
46
40
  "substitute_vars",
@@ -97,8 +91,9 @@ class MetaCommand:
97
91
  raise
98
92
  except ErrInfo as errinf:
99
93
  er = errinf
100
- except Exception:
94
+ except Exception as exc:
101
95
  er = ErrInfo("cmd", command_text=cmd_str, exception_msg=exception_desc())
96
+ er.__cause__ = exc
102
97
  if er:
103
98
  if _state.status.halt_on_metacommand_err:
104
99
  from execsql.utils.errors import exit_now
@@ -205,7 +200,7 @@ class MetaCommandList:
205
200
  def keywords_by_category(self) -> dict[str, list[str]]:
206
201
  """Return ``{category: [keyword, ...]}`` from entries that have both.
207
202
 
208
- Used by ``--dump-keywords`` to introspect the dispatch table.
203
+ Used by ``execsql list keywords`` to introspect the dispatch table.
209
204
  """
210
205
  result: dict[str, list[str]] = {}
211
206
  for mc in self._commands:
@@ -239,89 +234,6 @@ class MetaCommandList:
239
234
  return None
240
235
 
241
236
 
242
- # ---------------------------------------------------------------------------
243
- # SqlStmt / MetacommandStmt
244
- # ---------------------------------------------------------------------------
245
-
246
-
247
- class SqlStmt:
248
- """A single SQL statement ready to be executed against the active database.
249
-
250
- Data class only — the legacy ``.run()`` method was removed when the AST
251
- executor became the sole engine. SQL execution now goes through
252
- :func:`execsql.script.executor._exec_sql`.
253
- """
254
-
255
- def __init__(self, sql_statement: str) -> None:
256
- self.statement = re.sub(r"\s*;(\s*;\s*)+$", ";", sql_statement)
257
-
258
- def __repr__(self) -> str:
259
- return f"SqlStmt({self.statement})"
260
-
261
- def commandline(self) -> str:
262
- """Return the raw SQL statement text."""
263
- return self.statement
264
-
265
-
266
- class MetacommandStmt:
267
- """A single execsql metacommand line.
268
-
269
- Data class only — the legacy ``.run()`` method was removed when the AST
270
- executor became the sole engine. Metacommand dispatch now goes through
271
- :func:`execsql.script.executor._exec_metacommand`.
272
- """
273
-
274
- def __init__(self, metacommand_statement: str) -> None:
275
- self.statement = metacommand_statement
276
-
277
- def __repr__(self) -> str:
278
- return f"MetacommandStmt({self.statement})"
279
-
280
- def commandline(self) -> str:
281
- """Return the metacommand line in its canonical ``-- !x! ...`` form."""
282
- return "-- !x! " + self.statement
283
-
284
-
285
- # ---------------------------------------------------------------------------
286
- # ScriptCmd
287
- # ---------------------------------------------------------------------------
288
-
289
-
290
- class ScriptCmd:
291
- """A parsed script item: either a :class:`SqlStmt` or a :class:`MetacommandStmt`, with source location."""
292
-
293
- # A SQL script object that is either a SQL statement or a metacommand.
294
- def __init__(
295
- self,
296
- command_source_name: str,
297
- command_line_no: int,
298
- command_type: str,
299
- script_command: Any,
300
- ) -> None:
301
- self.source = command_source_name
302
- self.line_no = command_line_no
303
- self.command_type = command_type
304
- self.command = script_command
305
- # MIGRATION NOTE: differs from monolith (execsql.py) — source_dir and source_name are
306
- # resolved once at construction rather than on every statement execution. For absolute
307
- # paths (the common case) the result is identical. For relative paths the value is
308
- # anchored to the CWD at script-load time rather than at each statement's execution time;
309
- # the original per-statement resolve could yield inconsistent values across statements of
310
- # the same script if a CD metacommand ran between them.
311
- _p = Path(command_source_name)
312
- self.source_dir: str = str(_p.resolve().parent) + os.sep
313
- self.source_name: str = _p.name
314
-
315
- def __repr__(self) -> str:
316
- return f"ScriptCmd({self.source!r}, {self.line_no!r}, {self.command_type!r}, {repr(self.command)!r})"
317
-
318
- def current_script_line(self) -> tuple[str, int]:
319
- return (self.source, self.line_no)
320
-
321
- def commandline(self) -> str:
322
- return cast(str, self.command.statement if self.command_type == "sql" else "-- !x! " + self.command.statement)
323
-
324
-
325
237
  # ---------------------------------------------------------------------------
326
238
  # ScriptExecSpec
327
239
  # ---------------------------------------------------------------------------
@@ -509,7 +421,8 @@ def current_script_line() -> tuple[str, int]:
509
421
  """Return ``(source_name, line_number)`` for the command currently executing.
510
422
 
511
423
  Reads from ``_state.last_command``, which the AST executor updates on
512
- every statement via the ``_FakeScriptCmd`` shim. Returns ``("", 0)``
424
+ every statement via :class:`~execsql.script.executor.ExecutingStatement`.
425
+ Returns ``("", 0)``
513
426
  when nothing has executed yet (e.g. during early initialization errors).
514
427
  """
515
428
  last = getattr(_state, "last_command", None)
@@ -48,6 +48,7 @@ import re
48
48
  import time as _time
49
49
  from pathlib import Path
50
50
  from typing import Any, cast
51
+ from collections.abc import Callable
51
52
 
52
53
  from execsql.exceptions import ErrInfo
53
54
  from execsql.script.ast import (
@@ -64,12 +65,13 @@ from execsql.script.ast import (
64
65
  SqlBlock,
65
66
  SqlStatement,
66
67
  )
68
+ from execsql import manifest as _manifest
67
69
  from execsql.script.engine import set_dynamic_system_vars, set_static_system_vars, substitute_vars
68
70
  from execsql.script.variables import SubVarSet
69
71
  from execsql.state import ExecFrame, RuntimeContext, active_context, get_context, xcmd_test
70
72
  from execsql.utils.errors import exception_desc, exit_now, stamp_errinfo
71
73
 
72
- __all__ = ["execute"]
74
+ __all__ = ["execute", "execute_input"]
73
75
 
74
76
 
75
77
  # ---------------------------------------------------------------------------
@@ -256,19 +258,24 @@ def _exec_sql(
256
258
  f"Warning: There is a potential un-substituted variable in the command\n {cmd}\n",
257
259
  )
258
260
  e = None
261
+ result = None
259
262
  try:
260
263
  db = dbs.current()
261
264
  if conf.log_sql and ctx.exec_log:
262
265
  ctx.exec_log.log_sql_query(cmd, db.name(), line_no)
263
- db.execute(cmd)
266
+ if ctx.prompt_input is not None:
267
+ result = db.execute(cmd, fetch=True)
268
+ else:
269
+ db.execute(cmd)
264
270
  if commit:
265
271
  db.commit()
266
272
  except ErrInfo as errinfo:
267
273
  e = errinfo
268
274
  except SystemExit:
269
275
  raise
270
- except Exception:
276
+ except Exception as exc:
271
277
  e = ErrInfo(type="exception", exception_msg=exception_desc())
278
+ e.__cause__ = exc # the driver's own message, which a prompt shows
272
279
  if e:
273
280
  stamp_errinfo(e)
274
281
  subvars.add_substitution("$LAST_ERROR", cmd)
@@ -281,6 +288,8 @@ def _exec_sql(
281
288
  status.error_history.append((source, line_no, cmd, e.errmsg()))
282
289
  return
283
290
  subvars.add_substitution("$LAST_SQL", cmd)
291
+ if ctx.prompt_input is not None:
292
+ ctx.prompt_input(result)
284
293
 
285
294
 
286
295
  # ---------------------------------------------------------------------------
@@ -319,13 +328,16 @@ def _exec_metacommand(
319
328
  try:
320
329
  applies, result = metacommandlist.eval(cmd)
321
330
  if applies:
331
+ if (manifest := _manifest.current()) is not None:
332
+ manifest.record_metacommand(cmd, source, line_no, metacommandlist)
322
333
  return result
323
334
  except ErrInfo as errinfo:
324
335
  e = errinfo
325
336
  except SystemExit:
326
337
  raise
327
- except Exception:
338
+ except Exception as exc:
328
339
  e = ErrInfo(type="exception", exception_msg=exception_desc())
340
+ e.__cause__ = exc
329
341
  if e:
330
342
  stamp_errinfo(e)
331
343
  status.metacommand_error = True
@@ -402,9 +414,9 @@ def _execute_node(
402
414
  text = node.text
403
415
  if in_loop:
404
416
  text = _convert_deferred_vars(text)
405
- # Deduplicate trailing semicolons (matches SqlStmt.__init__)
417
+ # Deduplicate trailing semicolons
406
418
  text = re.sub(r"\s*;(\s*;\s*)+$", ";", text)
407
- ctx.last_command = _FakeScriptCmd(node)
419
+ ctx.last_command = ExecutingStatement(node)
408
420
  _exec_sql(
409
421
  ctx,
410
422
  text,
@@ -413,6 +425,8 @@ def _execute_node(
413
425
  localvars,
414
426
  commit=not ctx.status.batch.in_batch(),
415
427
  )
428
+ if (manifest := _manifest.current()) is not None:
429
+ manifest.record_sql()
416
430
 
417
431
  elif isinstance(node, MetaCommandStatement):
418
432
  command = node.command
@@ -425,31 +439,31 @@ def _execute_node(
425
439
  expanded = substitute_vars(command, effective_locals, ctx=ctx)
426
440
  if _BREAK_RX.match(expanded):
427
441
  raise _BreakLoop
428
- ctx.last_command = _FakeScriptCmd(node)
442
+ ctx.last_command = ExecutingStatement(node)
429
443
  _exec_metacommand(ctx, expanded, node.span.file, node.span.start_line)
430
444
 
431
445
  elif isinstance(node, IfBlock):
432
- ctx.last_command = _FakeScriptCmd(node)
446
+ ctx.last_command = ExecutingStatement(node)
433
447
  _execute_if(ctx, node, localvars, in_loop=in_loop)
434
448
 
435
449
  elif isinstance(node, LoopBlock):
436
- ctx.last_command = _FakeScriptCmd(node)
450
+ ctx.last_command = ExecutingStatement(node)
437
451
  _execute_loop(ctx, node, localvars)
438
452
 
439
453
  elif isinstance(node, BatchBlock):
440
- ctx.last_command = _FakeScriptCmd(node)
454
+ ctx.last_command = ExecutingStatement(node)
441
455
  _execute_batch(ctx, node, localvars, in_loop=in_loop)
442
456
 
443
457
  elif isinstance(node, ScriptBlock):
444
- ctx.last_command = _FakeScriptCmd(node)
458
+ ctx.last_command = ExecutingStatement(node)
445
459
  _register_script_block(ctx, node)
446
460
 
447
461
  elif isinstance(node, SqlBlock):
448
- ctx.last_command = _FakeScriptCmd(node)
462
+ ctx.last_command = ExecutingStatement(node)
449
463
  _execute_sql_block(ctx, node, localvars, in_loop=in_loop)
450
464
 
451
465
  elif isinstance(node, IncludeDirective):
452
- ctx.last_command = _FakeScriptCmd(node)
466
+ ctx.last_command = ExecutingStatement(node)
453
467
  _execute_include(ctx, node, localvars)
454
468
 
455
469
  else:
@@ -861,6 +875,8 @@ def _execute_include_native(
861
875
  # Parse with AST parser
862
876
  encoding = ctx.conf.script_encoding if ctx.conf is not None else "utf-8"
863
877
  included_tree = parse_script(target, encoding=encoding)
878
+ if (manifest := _manifest.current()) is not None:
879
+ manifest.record_include(target, node.span.file, node.span.start_line)
864
880
 
865
881
  # Pre-register SCRIPT blocks in the included file so forward references work.
866
882
  _pre_register_scripts(ctx, included_tree.body)
@@ -885,40 +901,104 @@ def _execute_include_native(
885
901
  # ---------------------------------------------------------------------------
886
902
 
887
903
 
888
- class _BreakLoop(Exception):
889
- """Raised by BREAK metacommand to exit the innermost loop."""
904
+ class _BreakLoop(BaseException):
905
+ """Raised by BREAK metacommand to exit the innermost loop.
906
+
907
+ A ``BaseException``, like ``KeyboardInterrupt``, so the ``except
908
+ Exception`` handlers it may pass on its way to the loop leave it alone:
909
+ a BREAK typed at a BREAKPOINT prompt travels out through the BREAKPOINT
910
+ metacommand's handler.
911
+ """
912
+
913
+
914
+ def _inside_loop(ctx: RuntimeContext) -> bool:
915
+ """Whether a BREAK at the current statement would leave a loop within its scope."""
916
+ for frame in reversed(ctx.ast_exec_stack):
917
+ if frame.kind in ("loop_while", "loop_until"):
918
+ return True
919
+ if frame.kind in ("script", "main"):
920
+ return frame.kind == "script" and frame.iteration > 0
921
+ return False
890
922
 
891
923
 
892
924
  _BREAK_RX = re.compile(r"^\s*BREAK\s*$", re.I)
893
925
 
894
926
 
895
927
  # ---------------------------------------------------------------------------
896
- # Fake ScriptCmd for ctx.last_command compatibility
928
+ # The statement the executor is currently on
897
929
  # ---------------------------------------------------------------------------
898
930
 
899
931
 
900
- class _FakeScriptCmd:
901
- """Minimal stand-in for ScriptCmd to satisfy ctx.last_command readers."""
932
+ class _StatementText:
933
+ """The text of one statement, in the two spellings its readers expect.
934
+
935
+ ``statement`` is the raw text; ``commandline()`` is the text as it
936
+ appeared in the source, so a metacommand gets its ``-- !x!`` marker back.
937
+ """
938
+
939
+ __slots__ = ("statement", "_prefix")
940
+
941
+ def __init__(self, statement: str, prefix: str = "") -> None:
942
+ self.statement = statement
943
+ self._prefix = prefix
902
944
 
903
- __slots__ = ("source", "line_no", "source_dir", "source_name", "command", "command_type")
945
+ def commandline(self) -> str:
946
+ return self._prefix + self.statement
947
+
948
+
949
+ class ExecutingStatement:
950
+ """Where the executor is, as ``ctx.last_command`` exposes it.
951
+
952
+ The AST node is the single representation of a statement. This wraps one
953
+ rather than copying fields out of it, so there is nothing to keep in sync
954
+ and no second source of truth about what is executing. Error reporting
955
+ (:mod:`execsql.utils.errors`), the debug REPL, and ``api.run()`` read
956
+ these five attributes; everything is derived from ``node`` on access, so
957
+ constructing one costs a single small object per statement rather than
958
+ the two classes the previous shim built each time.
959
+ """
960
+
961
+ __slots__ = ("node", "_source_dir")
904
962
 
905
963
  def __init__(self, node: Node) -> None:
906
- self.source = node.span.file
907
- self.line_no = node.span.start_line
908
- _p = Path(node.span.file)
909
- self.source_dir = str(_p.resolve().parent) + os.sep
910
- self.source_name = _p.name
911
- self.command_type = "sql" if isinstance(node, SqlStatement) else "cmd"
964
+ self.node = node
965
+ self._source_dir: str | None = None
966
+
967
+ @property
968
+ def source(self) -> str:
969
+ return self.node.span.file
970
+
971
+ @property
972
+ def line_no(self) -> int:
973
+ return self.node.span.start_line
974
+
975
+ @property
976
+ def source_name(self) -> str:
977
+ return Path(self.node.span.file).name
978
+
979
+ @property
980
+ def source_dir(self) -> str:
981
+ # resolve() touches the filesystem, so it is done once per statement
982
+ # object rather than on every read.
983
+ if self._source_dir is None:
984
+ self._source_dir = str(Path(self.node.span.file).resolve().parent) + os.sep
985
+ return self._source_dir
986
+
987
+ @property
988
+ def command_type(self) -> str:
989
+ return "sql" if isinstance(self.node, SqlStatement) else "cmd"
990
+
991
+ @property
992
+ def command(self) -> _StatementText:
993
+ node = self.node
912
994
  if isinstance(node, SqlStatement):
913
- self.command = type("_cmd", (), {"statement": node.text, "commandline": lambda self: self.statement})()
914
- elif isinstance(node, MetaCommandStatement):
915
- self.command = type(
916
- "_cmd",
917
- (),
918
- {"statement": node.command, "commandline": lambda self: "-- !x! " + self.statement},
919
- )()
920
- else:
921
- self.command = type("_cmd", (), {"statement": "", "commandline": lambda self: ""})()
995
+ return _StatementText(node.text)
996
+ if isinstance(node, MetaCommandStatement):
997
+ return _StatementText(node.command, "-- !x! ")
998
+ return _StatementText("")
999
+
1000
+ def __repr__(self) -> str:
1001
+ return f"ExecutingStatement({self.source}:{self.line_no}, {self.command_type})"
922
1002
 
923
1003
  def current_script_line(self) -> tuple[str, int]:
924
1004
  return (self.source, self.line_no)
@@ -1008,3 +1088,42 @@ def execute(script: Script, *, ctx: RuntimeContext | None = None) -> None:
1008
1088
  ) from exc
1009
1089
  finally:
1010
1090
  _pop_frame(ctx)
1091
+
1092
+
1093
+ # ---------------------------------------------------------------------------
1094
+ # Input typed at a prompt
1095
+ # ---------------------------------------------------------------------------
1096
+
1097
+
1098
+ def execute_input(
1099
+ script: Script,
1100
+ show: Callable[[tuple[list[str], list] | None], None],
1101
+ *,
1102
+ ctx: RuntimeContext | None = None,
1103
+ ) -> None:
1104
+ """Run input typed at a BREAKPOINT prompt as the next lines of the paused script.
1105
+
1106
+ The input runs as if it were written where the script paused: in its
1107
+ variable scope, its batch and its loop, so a BREAK leaves that loop.
1108
+ Unlike :func:`execute`, nothing is reset.
1109
+
1110
+ *show* receives the result of each SQL statement run. An error that
1111
+ would halt the run is raised to the caller instead (see ``exit_now``).
1112
+ The paused script's current statement is restored afterwards, so the
1113
+ debug REPL's ``.where`` still shows it.
1114
+ """
1115
+ if ctx is None:
1116
+ ctx = get_context()
1117
+ with active_context(ctx):
1118
+ _pre_register_scripts(ctx, script.body)
1119
+ last_command, prompt_input = ctx.last_command, ctx.prompt_input
1120
+ ctx.prompt_input = show
1121
+ try:
1122
+ _execute_nodes(ctx, script.body, script.source)
1123
+ except _BreakLoop as exc:
1124
+ if not _inside_loop(ctx):
1125
+ raise ErrInfo(type="cmd", other_msg="BREAK metacommand outside of a LOOP block.") from exc
1126
+ raise
1127
+ finally:
1128
+ ctx.prompt_input = prompt_input
1129
+ ctx.last_command = last_command
execsql/state.py CHANGED
@@ -33,6 +33,7 @@ import threading
33
33
  import types
34
34
  from dataclasses import dataclass
35
35
  from typing import TYPE_CHECKING, Any
36
+ from collections.abc import Callable
36
37
 
37
38
 
38
39
  @dataclass
@@ -96,10 +97,10 @@ if TYPE_CHECKING:
96
97
  from execsql.script import (
97
98
  CounterVars,
98
99
  MetaCommandList,
99
- ScriptCmd,
100
100
  ScriptExecSpec,
101
101
  SubVarSet,
102
102
  )
103
+ from execsql.script.executor import ExecutingStatement
103
104
  from execsql.utils.fileio import FileWriter, Logger, TempFileMgr
104
105
  from execsql.utils.mail import MailSpec
105
106
  from execsql.utils.timer import Timer
@@ -143,6 +144,7 @@ __all__ = [
143
144
  "profile_data",
144
145
  # Debug REPL
145
146
  "step_mode",
147
+ "prompt_input",
146
148
  # Version
147
149
  "primary_vno",
148
150
  "secondary_vno",
@@ -239,6 +241,7 @@ _CONTEXT_ATTRS: frozenset[str] = frozenset(
239
241
  "profile_data",
240
242
  # Debug REPL
241
243
  "step_mode",
244
+ "prompt_input",
242
245
  # AST executor
243
246
  "ast_scripts",
244
247
  "include_chain",
@@ -293,6 +296,7 @@ class RuntimeContext:
293
296
  "profile_data",
294
297
  # Debug REPL
295
298
  "step_mode",
299
+ "prompt_input",
296
300
  # AST executor
297
301
  "ast_scripts",
298
302
  "include_chain",
@@ -305,7 +309,7 @@ class RuntimeContext:
305
309
  self.logfile_encoding: str = "utf8"
306
310
 
307
311
  # Runtime flags
308
- self.last_command: ScriptCmd | None = None
312
+ self.last_command: ExecutingStatement | None = None
309
313
  self.upass: str | None = None
310
314
  self.err_halt_writespec: WriteSpec | None = None
311
315
  self.err_halt_email: MailSpec | None = None
@@ -347,6 +351,11 @@ class RuntimeContext:
347
351
 
348
352
  # Debug REPL — True after a ``next`` command; engine re-enters REPL after next statement.
349
353
  self.step_mode: bool = False
354
+ # Set while input typed at a BREAKPOINT prompt runs:
355
+ # called with each SQL statement's result, as ``(column_names, rows)``
356
+ # or ``None``. An error that would halt the run instead returns to the
357
+ # prompt (see ``exit_now``).
358
+ self.prompt_input: Callable[[tuple[list[str], list] | None], None] | None = None
350
359
 
351
360
  # AST executor — script block registry and include-chain tracking.
352
361
  self.ast_scripts: dict = {}