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/api.py CHANGED
@@ -15,7 +15,7 @@ Usage::
15
15
  result = run(sql="CREATE TABLE t (id INT); INSERT INTO t VALUES (1);",
16
16
  dsn="sqlite:///my.db")
17
17
 
18
- # With substitution variables
18
+ # With substitution variables (referenced as !!$SCHEMA!! and !!$DATE!!)
19
19
  result = run(script="etl.sql",
20
20
  dsn="postgresql://user:pass@host/db",
21
21
  variables={"SCHEMA": "public", "DATE": "2026-01-01"})
@@ -29,7 +29,6 @@ Usage::
29
29
 
30
30
  from __future__ import annotations
31
31
 
32
- import atexit
33
32
  import dataclasses
34
33
  import datetime
35
34
  import io
@@ -57,7 +56,9 @@ class ScriptError:
57
56
 
58
57
  Attributes:
59
58
  message: Human-readable error description.
60
- source: Script file path or ``"<inline>"``.
59
+ source: Script file path or ``"<inline>"`` for an error in the
60
+ script; ``"<config>"``, ``"<connect>"`` or ``"<output>"`` for a
61
+ config file, connection or output file that failed.
61
62
  line: Source line number, or ``None`` if unknown.
62
63
  sql: The SQL statement that caused the error, if applicable.
63
64
  """
@@ -77,10 +78,10 @@ class ScriptResult:
77
78
  commands_run: Number of SQL statements and metacommands executed.
78
79
  elapsed: Wall-clock execution time in seconds.
79
80
  errors: List of errors encountered (empty on success).
80
- variables: Final state of substitution variables, keyed by name
81
- without any sigil. Includes user-defined variables (no prefix)
82
- and system variables (the leading ``$`` is stripped, so
83
- ``$DATE_TAG`` appears as ``"DATE_TAG"``). Environment (``&``),
81
+ variables: Final state of substitution variables, keyed by
82
+ lower-case name without any sigil. Includes user-defined
83
+ variables (no prefix) and system variables (the leading ``$`` is
84
+ stripped, so ``$DATE_TAG`` appears as ``"date_tag"``). Environment (``&``),
84
85
  column-data (``@``), script-local (``~``), and script-parameter
85
86
  (``#``) variables are excluded.
86
87
  """
@@ -217,6 +218,8 @@ class _LibraryConfig:
217
218
  self.smtp_password: str | None = None
218
219
  self.smtp_ssl = False
219
220
  self.smtp_tls = False
221
+ self.smtp_verify_certificate = True
222
+ self.smtp_ca_file: str | None = None
220
223
  self.email_format = "plain"
221
224
  self.email_css: str | None = None
222
225
 
