execsql2 2.34.0__py3-none-any.whl → 2.37.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 (43) hide show
  1. execsql/api.py +17 -8
  2. execsql/cli/lint.py +54 -6
  3. execsql/cli/run.py +5 -10
  4. execsql/config.py +2 -0
  5. execsql/data/execsql.conf.template +17 -7
  6. execsql/db/base.py +12 -2
  7. execsql/db/duckdb.py +8 -1
  8. execsql/debug/repl.py +2 -2
  9. execsql/metacommands/connect.py +10 -9
  10. execsql/metacommands/data.py +7 -1
  11. execsql/metacommands/dispatch.py +7 -0
  12. execsql/metacommands/io_fileops.py +6 -3
  13. execsql/metacommands/io_write.py +3 -1
  14. execsql/metacommands/reference.py +1 -0
  15. execsql/metacommands/script_ext.py +13 -10
  16. execsql/metacommands/system.py +13 -6
  17. execsql/models.py +30 -4
  18. execsql/script/ast.py +21 -0
  19. execsql/script/control.py +63 -3
  20. execsql/script/executor.py +379 -229
  21. execsql/script/parser.py +102 -52
  22. execsql/state.py +23 -0
  23. execsql/utils/errors.py +24 -6
  24. execsql/utils/sigterm.py +154 -0
  25. {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/METADATA +2 -2
  26. {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/RECORD +43 -42
  27. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/README.md +0 -0
  28. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  29. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  30. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  31. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  32. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  33. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  34. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  35. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  36. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/script_template.sql +0 -0
  37. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  38. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  39. {execsql2-2.34.0.data → execsql2-2.37.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  40. {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/WHEEL +0 -0
  41. {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/entry_points.txt +0 -0
  42. {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/licenses/LICENSE.txt +0 -0
  43. {execsql2-2.34.0.dist-info → execsql2-2.37.0.dist-info}/licenses/NOTICE +0 -0
@@ -63,11 +63,13 @@ from execsql.script.ast import (
63
63
  Script,
64
64
  ScriptBlock,
65
65
  SqlBlock,
66
+ SourceSpan,
66
67
  SqlStatement,
67
68
  )
68
69
  from execsql import manifest as _manifest
70
+ from execsql.script.parser import substitute_directive
69
71
  from execsql.script.engine import set_dynamic_system_vars, set_static_system_vars, substitute_vars
70
- from execsql.script.variables import SubVarSet
72
+ from execsql.script.variables import ScriptArgSubVarSet, SubVarSet
71
73
  from execsql.state import ExecFrame, RuntimeContext, active_context, get_context, xcmd_test
72
74
  from execsql.utils.errors import exception_desc, exit_now, logged_error, stamp_errinfo
73
75
 
@@ -85,6 +87,14 @@ _DEFER_RX = re.compile(r"!\{([$@&~#+]?\w+)\}!")
85
87
  _VARLIKE = re.compile(r"!![$@&~#]?\w+!!", re.I)
86
88
 
87
89
 
90
+ def _warn_unsubstituted(cmd: str) -> None:
91
+ """Log a command that still looks like it holds a variable; print it too if ``write_warnings`` is on."""
92
+ from execsql.utils.errors import write_warning
93
+ from execsql.utils.fileio import redact
94
+
95
+ write_warning(f"There is a potential un-substituted variable in the command\n {redact(cmd)}")
96
+
97
+
88
98
  def _stack_localvars(ctx: RuntimeContext) -> SubVarSet | None:
89
99
  """Build the merged ``~`` local + ``#`` param overlay for the current scope.
90
100
 
@@ -169,39 +179,64 @@ def _convert_deferred_vars(text: str) -> str:
169
179
  return _DEFER_RX.sub(r"!!\1!!", text)
170
180
 
171
181
 
172
- def _eval_condition(
182
+ def _condition_holds(
173
183
  ctx: RuntimeContext,
184
+ command: str,
185
+ span: SourceSpan,
174
186
  condition: str,
175
187
  modifiers: list[ConditionModifier] | None = None,
176
188
  ) -> bool:
177
- """Evaluate a condition string with optional ANDIF/ORIF modifiers.
189
+ """Evaluate the condition of IF, ELSEIF, LOOP or EXECUTE SCRIPT … WHILE / UNTIL, with its ANDIF / ORIF modifiers.
178
190
 
179
191
  Short-circuits ANDIF (stops on first False) and ORIF (stops on first True)
180
192
  so that patterns like ``IF (sub_defined(x)) ANDIF (not sub_empty(x))``
181
193
  don't evaluate ``sub_empty`` when ``x`` is undefined.
194
+
195
+ *command* is the metacommand as written, on the line *span* starts; an
196
+ error is reported there, or on the ANDIF / ORIF that raised it.
182
197
  """
183
198
  effective_locals = _stack_localvars(ctx)
184
- expanded = substitute_vars(condition, effective_locals, ctx=ctx)
185
- result = xcmd_test(expanded)
186
-
187
- if modifiers:
188
- for mod in modifiers:
189
- # Short-circuit: AND with False can't become True,
190
- # OR with True can't become False.
191
- if mod.kind == "AND" and not result:
192
- continue
193
- if mod.kind == "OR" and result:
194
- continue
195
- mod_expanded = substitute_vars(mod.condition, effective_locals, ctx=ctx)
196
- mod_result = xcmd_test(mod_expanded)
197
- if mod.kind == "AND":
198
- result = result and mod_result
199
- else: # OR
200
- result = result or mod_result
201
-
199
+ result = _test(ctx, command, span, condition, effective_locals)
200
+ for mod in modifiers or []:
201
+ # Short-circuit: AND with False can't become True,
202
+ # OR with True can't become False.
203
+ if (mod.kind == "AND" and not result) or (mod.kind == "OR" and result):
204
+ continue
205
+ keyword = "ANDIF" if mod.kind == "AND" else "ORIF"
206
+ result = _test(ctx, f"{keyword} ({mod.condition})", mod.span, mod.condition, effective_locals)
202
207
  return result
203
208
 
204
209
 
210
+ def _test(
211
+ ctx: RuntimeContext,
212
+ command: str,
213
+ span: SourceSpan,
214
+ condition: str,
215
+ localvars: SubVarSet | None,
216
+ ) -> bool:
217
+ """One condition of *command*: its value.
218
+
219
+ A condition that cannot be evaluated stops the run. Its error is raised
220
+ with the line and text of *command*, which may be an ELSEIF, ANDIF or
221
+ ORIF rather than the statement on record, or a loop condition evaluated
222
+ after its body.
223
+ """
224
+ try:
225
+ return xcmd_test(substitute_vars(condition, localvars, ctx=ctx))
226
+ except Exception as exc:
227
+ e = _as_errinfo(exc)
228
+ if e.script_file is None:
229
+ e.script_file, e.script_line_no = span.file, span.start_line
230
+ e.cmd, e.cmdtype = command, "cmd"
231
+ # The run stops, except at a BREAKPOINT prompt, which carries on:
232
+ # a test that failed in the database (HASROWS) has rolled back the
233
+ # connection, so the batch must not commit.
234
+ _fail_open_batches(ctx, span.file, span.start_line)
235
+ if e is exc:
236
+ raise
237
+ raise e from exc
238
+
239
+
205
240
  def _set_command_vars(ctx: RuntimeContext, source: str, line_no: int) -> None:
206
241
  """Set per-command system variables (current script, line, time)."""
207
242
  assert ctx.subvars is not None
@@ -254,11 +289,7 @@ def _exec_sql(
254
289
  effective_locals = _stack_localvars(ctx) or localvars
255
290
  cmd = substitute_vars(text, effective_locals, ctx=ctx)
256
291
  if _VARLIKE.search(cmd):
257
- from execsql.utils.fileio import redact
258
-
259
- output.write(
260
- f"Warning: There is a potential un-substituted variable in the command\n {redact(cmd)}\n",
261
- )
292
+ _warn_unsubstituted(cmd)
262
293
  e = None
263
294
  result = None
264
295
  try:
@@ -271,34 +302,97 @@ def _exec_sql(
271
302
  db.execute(cmd)
272
303
  if commit:
273
304
  db.commit()
274
- except ErrInfo as errinfo:
275
- e = errinfo
276
- except SystemExit:
277
- raise
278
305
  except Exception as exc:
279
- e = ErrInfo(type="exception", exception_msg=exception_desc())
280
- e.__cause__ = exc # the driver's own message, which a prompt shows
306
+ e = _as_errinfo(exc)
281
307
  if e:
282
- stamp_errinfo(e)
283
- subvars.add_substitution("$LAST_ERROR", cmd)
284
- subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
285
- status.sql_error = True
286
- if ctx.exec_log is not None:
287
- ctx.exec_log.log_status_info(f"SQL error: {logged_error(e)}")
288
- if status.halt_on_err:
289
- exit_now(1, e)
290
- status.error_history.append((source, line_no, cmd, e.errmsg()))
308
+ _sql_failed(ctx, e, cmd, source, line_no)
291
309
  return
292
310
  subvars.add_substitution("$LAST_SQL", cmd)
293
311
  if ctx.prompt_input is not None:
294
312
  ctx.prompt_input(result)
295
313
 
296
314
 
315
+ def _sql_failed(ctx: RuntimeContext, e: ErrInfo, cmd: str, source: str, line_no: int) -> None:
316
+ """Report an SQL error: halt under ERROR_HALT ON, else record it and carry on.
317
+
318
+ Sets ``$LAST_ERROR`` to *cmd*, ``$ERROR_MESSAGE`` and ``SQL_ERROR()``.
319
+ """
320
+ assert ctx.status is not None
321
+ assert ctx.subvars is not None
322
+ stamp_errinfo(e)
323
+ ctx.subvars.add_substitution("$LAST_ERROR", cmd)
324
+ ctx.subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
325
+ ctx.status.sql_error = True
326
+ if ctx.exec_log is not None:
327
+ ctx.exec_log.log_status_info(f"SQL error: {logged_error(e)}")
328
+ _fail_open_batches(ctx, source, line_no)
329
+ if ctx.status.halt_on_err:
330
+ exit_now(1, e)
331
+ ctx.status.error_history.append((source, line_no, cmd, e.errmsg()))
332
+
333
+
297
334
  # ---------------------------------------------------------------------------
298
335
  # Metacommand execution
299
336
  # ---------------------------------------------------------------------------
300
337
 
301
338
 
339
+ def _as_errinfo(exc: Exception) -> ErrInfo:
340
+ """*exc* as an :class:`ErrInfo`: itself if it is one, else an exception report.
341
+
342
+ The report keeps *exc* as its ``__cause__``: the driver's or parser's own
343
+ message, which a prompt shows. Call it in the ``except`` clause, where
344
+ :func:`exception_desc` can still see the exception.
345
+ """
346
+ if isinstance(exc, ErrInfo):
347
+ return exc
348
+ e = ErrInfo(type="exception", exception_msg=exception_desc())
349
+ e.__cause__ = exc
350
+ return e
351
+
352
+
353
+ def _fail_open_batches(ctx: RuntimeContext, source: str, line_no: int) -> None:
354
+ """Keep every open batch from committing after an error inside it.
355
+
356
+ Whatever failed (a statement, a metacommand, or SQL a metacommand ran),
357
+ the batch is not committed: a database error may already have rolled
358
+ back the batch's earlier work, and execsql cannot tell from here whether
359
+ it did.
360
+ """
361
+ assert ctx.status is not None
362
+ if ctx.status.batch.in_batch():
363
+ ctx.status.batch.fail_enclosing(f"an error on line {line_no} of {source}")
364
+
365
+
366
+ def _metacommand_failed(
367
+ ctx: RuntimeContext,
368
+ cmd: str,
369
+ e: ErrInfo,
370
+ source: str,
371
+ line_no: int,
372
+ *,
373
+ always_halt: bool = False,
374
+ ) -> None:
375
+ """Record a failed metacommand; stop the run if METACOMMAND_ERROR_HALT is on, or *always_halt*.
376
+
377
+ With it off the script carries on, and ``METACOMMAND_ERROR()``,
378
+ ``$LAST_ERROR`` and ``$ERROR_MESSAGE`` say what failed.
379
+ """
380
+ status = ctx.status
381
+ subvars = ctx.subvars
382
+ assert status is not None
383
+ assert subvars is not None
384
+ stamp_errinfo(e)
385
+ status.metacommand_error = True
386
+ subvars.add_substitution("$LAST_ERROR", cmd)
387
+ subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
388
+ if ctx.exec_log is not None:
389
+ ctx.exec_log.log_status_info(f"Metacommand error: {logged_error(e)}")
390
+ _fail_open_batches(ctx, source, line_no)
391
+ if status.halt_on_metacommand_err or always_halt:
392
+ exit_now(1, e)
393
+ status.error_history.append((source, line_no, cmd, e.errmsg()))
394
+
395
+
302
396
  def _exec_metacommand(
303
397
  ctx: RuntimeContext,
304
398
  cmd: str,
@@ -323,11 +417,7 @@ def _exec_metacommand(
323
417
  assert subvars is not None
324
418
 
325
419
  if _VARLIKE.search(cmd):
326
- from execsql.utils.fileio import redact
327
-
328
- output.write(
329
- f"Warning: There is a potential un-substituted variable in the command\n {redact(cmd)}\n",
330
- )
420
+ _warn_unsubstituted(cmd)
331
421
  e = None
332
422
  try:
333
423
  applies, result = metacommandlist.eval(cmd)
@@ -335,28 +425,16 @@ def _exec_metacommand(
335
425
  if (manifest := _manifest.current()) is not None:
336
426
  manifest.record_metacommand(cmd, source, line_no, metacommandlist)
337
427
  return result
338
- except ErrInfo as errinfo:
339
- e = errinfo
340
- except SystemExit:
341
- raise
342
428
  except Exception as exc:
343
- e = ErrInfo(type="exception", exception_msg=exception_desc())
344
- e.__cause__ = exc
429
+ e = _as_errinfo(exc)
345
430
  if e:
346
- stamp_errinfo(e)
347
- status.metacommand_error = True
348
- subvars.add_substitution("$LAST_ERROR", cmd)
349
- subvars.add_substitution("$ERROR_MESSAGE", e.errmsg())
350
- if ctx.exec_log is not None:
351
- ctx.exec_log.log_status_info(f"Metacommand error: {logged_error(e)}")
352
- if status.halt_on_metacommand_err:
353
- exit_now(1, e)
431
+ _metacommand_failed(ctx, cmd, e, source, line_no)
354
432
  if (manifest := _manifest.current()) is not None:
355
433
  manifest.record_metacommand(cmd, source, line_no, metacommandlist)
356
- status.error_history.append((source, line_no, cmd, e.errmsg()))
357
434
  return None
358
435
  # No handler matched — truly unknown metacommand
359
436
  status.metacommand_error = True
437
+ _fail_open_batches(ctx, source, line_no)
360
438
  raise ErrInfo(type="cmd", command_text=cmd, other_msg="Unknown metacommand")
361
439
 
362
440
 
@@ -443,10 +521,22 @@ def _execute_node(
443
521
  # !!$COUNTER_N!! and re-roll !!$RANDOM!!/!!$UUID!! references.
444
522
  effective_locals = _stack_localvars(ctx) or localvars
445
523
  expanded = substitute_vars(command, effective_locals, ctx=ctx)
446
- if _BREAK_RX.match(expanded):
447
- raise _BreakLoop
448
524
  ctx.last_command = ExecutingStatement(node)
449
- _exec_metacommand(ctx, expanded, node.span.file, node.span.start_line)
525
+ if not _BREAK_RX.match(expanded):
526
+ _exec_metacommand(ctx, expanded, node.span.file, node.span.start_line)
527
+ elif _break_has_target(ctx):
528
+ raise _BreakLoop
529
+ else:
530
+ _metacommand_failed(
531
+ ctx,
532
+ expanded,
533
+ ErrInfo(
534
+ type="cmd",
535
+ other_msg="BREAK has nothing to leave: it is not inside a LOOP, SCRIPT or included file.",
536
+ ),
537
+ node.span.file,
538
+ node.span.start_line,
539
+ )
450
540
 
451
541
  elif isinstance(node, IfBlock):
452
542
  ctx.last_command = ExecutingStatement(node)
@@ -499,7 +589,7 @@ def _execute_if(
499
589
  """
500
590
  from execsql.state import ExecFrame
501
591
 
502
- if _eval_condition(ctx, node.condition, node.condition_modifiers):
592
+ if _condition_holds(ctx, node.command, node.span, node.condition, node.condition_modifiers):
503
593
  _push_block_frame(
504
594
  ctx,
505
595
  ExecFrame(kind="if", label=node.condition, source=node.span.file, line=node.span.start_line),
@@ -512,7 +602,7 @@ def _execute_if(
512
602
 
513
603
  # Try ELSEIF clauses
514
604
  for clause in node.elseif_clauses:
515
- if _eval_condition(ctx, clause.condition, clause.condition_modifiers):
605
+ if _condition_holds(ctx, clause.command, clause.span, clause.condition, clause.condition_modifiers):
516
606
  _push_block_frame(
517
607
  ctx,
518
608
  ExecFrame(kind="elseif", label=clause.condition, source=node.span.file, line=node.span.start_line),
@@ -552,9 +642,8 @@ def _execute_loop(
552
642
  try:
553
643
  if node.loop_type == "WHILE":
554
644
  while True:
555
- effective_locals = _stack_localvars(ctx)
556
- expanded = substitute_vars(condition, effective_locals, ctx=ctx)
557
- if not xcmd_test(expanded):
645
+ ctx.last_command = ExecutingStatement(node)
646
+ if not _condition_holds(ctx, node.command, node.span, condition):
558
647
  break
559
648
  frame.iteration += 1
560
649
  try:
@@ -568,9 +657,8 @@ def _execute_loop(
568
657
  _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=True)
569
658
  except _BreakLoop:
570
659
  break
571
- effective_locals = _stack_localvars(ctx)
572
- expanded = substitute_vars(condition, effective_locals, ctx=ctx)
573
- if xcmd_test(expanded):
660
+ ctx.last_command = ExecutingStatement(node)
661
+ if _condition_holds(ctx, node.command, node.span, condition):
574
662
  break
575
663
  finally:
576
664
  ctx.ast_exec_stack.pop()
@@ -583,7 +671,19 @@ def _execute_batch(
583
671
  *,
584
672
  in_loop: bool = False,
585
673
  ) -> None:
586
- """Execute a BEGIN BATCH / END BATCH block."""
674
+ """Execute a BEGIN BATCH / END BATCH block.
675
+
676
+ Reaching END BATCH commits the batch; a commit that fails is an SQL error
677
+ on the END BATCH line, after the batch's uncommitted databases are rolled
678
+ back, and every enclosing batch then rolls back at its own END BATCH
679
+ instead of committing. Leaving it any other way (BREAK,
680
+ an error, HALT) abandons it: see
681
+ :meth:`~execsql.script.control.BatchLevels.abandon_batch` for when that
682
+ rolls back and when an enclosing batch takes the statements over. A
683
+ halt usually gets there first: :func:`~execsql.utils.errors.exit_now`
684
+ abandons every open batch before an ON ERROR_HALT script runs, so
685
+ nothing is left to do here.
686
+ """
587
687
  from execsql.state import ExecFrame
588
688
 
589
689
  status = ctx.status
@@ -593,12 +693,35 @@ def _execute_batch(
593
693
  ctx,
594
694
  ExecFrame(kind="batch", label="", source=node.span.file, line=node.span.start_line),
595
695
  )
696
+ completed = False
596
697
  try:
597
698
  _execute_nodes(ctx, node.body, node.span.file, localvars, in_loop=in_loop)
699
+ completed = True
598
700
  finally:
599
701
  ctx.ast_exec_stack.pop()
600
- if status.batch.in_batch():
702
+ if status.batch.in_batch() and not completed:
703
+ status.batch.abandon_batch()
704
+ if status.batch.in_batch():
705
+ end_line = node.span.effective_end_line
706
+ ctx.last_command = ExecutingStatement(
707
+ MetaCommandStatement(span=SourceSpan(node.span.file, end_line), command="END BATCH"),
708
+ )
709
+ failure = status.batch.failure()
710
+ if failure is not None:
711
+ status.batch.abandon_batch()
712
+ e = ErrInfo(type="error", other_msg=f"The batch was not committed: {failure}.")
713
+ _sql_failed(ctx, e, "END BATCH", node.span.file, end_line)
714
+ return
715
+ # The commit is the batch's last SQL: SQL_ERROR() right after END
716
+ # BATCH says whether it failed, as after any statement.
717
+ status.sql_error = False
718
+ try:
601
719
  status.batch.end_batch()
720
+ except Exception as exc:
721
+ # end_batch has rolled back what it had not committed, which can
722
+ # include an enclosing batch's statements on a shared connection;
723
+ # _sql_failed keeps that batch from committing the rest.
724
+ _sql_failed(ctx, _as_errinfo(exc), "END BATCH", node.span.file, end_line)
602
725
 
603
726
 
604
727
  def _pre_register_scripts(ctx: RuntimeContext, nodes: list[Node]) -> None:
@@ -649,107 +772,123 @@ def _execute_include(
649
772
  node: IncludeDirective,
650
773
  localvars: SubVarSet | None = None,
651
774
  ) -> None:
652
- """Execute an INCLUDE or EXECUTE SCRIPT directive.
653
-
654
- **INCLUDE** is handled natively: the target file is parsed by the AST
655
- parser and executed through the AST executor with circular-include
656
- detection.
775
+ """Run an INCLUDE or EXECUTE SCRIPT written in the script.
657
776
 
658
- **EXECUTE SCRIPT** is handled natively when the target is in
659
- ``ctx.ast_scripts``: arguments are parsed, a local variable overlay
660
- is created, and the body is executed through the AST executor.
661
- WHILE/UNTIL loops are handled natively too.
777
+ Variables in its file, script name and arguments are substituted once,
778
+ as in any other metacommand, so ``!!$COUNTER_1!!`` counts once and
779
+ ``$LAST_ERROR`` holds the text that ran. An EXECUTE SCRIPT ``WHILE`` /
780
+ ``UNTIL`` condition is substituted on each pass instead.
662
781
  """
663
- if node.is_execute_script:
664
- # Substitute variables in the target — the script name may be passed
665
- # as a parameter (e.g., EXECUTE SCRIPT !!#script_name!!).
666
- effective_locals = _stack_localvars(ctx) or localvars
667
- target = substitute_vars(node.target, effective_locals, ctx=ctx).strip().lower()
668
-
669
- # Native path: target is in our AST registry
670
- if target in ctx.ast_scripts:
671
- _execute_script_native(ctx, node, ctx.ast_scripts[target], localvars)
672
- return
673
-
674
- # Target not registered — IF EXISTS silently skips; otherwise error.
675
- if node.if_exists:
676
- return
677
- raise ErrInfo(
678
- "cmd",
679
- other_msg=f"There is no SCRIPT named {node.target}.",
680
- )
681
-
682
- # --- INCLUDE (file inclusion) — parse and execute natively ---
683
- _execute_include_native(ctx, node, localvars)
782
+ effective_locals = _stack_localvars(ctx) or localvars
783
+ directive = substitute_directive(node, lambda text: substitute_vars(text, effective_locals, ctx=ctx))
784
+ _run_directive(ctx, directive, localvars)
684
785
 
685
786
 
686
- def _execute_script_native(
787
+ def _run_directive(
687
788
  ctx: RuntimeContext,
688
789
  node: IncludeDirective,
689
- script_block: ScriptBlock,
690
790
  localvars: SubVarSet | None = None,
691
791
  ) -> None:
692
- """Execute a SCRIPT block natively through the AST executor.
792
+ """Run an INCLUDE or EXECUTE SCRIPT whose variables have been substituted.
693
793
 
694
- Pushes a SCRIPT-kind :class:`~execsql.state.ExecFrame` onto
695
- ``ctx.ast_exec_stack`` so that metacommand handlers (``x_sub``,
696
- ``x_rm_sub``, ``xf_sub_defined``, prompt handlers, etc.) can access
697
- ``~`` local variables and ``#`` script arguments via
698
- ``ctx.current_localvars()`` / ``ctx.current_paramvals()``. The frame
699
- is popped on exit (including on error) via ``try/finally``.
794
+ **INCLUDE** parses the target file with the AST parser and executes it
795
+ with circular-include detection. **EXECUTE SCRIPT** runs a SCRIPT
796
+ registered in ``ctx.ast_scripts`` with its arguments in a local variable
797
+ overlay, once or in a WHILE / UNTIL loop.
798
+
799
+ The INCLUDE or EXECUTE SCRIPT failing itself (no such file or script, a
800
+ missing argument) is a metacommand error; see :func:`_directive_failed`.
801
+ """
802
+ if (manifest := _manifest.current()) is not None:
803
+ assert ctx.metacommandlist is not None
804
+ manifest.record_metacommand(node.command, node.span.file, node.span.start_line, ctx.metacommandlist)
805
+
806
+ if not node.is_execute_script:
807
+ _execute_include_native(ctx, node, localvars)
808
+ return
809
+
810
+ target = node.target.strip().lower()
811
+ script_block = ctx.ast_scripts.get(target)
812
+ if script_block is None:
813
+ # IF EXISTS silently skips a SCRIPT that is not defined.
814
+ if not node.if_exists:
815
+ _directive_failed(ctx, node, ErrInfo("cmd", other_msg=f"There is no SCRIPT named {node.target.strip()}."))
816
+ return
817
+ try:
818
+ paramvals = _script_args(node, script_block)
819
+ except ErrInfo as e:
820
+ _directive_failed(ctx, node, e)
821
+ return
822
+ _execute_script_native(ctx, node, script_block, paramvals)
823
+
824
+
825
+ def _directive_failed(ctx: RuntimeContext, node: IncludeDirective, e: ErrInfo) -> None:
826
+ """INCLUDE or EXECUTE SCRIPT itself failed (no such file or script, a missing argument): a metacommand error.
827
+
828
+ Errors raised by the statements an included file or script runs are
829
+ theirs, not the directive's, and are handled where they happen.
830
+ """
831
+ _metacommand_failed(ctx, node.command, e, node.span.file, node.span.start_line)
832
+
833
+
834
+ def _script_args(node: IncludeDirective, script_block: ScriptBlock) -> ScriptArgSubVarSet | None:
835
+ """The arguments an EXECUTE SCRIPT passes to *script_block*, with defaults for those it leaves out.
836
+
837
+ ``node.arguments`` must already be substituted. Raises :class:`ErrInfo`
838
+ when a required parameter has no argument.
700
839
  """
701
- from execsql.script.variables import ScriptArgSubVarSet
702
840
  from execsql.utils.strings import wo_quotes
703
841
 
704
- # Parse arguments (replicates ScriptExecSpec logic)
705
- # Expand the argument string in the *caller's* scope so that references
706
- # like val=!!#parent_param!! or val=!!~parent_local!! resolve correctly
707
- # before we push a new scope for this script.
708
- paramvals: ScriptArgSubVarSet | None = None
709
842
  if node.arguments is not None:
710
- caller_locals = _stack_localvars(ctx)
711
- expanded_args = substitute_vars(node.arguments, caller_locals, ctx=ctx)
712
843
  args_rx = re.compile(
713
844
  r'(?P<param>#?\w+)\s*=\s*(?P<arg>(?:(?:[^"\'\[][^,\)]*)|(?:"[^"]*")|(?:\'[^\']*\')|(?:\[[^\]]*\])))',
714
845
  re.I,
715
846
  )
716
- all_args = re.findall(args_rx, expanded_args)
847
+ all_args = re.findall(args_rx, node.arguments)
717
848
  all_cleaned_args = [(ae[0], wo_quotes(ae[1])) for ae in all_args]
718
849
  all_prepared_args = [(ae[0] if ae[0][0] == "#" else "#" + ae[0], ae[1]) for ae in all_cleaned_args]
719
850
  paramvals = ScriptArgSubVarSet()
720
851
  for param, arg in all_prepared_args:
721
852
  paramvals.add_substitution(param, arg)
722
-
723
- # Validate parameter names match — with default parameter support
724
- if script_block.param_defs is not None:
725
- passed_names = [p[0].lstrip("#") for p in all_prepared_args]
726
- required = [p.name for p in script_block.param_defs if p.required]
727
- missing = [p for p in required if p not in passed_names]
728
- if missing:
729
- raise ErrInfo(
730
- "error",
731
- other_msg=(f"Missing required parameter(s) ({', '.join(missing)}) in call to {script_block.name}."),
732
- )
733
- # Inject defaults for optional params not provided
734
- for pdef in script_block.param_defs:
735
- if pdef.default is not None and pdef.name not in passed_names:
736
- paramvals.add_substitution(f"#{pdef.name}", pdef.default)
853
+ passed_names = [p[0].lstrip("#") for p in all_prepared_args]
737
854
  else:
738
- if script_block.param_defs is not None:
739
- required = [p.name for p in script_block.param_defs if p.required]
740
- if required:
741
- raise ErrInfo(
742
- "error",
743
- other_msg=(
744
- f"Missing required parameter(s) ({', '.join(required)}) in call to {script_block.name}."
745
- ),
746
- )
747
- # No args provided but all params have defaults — inject them all
748
- paramvals = ScriptArgSubVarSet()
749
- for pdef in script_block.param_defs:
750
- if pdef.default is not None:
751
- paramvals.add_substitution(f"#{pdef.name}", pdef.default)
855
+ if script_block.param_defs is None:
856
+ return None
857
+ paramvals = ScriptArgSubVarSet()
858
+ passed_names = []
859
+
860
+ # Validate parameter names match — with default parameter support
861
+ if script_block.param_defs is not None:
862
+ missing = [p.name for p in script_block.param_defs if p.required and p.name not in passed_names]
863
+ if missing:
864
+ raise ErrInfo(
865
+ "error",
866
+ other_msg=f"Missing required parameter(s) ({', '.join(missing)}) in call to {script_block.name}.",
867
+ )
868
+ # Inject defaults for optional params not provided
869
+ for pdef in script_block.param_defs:
870
+ if pdef.default is not None and pdef.name not in passed_names:
871
+ paramvals.add_substitution(f"#{pdef.name}", pdef.default)
872
+ return paramvals
873
+
752
874
 
875
+ def _execute_script_native(
876
+ ctx: RuntimeContext,
877
+ node: IncludeDirective,
878
+ script_block: ScriptBlock,
879
+ paramvals: ScriptArgSubVarSet | None,
880
+ ) -> None:
881
+ """Execute a SCRIPT block natively through the AST executor.
882
+
883
+ *paramvals* are its arguments, from :func:`_script_args`.
884
+
885
+ Pushes a SCRIPT-kind :class:`~execsql.state.ExecFrame` onto
886
+ ``ctx.ast_exec_stack`` so that metacommand handlers (``x_sub``,
887
+ ``x_rm_sub``, ``xf_sub_defined``, prompt handlers, etc.) can access
888
+ ``~`` local variables and ``#`` script arguments via
889
+ ``ctx.current_localvars()`` / ``ctx.current_paramvals()``. The frame
890
+ is popped on exit (including on error) via ``try/finally``.
891
+ """
753
892
  # Push a SCRIPT-kind ExecFrame onto ast_exec_stack so that:
754
893
  # - get_subvarset() can find ~local and +outer-scope variables
755
894
  # - xf_sub_defined/xf_sub_empty can check ~local and #param variables
@@ -782,9 +921,8 @@ def _execute_script_native(
782
921
 
783
922
  if node.loop_type == "WHILE":
784
923
  while True:
785
- effective_locals = _stack_localvars(ctx)
786
- expanded = substitute_vars(condition, effective_locals, ctx=ctx)
787
- if not xcmd_test(expanded):
924
+ ctx.last_command = ExecutingStatement(node)
925
+ if not _condition_holds(ctx, node.command, node.span, condition):
788
926
  break
789
927
  frame.iteration += 1
790
928
  try:
@@ -798,18 +936,14 @@ def _execute_script_native(
798
936
  _run_body()
799
937
  except _BreakLoop:
800
938
  break
801
- effective_locals = _stack_localvars(ctx)
802
- expanded = substitute_vars(condition, effective_locals, ctx=ctx)
803
- if xcmd_test(expanded):
939
+ ctx.last_command = ExecutingStatement(node)
940
+ if _condition_holds(ctx, node.command, node.span, condition):
804
941
  break
805
942
  else:
806
943
  try:
807
944
  _run_body()
808
- except _BreakLoop as exc:
809
- raise ErrInfo(
810
- type="cmd",
811
- other_msg=f"BREAK metacommand inside SCRIPT '{script_block.name}' but not inside a LOOP.",
812
- ) from exc
945
+ except _BreakLoop:
946
+ pass # BREAK outside a LOOP in the script returns from it
813
947
  finally:
814
948
  _pop_frame(ctx)
815
949
 
@@ -824,24 +958,68 @@ def _execute_include_native(
824
958
  Handles tilde expansion, IF EXISTS, logging, and circular-include
825
959
  detection via ``ctx.include_chain``.
826
960
  """
827
- from execsql.script.parser import parse_script
828
- from execsql.utils.errors import file_size_date
961
+ try:
962
+ loaded = _load_include(ctx, node)
963
+ except _OutsideIncludeRoot as refused:
964
+ # A security boundary, not a script error to carry on from: the run
965
+ # stops whatever METACOMMAND_ERROR_HALT says.
966
+ _metacommand_failed(ctx, node.command, refused.errinfo, node.span.file, node.span.start_line, always_halt=True)
967
+ return
968
+ except Exception as exc:
969
+ _directive_failed(ctx, node, _as_errinfo(exc))
970
+ return
971
+ if loaded is None:
972
+ return
973
+ resolved, included_tree = loaded
829
974
 
830
- # Substitute variables in the target path
831
- effective_locals = _stack_localvars(ctx) or localvars
832
- target = substitute_vars(node.target, effective_locals, ctx=ctx).strip()
975
+ # Pre-register SCRIPT blocks in the included file so forward references work.
976
+ _pre_register_scripts(ctx, included_tree.body)
977
+
978
+ # Execute with include-chain tracking
979
+ from execsql.state import ExecFrame
980
+
981
+ ctx.include_chain.append(resolved)
982
+ _push_block_frame(
983
+ ctx,
984
+ ExecFrame(kind="include", label=Path(resolved).name, source=resolved, line=1),
985
+ )
986
+ try:
987
+ _execute_nodes(ctx, included_tree.body, included_tree.source, localvars)
988
+ except _BreakLoop:
989
+ pass # BREAK outside a LOOP in the file leaves it
990
+ finally:
991
+ ctx.ast_exec_stack.pop()
992
+ ctx.include_chain.pop()
993
+
994
+
995
+ class _OutsideIncludeRoot(Exception):
996
+ """An INCLUDE's file is outside ``include_root``; *errinfo* says where it resolved."""
833
997
 
834
- # Strip surrounding quotes — the AST parser captures the full target
835
- # including any quotes, but the legacy dispatch regex stripped them.
836
- if len(target) >= 2 and target[0] in ('"', "'") and target[-1] == target[0]:
837
- target = target[1:-1]
998
+ def __init__(self, errinfo: ErrInfo) -> None:
999
+ super().__init__(str(errinfo))
1000
+ self.errinfo = errinfo
838
1001
 
839
- # Tilde expansion (matches x_include legacy handler)
1002
+
1003
+ def _load_include(
1004
+ ctx: RuntimeContext,
1005
+ node: IncludeDirective,
1006
+ ) -> tuple[str, Any] | None:
1007
+ """Find and parse the file an INCLUDE names: ``(resolved path, tree)``, or ``None`` to skip it (IF EXISTS).
1008
+
1009
+ Raises :class:`_OutsideIncludeRoot` for a file outside ``include_root``,
1010
+ and :class:`ErrInfo` when the INCLUDE itself fails: no such file, a
1011
+ circular include, or a file that does not parse.
1012
+ """
1013
+ from execsql.script.parser import parse_script
1014
+ from execsql.utils.errors import file_size_date
1015
+
1016
+ target = node.target
1017
+ # Tilde expansion (matches the monolith's x_include)
840
1018
  if len(target) > 1 and target[0] == "~" and target[1] == os.sep:
841
1019
  target = str(Path.home() / target[2:])
842
1020
 
843
- # Optional containment: when conf.include_root is set, the resolved
844
- # INCLUDE / EXECUTE SCRIPT target must live under that root.
1021
+ # Optional containment: when conf.include_root is set, the file must
1022
+ # live under that root.
845
1023
  include_root = getattr(ctx.conf, "include_root", None) if hasattr(ctx, "conf") else None
846
1024
  if include_root is None:
847
1025
  import execsql.state as _state
@@ -850,14 +1028,17 @@ def _execute_include_native(
850
1028
  if include_root:
851
1029
  from execsql.utils.fileio import safe_output_path
852
1030
 
853
- target = safe_output_path(target, include_root)
1031
+ try:
1032
+ target = safe_output_path(target, include_root)
1033
+ except ErrInfo as e:
1034
+ raise _OutsideIncludeRoot(e) from e
854
1035
 
855
1036
  target_path = Path(target)
856
1037
 
857
1038
  # IF EXISTS handling
858
1039
  if node.if_exists:
859
1040
  if not target_path.is_file():
860
- return
1041
+ return None
861
1042
  else:
862
1043
  if not target_path.is_file():
863
1044
  raise ErrInfo(type="error", other_msg=f"File {target} does not exist.")
@@ -883,23 +1064,7 @@ def _execute_include_native(
883
1064
  included_tree = parse_script(target, encoding=encoding)
884
1065
  if (manifest := _manifest.current()) is not None:
885
1066
  manifest.record_include(target, node.span.file, node.span.start_line)
886
-
887
- # Pre-register SCRIPT blocks in the included file so forward references work.
888
- _pre_register_scripts(ctx, included_tree.body)
889
-
890
- # Execute with include-chain tracking
891
- from execsql.state import ExecFrame
892
-
893
- ctx.include_chain.append(resolved)
894
- _push_block_frame(
895
- ctx,
896
- ExecFrame(kind="include", label=Path(resolved).name, source=resolved, line=1),
897
- )
898
- try:
899
- _execute_nodes(ctx, included_tree.body, included_tree.source, localvars)
900
- finally:
901
- ctx.ast_exec_stack.pop()
902
- ctx.include_chain.pop()
1067
+ return resolved, included_tree
903
1068
 
904
1069
 
905
1070
  # ---------------------------------------------------------------------------
@@ -908,7 +1073,7 @@ def _execute_include_native(
908
1073
 
909
1074
 
910
1075
  class _BreakLoop(BaseException):
911
- """Raised by BREAK metacommand to exit the innermost loop.
1076
+ """Raised by BREAK to leave the innermost LOOP, SCRIPT or included file.
912
1077
 
913
1078
  A ``BaseException``, like ``KeyboardInterrupt``, so the ``except
914
1079
  Exception`` handlers it may pass on its way to the loop leave it alone:
@@ -917,13 +1082,23 @@ class _BreakLoop(BaseException):
917
1082
  """
918
1083
 
919
1084
 
920
- def _inside_loop(ctx: RuntimeContext) -> bool:
921
- """Whether a BREAK at the current statement would leave a loop within its scope."""
1085
+ def _break_has_target(ctx: RuntimeContext) -> bool:
1086
+ """Whether a BREAK at the current statement has something to leave.
1087
+
1088
+ BREAK leaves the innermost LOOP, SCRIPT or included file (a SCRIPT that
1089
+ ``EXECUTE SCRIPT … WHILE`` / ``UNTIL`` repeats stops repeating); in the main
1090
+ script outside all of them there is nothing to leave.
1091
+
1092
+ Each kind listed here must have a runner that catches ``_BreakLoop``:
1093
+ ``_execute_loop``, ``_execute_script_native`` and
1094
+ ``_execute_include_native``. A ``_BreakLoop`` nobody catches would end
1095
+ the run as an uncaught BaseException.
1096
+ """
922
1097
  for frame in reversed(ctx.ast_exec_stack):
923
- if frame.kind in ("loop_while", "loop_until"):
1098
+ if frame.kind in ("loop_while", "loop_until", "script", "include"):
924
1099
  return True
925
- if frame.kind in ("script", "main"):
926
- return frame.kind == "script" and frame.iteration > 0
1100
+ if frame.kind == "main":
1101
+ return False
927
1102
  return False
928
1103
 
929
1104
 
@@ -986,9 +1161,8 @@ class ExecutingStatement:
986
1161
  node = self.node
987
1162
  if isinstance(node, SqlStatement):
988
1163
  return _StatementText(node.text)
989
- if isinstance(node, MetaCommandStatement):
990
- return _StatementText(node.command, "-- !x! ")
991
- return _StatementText("")
1164
+ command = getattr(node, "command", "")
1165
+ return _StatementText(command, "-- !x! " if command else "")
992
1166
 
993
1167
  def __repr__(self) -> str:
994
1168
  return f"ExecutingStatement({self.source}:{self.line_no}, {self.command_type})"
@@ -1006,23 +1180,7 @@ def _node_cmd_type(node: Node) -> str:
1006
1180
 
1007
1181
 
1008
1182
  def _node_cmd_text(node: Node) -> str:
1009
- if isinstance(node, SqlStatement):
1010
- return node.text
1011
- if isinstance(node, MetaCommandStatement):
1012
- return "-- !x! " + node.command
1013
- if isinstance(node, IfBlock):
1014
- return f"-- !x! IF ({node.condition})"
1015
- if isinstance(node, LoopBlock):
1016
- return f"-- !x! LOOP {node.loop_type} ({node.condition})"
1017
- if isinstance(node, BatchBlock):
1018
- return "-- !x! BEGIN BATCH"
1019
- if isinstance(node, ScriptBlock):
1020
- return f"-- !x! BEGIN SCRIPT {node.name}"
1021
- if isinstance(node, IncludeDirective):
1022
- if node.is_execute_script:
1023
- return f"-- !x! EXECUTE SCRIPT {node.target}"
1024
- return f"-- !x! INCLUDE {node.target}"
1025
- return repr(node)
1183
+ return ExecutingStatement(node).command.commandline() or repr(node)
1026
1184
 
1027
1185
 
1028
1186
  # ---------------------------------------------------------------------------
@@ -1068,11 +1226,6 @@ def execute(script: Script, *, ctx: RuntimeContext | None = None) -> None:
1068
1226
  _push_frame(ctx, "<main>", script.source, line_no=1, kind="main")
1069
1227
  try:
1070
1228
  _execute_nodes(ctx, script.body, script.source)
1071
- except _BreakLoop as exc:
1072
- raise ErrInfo(
1073
- type="cmd",
1074
- other_msg="BREAK metacommand outside of a LOOP block.",
1075
- ) from exc
1076
1229
  finally:
1077
1230
  _pop_frame(ctx)
1078
1231
 
@@ -1091,7 +1244,8 @@ def execute_input(
1091
1244
  """Run input typed at a BREAKPOINT prompt as the next lines of the paused script.
1092
1245
 
1093
1246
  The input runs as if it were written where the script paused: in its
1094
- variable scope, its batch and its loop, so a BREAK leaves that loop.
1247
+ variable scope, its batch and its loop, so a BREAK leaves that loop
1248
+ (or the SCRIPT or included file it paused in).
1095
1249
  Unlike :func:`execute`, nothing is reset.
1096
1250
 
1097
1251
  *show* receives the result of each SQL statement run. An error that
@@ -1107,10 +1261,6 @@ def execute_input(
1107
1261
  ctx.prompt_input = show
1108
1262
  try:
1109
1263
  _execute_nodes(ctx, script.body, script.source)
1110
- except _BreakLoop as exc:
1111
- if not _inside_loop(ctx):
1112
- raise ErrInfo(type="cmd", other_msg="BREAK metacommand outside of a LOOP block.") from exc
1113
- raise
1114
1264
  finally:
1115
1265
  ctx.prompt_input = prompt_input
1116
1266
  ctx.last_command = last_command