@@ -352,7 +355,8 @@ def run(
352
355
  connection: A pre-existing :class:`~execsql.db.base.Database`
353
356
  instance. ``run()`` will NOT close this connection on exit.
354
357
  variables: Substitution variables as ``{"NAME": "value"}``.
355
- Keys without a ``$`` prefix get one added automatically.
358
+ Keys without a ``$`` prefix get one added automatically, so
359
+ ``{"SCHEMA": "public"}`` is referenced as ``!!$SCHEMA!!``.
356
360
  config_file: Optional execsql configuration file to load.
357
361
  encoding: Script file encoding (default ``"utf-8"``).
358
362
  halt_on_error: If ``True`` (default), stop on the first SQL
@@ -373,7 +377,9 @@ def run(
373
377
 
374
378
  Returns:
375
379
  A :class:`ScriptResult` with execution outcome, timing, errors,
376
- and final variable state.
380
+ and final variable state. A script that cannot be read, a bad
381
+ *config_file*, and a *dsn* that cannot be parsed or opened all
382
+ return ``success=False`` rather than raising.
377
383
 
378
384
  Raises:
379
385
  ValueError: If the argument combination is invalid (e.g. both
@@ -404,14 +410,8 @@ def run(
404
410
  else:
405
411
  assert sql is not None
406
412
  tree = parse_string(sql, source_name="<inline>")
407
- except ErrInfo as exc:
408
- return ScriptResult(
409
- success=False,
410
- commands_run=0,
411
- elapsed=0.0,
412
- errors=[ScriptError(message=exc.errmsg(), source=str(script) if script else "<inline>")],
413
- variables={},
414
- )
413
+ except (ErrInfo, OSError) as exc:
414
+ return _failed(_error_text(exc), str(script) if script else "<inline>")
415
415
 
416
416
  # ------------------------------------------------------------------
417
417
  # Build an isolated RuntimeContext
@@ -425,9 +425,14 @@ def run(
425
425
  # Load a real ConfigData with the explicit config file
426
426
  from execsql.script.variables import SubVarSet
427
427
 
428
+ if not Path(config_file).is_file():
429
+ return _failed(f"Config file {str(config_file)!r} does not exist.", "<config>")
428
430
  temp_subvars = SubVarSet()
429
431
  script_dir = str(Path(script).resolve().parent) if script else os.getcwd()
430
- conf = ConfigData(script_dir, temp_subvars, config_file=str(config_file))
432
+ try:
433
+ conf = ConfigData(script_dir, temp_subvars, config_file=str(config_file))
434
+ except Exception as exc:
435
+ return _failed(_error_text(exc), "<config>")
431
436
  # Apply any overrides
432
437
  for k, v in conf_overrides.items():
433
438
  setattr(conf, k, v)
@@ -492,89 +497,103 @@ def run(
492
497
  # Connect to database
493
498
  # ------------------------------------------------------------------
494
499
  owns_connection = connection is None
495
- if dsn is not None:
496
- db = _connect_from_dsn(dsn, new_db=new_db)
497
- else:
498
- db = connection
499
-
500
- assert db is not None
501
- assert ctx.dbs is not None
502
- ctx.dbs.add("initial", db)
503
- ctx.subvars.add_substitution("$CURRENT_DBMS", db.type.dbms_id)
504
- ctx.subvars.add_substitution("$CURRENT_DATABASE", db.name())
505
- ctx.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", "0")
506
-
507
- # ------------------------------------------------------------------
508
- # Execute
509
- # ------------------------------------------------------------------
510
- from execsql.script.executor import execute
511
-
512
- errors: list[ScriptError] = []
513
- t0 = time.perf_counter()
514
-
515
- # WRITE ... TO <file> and TEE are handled by a FileWriter subprocess,
516
- # and fileio drops every write when none is running. Start one here so
517
- # the API behaves like the CLI.
518
- _ensure_filewriter(conf)
519
-
500
+ # Everything from here on runs inside try/finally so an owned
501
+ # connection is closed and temp files removed however run() exits,
502
+ # including KeyboardInterrupt in a notebook.
520
503
  try:
521
- execute(tree, ctx=ctx)
522
- except SystemExit:
523
- # exit_now() calls sys.exit() — catch and convert to error
524
- _capture_errors(ctx, errors)
525
- except ErrInfo as exc:
526
- errors.append(
527
- ScriptError(
528
- message=exc.errmsg(),
529
- source=_last_source(ctx),
530
- line=_last_line(ctx),
531
- sql=getattr(exc, "command_text", None),
532
- ),
533
- )
534
- except Exception as exc:
535
- errors.append(ScriptError(message=str(exc), source="<runtime>"))
536
-
537
- # Non-halting errors (halt_on_error=False, METACOMMAND_ERROR_HALT OFF)
538
- # are recorded in status.error_history as execution continues; a
539
- # halting error never reaches the history, so there is no overlap
540
- # with the exception paths above.
541
- if ctx.status is not None:
542
- for err_source, err_line, err_cmd, err_msg in ctx.status.error_history:
543
- errors.append(ScriptError(message=err_msg, source=err_source, line=err_line, sql=err_cmd))
544
-
545
- elapsed = time.perf_counter() - t0
504
+ if dsn is not None:
505
+ try:
506
+ db = _connect_from_dsn(dsn, new_db=new_db)
507
+ except Exception as exc:
508
+ return _failed(_error_text(exc), "<connect>")
509
+ else:
510
+ db = connection
511
+
512
+ assert db is not None
513
+ assert ctx.dbs is not None
514
+ ctx.dbs.add("initial", db)
515
+ ctx.subvars.add_substitution("$CURRENT_DBMS", db.type.dbms_id)
516
+ ctx.subvars.add_substitution("$CURRENT_DATABASE", db.name())
517
+ ctx.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", "0")
518
+
519
+ # ------------------------------------------------------------------
520
+ # Execute
521
+ # ------------------------------------------------------------------
522
+ from execsql.script.executor import execute
523
+
524
+ errors: list[ScriptError] = []
525
+ t0 = time.perf_counter()
526
+
527
+ # WRITE ... TO <file> and TEE hand their output to a FileWriter, and
528
+ # fileio drops every write when none is running. This run gets its
529
+ # own, so concurrent runs never share files or a halt.
530
+ _start_filewriter(ctx, conf)
546
531
 
547
- # Flush and close every file the script wrote, so the caller can read
548
- # them the moment run() returns. The subprocess itself is left running
549
- # for reuse by a later run() and is reaped by the atexit handler; a
550
- # writer the caller started is theirs and is never shut down here.
551
- from execsql.utils.fileio import filewriter_close_all_after_write
552
-
553
- try:
554
- filewriter_close_all_after_write()
555
- except Exception:
556
- pass # Best-effort: a failed flush must not mask a script error.
532
+ try:
533
+ execute(tree, ctx=ctx)
534
+ except SystemExit as exc:
535
+ # exit_now() calls sys.exit() — catch and convert to error
536
+ _capture_errors(ctx, errors, exc.code)
537
+ except ErrInfo as exc:
538
+ errors.append(
539
+ ScriptError(
540
+ message=exc.errmsg(),
541
+ source=_last_source(ctx),
542
+ line=_last_line(ctx),
543
+ sql=getattr(exc, "command_text", None),
544
+ ),
545
+ )
546
+ except Exception as exc:
547
+ errors.append(ScriptError(message=str(exc), source="<runtime>"))
548
+
549
+ # Non-halting SQL errors (halt_on_error=False) are recorded in
550
+ # status.error_history as execution continues; a halting error never
551
+ # reaches the history, so there is no overlap with the exception
552
+ # paths above. They happened first, so they are listed first.
553
+ if ctx.status is not None:
554
+ errors[:0] = [
555
+ ScriptError(message=err_msg, source=err_source, line=err_line, sql=err_cmd)
556
+ for err_source, err_line, err_cmd, err_msg in ctx.status.error_history
557
+ ]
558
+
559
+ elapsed = time.perf_counter() - t0
560
+
561
+ # Flush and close every file the script wrote, so the caller can read
562
+ # them the moment run() returns. Output to a file that could not be
563
+ # opened is reported as an error. The writer is stopped below.
564
+ from execsql.utils.fileio import filewriter_close_all_after_write
557
565
 
558
- # ------------------------------------------------------------------
559
- # Collect results
560
- # ------------------------------------------------------------------
561
- final_vars = {}
562
- if ctx.subvars is not None:
563
- for name, value in ctx.subvars.substitutions:
564
- # Include user vars and $-prefixed system vars
565
- # Skip environment (&), column (@), local (~), parameter (#) vars
566
- if not name or name[0] in ("&", "@", "~", "#"):
567
- continue
568
- key = name.lstrip("$")
569
- final_vars[key] = str(value) if value is not None else ""
570
-
571
- # Close connection if we own it
572
- if owns_connection:
573
566
  try:
574
- assert ctx.dbs is not None
575
- ctx.dbs.closeall()
567
+ filewriter_close_all_after_write()
568
+ except ErrInfo as exc:
569
+ errors.append(ScriptError(message=exc.errmsg(), source="<output>"))
576
570
  except Exception:
577
- pass
571
+ pass # Best-effort: a failed flush must not mask a script error.
572
+
573
+ # ------------------------------------------------------------------
574
+ # Collect results
575
+ # ------------------------------------------------------------------
576
+ final_vars = {}
577
+ if ctx.subvars is not None:
578
+ for name, value in ctx.subvars.substitutions:
579
+ # Include user vars and $-prefixed system vars
580
+ # Skip environment (&), column (@), local (~), parameter (#) vars
581
+ if not name or name[0] in ("&", "@", "~", "#"):
582
+ continue
583
+ key = name.lstrip("$")
584
+ final_vars[key] = str(value) if value is not None else ""
585
+ finally:
586
+ if ctx.filewriter is not None:
587
+ from execsql.utils.fileio import filewriter_end
588
+
589
+ filewriter_end(ctx.filewriter)
590
+ if owns_connection and ctx.dbs is not None:
591
+ try:
592
+ ctx.dbs.closeall()
593
+ except Exception:
594
+ pass
595
+ if ctx.tempfiles is not None:
596
+ ctx.tempfiles.remove_all()
578
597
 
579
598
  return ScriptResult(
580
599
  success=len(errors) == 0,
@@ -588,80 +607,70 @@ def run(
588
607
  # ---------------------------------------------------------------------------
589
608
  # Helpers
590
609
  # ---------------------------------------------------------------------------
591
- def _ensure_filewriter(conf: Any) -> bool:
592
- """Start the FileWriter subprocess if one is not already running.
610
+ def _start_filewriter(ctx: RuntimeContext, conf: Any) -> None:
611
+ """Give this run its own FileWriter thread, held on *ctx*.
612
+
613
+ ``WRITE ... TO <file>`` and ``TEE`` hand their output to the active run's
614
+ writer, and every entry point in :mod:`execsql.utils.fileio` drops the
615
+ write when none is running. A writer per run keeps concurrent runs from
616
+ sharing open files or reply queues, and a halt in one run (which stops
617
+ its writer) from discarding another run's output. :func:`run` stops the
618
+ writer before it returns.
619
+ """
620
+ from execsql.utils.fileio import FileWriter
593
621
 
594
- ``WRITE ... TO <file>`` and ``TEE`` hand their output to a FileWriter
595
- subprocess, and every entry point in :mod:`execsql.utils.fileio` guards on
596
- the subprocess being alive — dropping the write when it is not, so that a
597
- dead writer cannot deadlock the caller on a full pipe. The CLI starts one
598
- during startup; :func:`run` did not, so every file write under the API was
599
- silently discarded and the run still reported success.
622
+ ctx.filewriter = FileWriter(
623
+ file_encoding=conf.output_encoding,
624
+ open_timeout=getattr(conf, "outfile_open_timeout", 10),
625
+ )
626
+ ctx.filewriter.start()
600
627
 
601
- A writer started here outlives the call and is reused by a later
602
- :func:`run`, exactly as the CLI keeps one for the life of the process; it
603
- is reaped by an ``atexit`` handler. A writer the caller started themselves
604
- is left alone.
605
628
 
606
- Returns:
607
- ``True`` if this call started the writer, ``False`` if one was already
608
- running.
609
- """
610
- import execsql.state as _state
611
- import execsql.utils.fileio as _fileio
612
- from execsql.utils.fileio import FileWriter, filewriter_end
613
-
614
- if _fileio.filewriter is not None and _fileio.filewriter.is_alive():
615
- return False
616
-
617
- # Drain stale messages so a previously-dead subprocess cannot leak
618
- # responses or unconsumed commands into the new one. On macOS (`spawn`)
619
- # the OS pipe buffer is small enough that retained entries would deadlock
620
- # the next put().
621
- for q in (_fileio.fw_input, _fileio.fw_output):
622
- try:
623
- while True:
624
- q.get_nowait()
625
- except Exception:
626
- pass
629
+ def _capture_errors(ctx: RuntimeContext, errors: list[ScriptError], exit_code: Any) -> None:
630
+ """Record the error that halted the run, after ``exit_now()`` raised ``SystemExit``.
627
631
 
628
- try:
629
- _fileio.filewriter = _state.filewriter = FileWriter(
630
- _fileio.fw_input,
631
- _fileio.fw_output,
632
- file_encoding=conf.output_encoding,
633
- open_timeout=getattr(conf, "outfile_open_timeout", 10),
634
- )
635
- _state.filewriter.start()
636
- except Exception:
637
- # Under the `spawn` start method (macOS, Windows) the subprocess
638
- # re-imports the parent's __main__, which fails from a REPL, a
639
- # notebook, or `python -c`. Leave the writer unset: fileio's guards
640
- # then warn on the first dropped write rather than deadlocking.
641
- _fileio.filewriter = _state.filewriter = None
642
- return False
643
- atexit.register(filewriter_end)
644
- return True
645
-
646
-
647
- def _capture_errors(ctx: RuntimeContext, errors: list[ScriptError]) -> None:
648
- """Extract error info from the current context after a SystemExit."""
649
- last_error = None
650
- error_msg = None
651
- if ctx.subvars is not None:
652
- subs = dict(ctx.subvars.substitutions)
653
- last_error = subs.get("$LAST_ERROR")
654
- error_msg = subs.get("$ERROR_MESSAGE")
655
-
656
- msg = error_msg or last_error or "Script execution failed"
632
+ ``exit_now`` leaves the halting :class:`ErrInfo` on the context, and any
633
+ file output lost at the halt as a second error. A failed
634
+ SQL statement's text is in ``$LAST_ERROR``, which the executor sets just
635
+ before halting; it is reported only when the halt came from SQL, so a
636
+ metacommand error is never paired with an earlier statement. A ``HALT``
637
+ carries no error, only its exit status.
638
+ """
639
+ err = ctx.halt_error
640
+ if err is None:
641
+ msg = f"Script halted by HALT with exit status {exit_code}."
642
+ else:
643
+ msg = err.errmsg()
644
+ sql = None
645
+ lc = ctx.last_command
646
+ if err is not None and lc is not None and lc.command_type == "sql" and ctx.subvars is not None:
647
+ sql = ctx.subvars.varvalue("$LAST_ERROR") or None
657
648
  errors.append(
658
649
  ScriptError(
659
- message=str(msg),
650
+ message=msg,
660
651
  source=_last_source(ctx),
661
652
  line=_last_line(ctx),
662
- sql=str(last_error) if last_error else None,
653
+ sql=sql,
663
654
  ),
664
655
  )
656
+ if ctx.halt_output_error is not None:
657
+ errors.append(ScriptError(message=ctx.halt_output_error.errmsg(), source="<output>"))
658
+
659
+
660
+ def _failed(message: str, source: str) -> ScriptResult:
661
+ """A result for a run that failed before any of the script ran."""
662
+ return ScriptResult(
663
+ success=False,
664
+ commands_run=0,
665
+ elapsed=0.0,
666
+ errors=[ScriptError(message=message, source=source)],
667
+ variables={},
668
+ )
669
+
670
+
671
+ def _error_text(exc: BaseException) -> str:
672
+ """The message of *exc*: the full text for an :class:`ErrInfo`, else ``str()``."""
673
+ return exc.errmsg() if isinstance(exc, ErrInfo) else str(exc)
665
674
 
666
675
 
667
676
  def _last_source(ctx: RuntimeContext) -> str: