execsql2 2.15.11__py3-none-any.whl → 2.16.1__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 (62) hide show
  1. execsql/__init__.py +4 -0
  2. execsql/api.py +580 -0
  3. execsql/cli/__init__.py +106 -0
  4. execsql/cli/lint_ast.py +439 -0
  5. execsql/cli/run.py +431 -263
  6. execsql/config.py +10 -1
  7. execsql/db/access.py +1 -0
  8. execsql/db/dsn.py +3 -2
  9. execsql/db/duckdb.py +1 -1
  10. execsql/db/factory.py +3 -0
  11. execsql/db/firebird.py +2 -1
  12. execsql/db/mysql.py +2 -1
  13. execsql/db/oracle.py +2 -1
  14. execsql/db/postgres.py +2 -1
  15. execsql/db/sqlite.py +1 -1
  16. execsql/db/sqlserver.py +3 -2
  17. execsql/exporters/base.py +6 -4
  18. execsql/exporters/delimited.py +11 -3
  19. execsql/exporters/pretty.py +9 -12
  20. execsql/metacommands/__init__.py +3 -0
  21. execsql/metacommands/connect.py +1 -1
  22. execsql/metacommands/control.py +8 -14
  23. execsql/metacommands/debug.py +6 -4
  24. execsql/metacommands/io_export.py +117 -315
  25. execsql/metacommands/io_fileops.py +7 -13
  26. execsql/metacommands/io_write.py +1 -1
  27. execsql/metacommands/script_ext.py +8 -5
  28. execsql/metacommands/upsert.py +40 -0
  29. execsql/models.py +8 -12
  30. execsql/plugins.py +414 -0
  31. execsql/script/__init__.py +36 -12
  32. execsql/script/ast.py +562 -0
  33. execsql/script/engine.py +59 -368
  34. execsql/script/executor.py +926 -0
  35. execsql/script/parser.py +663 -0
  36. execsql/script/variables.py +11 -0
  37. execsql/state.py +118 -44
  38. execsql/utils/crypto.py +14 -10
  39. execsql/utils/errors.py +31 -8
  40. execsql/utils/mail.py +15 -12
  41. {execsql2-2.15.11.dist-info → execsql2-2.16.1.dist-info}/METADATA +93 -27
  42. execsql2-2.16.1.dist-info/RECORD +122 -0
  43. execsql2-2.15.11.dist-info/RECORD +0 -116
  44. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/README.md +0 -0
  45. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  46. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  47. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/execsql.conf +0 -0
  48. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  49. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/md_compare.sql +0 -0
  50. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
  51. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
  52. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
  53. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  54. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  55. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/script_template.sql +0 -0
  56. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
  57. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  58. {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  59. {execsql2-2.15.11.dist-info → execsql2-2.16.1.dist-info}/WHEEL +0 -0
  60. {execsql2-2.15.11.dist-info → execsql2-2.16.1.dist-info}/entry_points.txt +0 -0
  61. {execsql2-2.15.11.dist-info → execsql2-2.16.1.dist-info}/licenses/LICENSE.txt +0 -0
  62. {execsql2-2.15.11.dist-info → execsql2-2.16.1.dist-info}/licenses/NOTICE +0 -0
execsql/__init__.py CHANGED
@@ -15,3 +15,7 @@ try:
15
15
  __version__ = version("execsql2")
16
16
  except PackageNotFoundError:
17
17
  __version__ = "unknown"
18
+
19
+ from execsql.api import ExecSqlError, ScriptError, ScriptResult, run
20
+
21
+ __all__ = ["__version__", "run", "ScriptResult", "ScriptError", "ExecSqlError"]
execsql/api.py ADDED
@@ -0,0 +1,580 @@
1
+ """Public Python API for execsql.
2
+
3
+ Provides :func:`run` — the single entry point for executing SQL scripts
4
+ programmatically from Python code (notebooks, pipelines, applications).
5
+
6
+ Usage::
7
+
8
+ from execsql import run
9
+
10
+ # Execute a script file against SQLite
11
+ result = run(script="pipeline.sql", dsn="sqlite:///my.db")
12
+ print(result.success, result.commands_run)
13
+
14
+ # Execute inline SQL
15
+ result = run(sql="CREATE TABLE t (id INT); INSERT INTO t VALUES (1);",
16
+ dsn="sqlite:///my.db")
17
+
18
+ # With substitution variables
19
+ result = run(script="etl.sql",
20
+ dsn="postgresql://user:pass@host/db",
21
+ variables={"SCHEMA": "public", "DATE": "2026-01-01"})
22
+
23
+ # Error handling
24
+ result = run(sql="SELECT * FROM nonexistent;", dsn="sqlite:///my.db")
25
+ if not result.success:
26
+ for err in result.errors:
27
+ print(f"{err.source}:{err.line}: {err.message}")
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import dataclasses
33
+ import datetime
34
+ import io
35
+ import os
36
+ import time
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ from execsql.cli.dsn import _parse_connection_string
41
+ from execsql.config import ConfigData, StatObj, WriteHooks
42
+ from execsql.exceptions import ErrInfo
43
+ from execsql.state import RuntimeContext, active_context
44
+
45
+ __all__ = ["run", "ScriptResult", "ScriptError"]
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Result types
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ @dataclasses.dataclass(frozen=True)
54
+ class ScriptError:
55
+ """A single error encountered during script execution.
56
+
57
+ Attributes:
58
+ message: Human-readable error description.
59
+ source: Script file path or ``"<inline>"``.
60
+ line: Source line number, or ``None`` if unknown.
61
+ sql: The SQL statement that caused the error, if applicable.
62
+ """
63
+
64
+ message: str
65
+ source: str = "<unknown>"
66
+ line: int | None = None
67
+ sql: str | None = None
68
+
69
+
70
+ @dataclasses.dataclass(frozen=True)
71
+ class ScriptResult:
72
+ """Result of a script execution via :func:`run`.
73
+
74
+ Attributes:
75
+ success: ``True`` if execution completed without errors.
76
+ commands_run: Number of SQL statements and metacommands executed.
77
+ elapsed: Wall-clock execution time in seconds.
78
+ errors: List of errors encountered (empty on success).
79
+ variables: Final state of all user-defined substitution variables
80
+ (``$``-prefixed names, without the ``$``).
81
+ """
82
+
83
+ success: bool
84
+ commands_run: int
85
+ elapsed: float
86
+ errors: list[ScriptError]
87
+ variables: dict[str, str]
88
+
89
+ def raise_on_error(self) -> None:
90
+ """Raise :class:`ExecSqlError` if the script failed.
91
+
92
+ Convenience for callers who prefer exceptions over checking
93
+ :attr:`success`.
94
+ """
95
+ if not self.success:
96
+ msgs = "; ".join(e.message for e in self.errors[:3])
97
+ if len(self.errors) > 3:
98
+ msgs += f" (and {len(self.errors) - 3} more)"
99
+ raise ExecSqlError(msgs, result=self)
100
+
101
+
102
+ class ExecSqlError(Exception):
103
+ """Raised by :meth:`ScriptResult.raise_on_error` when a script fails."""
104
+
105
+ def __init__(self, message: str, result: ScriptResult) -> None:
106
+ super().__init__(message)
107
+ self.result = result
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Minimal config for library use (skips config file search)
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ class _LibraryConfig:
116
+ """Lightweight configuration for :func:`run` that skips INI file search.
117
+
118
+ Provides the same attribute interface as :class:`ConfigData` but only
119
+ sets the defaults needed for script execution. No filesystem I/O at
120
+ construction time.
121
+ """
122
+
123
+ def __init__(self, **overrides: Any) -> None:
124
+ # Connection (overridden by run())
125
+ self.db_type = "l"
126
+ self.server: str | None = None
127
+ self.port: int | None = None
128
+ self.db: str | None = None
129
+ self.db_file: str | None = None
130
+ self.username: str | None = None
131
+ self.passwd_prompt = False
132
+ self.use_keyring = False
133
+ self.new_db = False
134
+ self.access_username: str | None = None
135
+
136
+ # Encoding
137
+ self.script_encoding = "utf-8"
138
+ self.output_encoding = "utf-8"
139
+ self.import_encoding = "utf-8"
140
+ self.db_encoding: str | None = None
141
+ self.enc_err_disposition: str | None = None
142
+
143
+ # Runtime
144
+ self.user_logfile = False
145
+ self.gui_level = 0
146
+ self.gui_framework = "tkinter"
147
+ self.gui_wait_on_exit = False
148
+ self.gui_wait_on_error_halt = False
149
+ self.write_warnings = False
150
+ self.make_export_dirs = False
151
+ self.tee_write_log = False
152
+ self.log_sql = False
153
+ self.log_datavars = False
154
+ self.show_progress = False
155
+ self.max_log_size_mb = 0
156
+
157
+ # Data handling
158
+ self.boolean_int = True
159
+ self.boolean_words = False
160
+ self.empty_strings = True
161
+ self.only_strings = False
162
+ self.empty_rows = True
163
+ self.del_empty_cols = False
164
+ self.create_col_hdrs = False
165
+ self.trim_col_hdrs = "none"
166
+ self.clean_col_hdrs = False
167
+ self.fold_col_hdrs = "no"
168
+ self.dedup_col_hdrs = False
169
+ self.trim_strings = False
170
+ self.replace_newlines = False
171
+ self.scan_lines = 100
172
+ self.import_buffer = 32 * 1024
173
+ self.import_common_cols_only = False
174
+ self.import_row_buffer = 1000
175
+ self.import_progress_interval = 0
176
+ self.export_row_buffer = 1000
177
+ self.max_int = 2147483647
178
+ self.quote_all_text = False
179
+ self.hdf5_text_len = 1000
180
+ self.outfile_open_timeout = 600
181
+ self.zip_buffer_mb = 10
182
+ self.dao_flush_delay_secs = 5.0
183
+ self.access_use_numeric = False
184
+
185
+ # Output
186
+ self.write_prefix: str | None = None
187
+ self.write_suffix: str | None = None
188
+ self.css_file: str | None = None
189
+ self.css_styles: str | None = None
190
+ self.template_processor: str | None = None
191
+ self.gui_console_height = 25
192
+ self.gui_console_width = 100
193
+
194
+ # Email (needed by ON ERROR_HALT EMAIL handlers)
195
+ self.smtp_host: str | None = None
196
+ self.smtp_port: int | None = None
197
+ self.smtp_username: str | None = None
198
+ self.smtp_password: str | None = None
199
+ self.smtp_ssl = False
200
+ self.smtp_tls = False
201
+ self.email_format = "plain"
202
+ self.email_css: str | None = None
203
+
204
+ # Includes
205
+ self.include_req: list = []
206
+ self.include_opt: list = []
207
+
208
+ # Config file tracking (for compatibility)
209
+ self.files_read: list = []
210
+
211
+ # Apply overrides
212
+ for k, v in overrides.items():
213
+ setattr(self, k, v)
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Database connection from DSN
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ def _connect_from_dsn(dsn: str, new_db: bool = False) -> Any:
222
+ """Create a database connection from a DSN URL string.
223
+
224
+ Returns a :class:`~execsql.db.base.Database` subclass instance.
225
+ """
226
+ from execsql.db.factory import (
227
+ db_Access,
228
+ db_Dsn,
229
+ db_DuckDB,
230
+ db_Firebird,
231
+ db_MySQL,
232
+ db_Oracle,
233
+ db_Postgres,
234
+ db_SQLite,
235
+ db_SqlServer,
236
+ )
237
+
238
+ params = _parse_connection_string(dsn)
239
+ db_type = params["db_type"]
240
+ server = params["server"]
241
+ db_name = params["db"]
242
+ db_file = params["db_file"]
243
+ user = params["user"]
244
+ password = params["password"]
245
+ port = params["port"]
246
+
247
+ if db_type == "l":
248
+ file = db_file or ":memory:"
249
+ # In-memory databases are always "new"
250
+ return db_SQLite(file, new_db=new_db or file == ":memory:")
251
+ elif db_type == "k":
252
+ file = db_file or ":memory:"
253
+ return db_DuckDB(file, new_db=new_db or file == ":memory:")
254
+ elif db_type == "p":
255
+ return db_Postgres(
256
+ server or "localhost",
257
+ db_name,
258
+ user=user,
259
+ password=password,
260
+ port=port or 5432,
261
+ new_db=new_db,
262
+ )
263
+ elif db_type == "m":
264
+ return db_MySQL(
265
+ server or "localhost",
266
+ db_name,
267
+ user=user,
268
+ password=password,
269
+ port=port or 3306,
270
+ )
271
+ elif db_type == "s":
272
+ return db_SqlServer(
273
+ server or "localhost",
274
+ db_name,
275
+ user=user,
276
+ password=password,
277
+ port=port,
278
+ )
279
+ elif db_type == "o":
280
+ return db_Oracle(
281
+ server or "localhost",
282
+ db_name,
283
+ user=user,
284
+ password=password,
285
+ port=port or 1521,
286
+ )
287
+ elif db_type == "f":
288
+ return db_Firebird(
289
+ server or "localhost",
290
+ db_name or db_file,
291
+ user=user,
292
+ password=password,
293
+ port=port or 3050,
294
+ )
295
+ elif db_type == "a":
296
+ return db_Access(db_file)
297
+ elif db_type == "d":
298
+ return db_Dsn(db_name, user=user, password=password)
299
+ else:
300
+ raise ValueError(f"Unsupported database type: {db_type!r}")
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Public API
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ def run(
309
+ script: str | Path | None = None,
310
+ *,
311
+ sql: str | None = None,
312
+ dsn: str | None = None,
313
+ connection: Any = None,
314
+ variables: dict[str, str] | None = None,
315
+ config_file: str | Path | None = None,
316
+ encoding: str = "utf-8",
317
+ halt_on_error: bool = True,
318
+ new_db: bool = False,
319
+ ) -> ScriptResult:
320
+ """Execute a SQL script and return the result.
321
+
322
+ Exactly one of *script* or *sql* must be provided. Exactly one of
323
+ *dsn* or *connection* must be provided.
324
+
325
+ Args:
326
+ script: Path to a ``.sql`` script file.
327
+ sql: Inline SQL/metacommand string to execute.
328
+ dsn: Database connection URL (e.g. ``"sqlite:///my.db"``,
329
+ ``"postgresql://user:pass@host/db"``).
330
+ connection: A pre-existing :class:`~execsql.db.base.Database`
331
+ instance. ``run()`` will NOT close this connection on exit.
332
+ variables: Substitution variables as ``{"NAME": "value"}``.
333
+ Keys without a ``$`` prefix get one added automatically.
334
+ config_file: Optional execsql configuration file to load.
335
+ encoding: Script file encoding (default ``"utf-8"``).
336
+ halt_on_error: If ``True`` (default), stop on the first SQL
337
+ error. If ``False``, capture errors and continue.
338
+ new_db: If ``True``, create the database if it does not exist
339
+ (SQLite, PostgreSQL, DuckDB).
340
+
341
+ Returns:
342
+ A :class:`ScriptResult` with execution outcome, timing, errors,
343
+ and final variable state.
344
+
345
+ Raises:
346
+ ValueError: If the argument combination is invalid (e.g. both
347
+ *script* and *sql* provided, or neither *dsn* nor *connection*).
348
+ ExecSqlError: Only if the caller explicitly calls
349
+ :meth:`ScriptResult.raise_on_error`.
350
+ """
351
+ # ------------------------------------------------------------------
352
+ # Validate arguments
353
+ # ------------------------------------------------------------------
354
+ if script is not None and sql is not None:
355
+ raise ValueError("Provide either 'script' or 'sql', not both.")
356
+ if script is None and sql is None:
357
+ raise ValueError("Either 'script' or 'sql' must be provided.")
358
+ if dsn is not None and connection is not None:
359
+ raise ValueError("Provide either 'dsn' or 'connection', not both.")
360
+ if dsn is None and connection is None:
361
+ raise ValueError("Either 'dsn' or 'connection' must be provided.")
362
+
363
+ # ------------------------------------------------------------------
364
+ # Parse the script into an AST
365
+ # ------------------------------------------------------------------
366
+ from execsql.script.parser import parse_script, parse_string
367
+
368
+ try:
369
+ if script is not None:
370
+ tree = parse_script(str(script), encoding=encoding)
371
+ else:
372
+ tree = parse_string(sql, source_name="<inline>")
373
+ except ErrInfo as exc:
374
+ return ScriptResult(
375
+ success=False,
376
+ commands_run=0,
377
+ elapsed=0.0,
378
+ errors=[ScriptError(message=exc.errmsg(), source=str(script) if script else "<inline>")],
379
+ variables={},
380
+ )
381
+
382
+ # ------------------------------------------------------------------
383
+ # Build an isolated RuntimeContext
384
+ # ------------------------------------------------------------------
385
+ ctx = RuntimeContext()
386
+
387
+ # Configuration
388
+ conf_overrides: dict[str, Any] = {"script_encoding": encoding}
389
+ if config_file is not None:
390
+ # Load a real ConfigData with the explicit config file
391
+ from execsql.script.variables import SubVarSet
392
+
393
+ temp_subvars = SubVarSet()
394
+ script_dir = str(Path(script).resolve().parent) if script else os.getcwd()
395
+ conf = ConfigData(script_dir, temp_subvars, config_file=str(config_file))
396
+ # Apply any overrides
397
+ for k, v in conf_overrides.items():
398
+ setattr(conf, k, v)
399
+ else:
400
+ conf = _LibraryConfig(**conf_overrides)
401
+
402
+ # Substitution variables
403
+ from execsql.script.variables import SubVarSet
404
+
405
+ subvars = SubVarSet()
406
+
407
+ # Seed essential built-in variables
408
+ dt_now = datetime.datetime.now()
409
+ subvars.add_substitution("$SCRIPT_START_TIME", dt_now.strftime("%Y-%m-%d %H:%M"))
410
+ subvars.add_substitution("$DATE_TAG", dt_now.strftime("%Y%m%d"))
411
+ subvars.add_substitution("$DATETIME_TAG", dt_now.strftime("%Y%m%d_%H%M"))
412
+ subvars.add_substitution("$LAST_SQL", "")
413
+ subvars.add_substitution("$LAST_ERROR", "")
414
+ subvars.add_substitution("$ERROR_MESSAGE", "")
415
+ subvars.add_substitution("$LAST_ROWCOUNT", None)
416
+ subvars.add_substitution("$PATHSEP", os.sep)
417
+ subvars.add_substitution("$STARTING_PATH", os.getcwd() + os.sep)
418
+ import platform
419
+
420
+ subvars.add_substitution("$HOSTNAME", platform.node())
421
+
422
+ # User-supplied variables
423
+ if variables:
424
+ for name, value in variables.items():
425
+ key = name if name.startswith("$") else f"${name}"
426
+ subvars.add_substitution(key, str(value))
427
+
428
+ # ------------------------------------------------------------------
429
+ # Initialize state
430
+ # ------------------------------------------------------------------
431
+ from execsql.metacommands import DISPATCH_TABLE
432
+ from execsql.metacommands.conditions import CONDITIONAL_TABLE
433
+
434
+ ctx.subvars = subvars
435
+ ctx.status = StatObj()
436
+ ctx.status.halt_on_err = halt_on_error
437
+ ctx.conf = conf
438
+
439
+ # Capture output to a buffer (suppress stdout/stderr)
440
+ stdout_buf = io.StringIO()
441
+ stderr_buf = io.StringIO()
442
+ ctx.output = WriteHooks(stdout_buf.write, stderr_buf.write)
443
+
444
+ # No log file for library use
445
+ ctx.exec_log = _NoOpLogger()
446
+
447
+ with active_context(ctx):
448
+ # Initialize singletons (IfLevels, CounterVars, Timer, DatabasePool, etc.)
449
+ from execsql.state import initialize
450
+
451
+ initialize(conf, DISPATCH_TABLE, CONDITIONAL_TABLE)
452
+
453
+ # ------------------------------------------------------------------
454
+ # Connect to database
455
+ # ------------------------------------------------------------------
456
+ owns_connection = connection is None
457
+ if dsn is not None:
458
+ db = _connect_from_dsn(dsn, new_db=new_db)
459
+ else:
460
+ db = connection
461
+
462
+ ctx.dbs.add("initial", db)
463
+ ctx.subvars.add_substitution("$CURRENT_DBMS", db.type.dbms_id)
464
+ ctx.subvars.add_substitution("$CURRENT_DATABASE", db.name())
465
+ ctx.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", "0")
466
+
467
+ # ------------------------------------------------------------------
468
+ # Execute
469
+ # ------------------------------------------------------------------
470
+ from execsql.script.executor import execute
471
+
472
+ errors: list[ScriptError] = []
473
+ t0 = time.perf_counter()
474
+
475
+ try:
476
+ execute(tree, ctx=ctx)
477
+ except SystemExit:
478
+ # exit_now() calls sys.exit() — catch and convert to error
479
+ _capture_errors(ctx, errors)
480
+ except ErrInfo as exc:
481
+ errors.append(
482
+ ScriptError(
483
+ message=exc.errmsg(),
484
+ source=_last_source(ctx),
485
+ line=_last_line(ctx),
486
+ sql=getattr(exc, "command_text", None),
487
+ ),
488
+ )
489
+ except Exception as exc:
490
+ errors.append(ScriptError(message=str(exc), source="<runtime>"))
491
+
492
+ elapsed = time.perf_counter() - t0
493
+
494
+ # ------------------------------------------------------------------
495
+ # Collect results
496
+ # ------------------------------------------------------------------
497
+ final_vars = {}
498
+ if ctx.subvars is not None:
499
+ for name, value in ctx.subvars.substitutions:
500
+ # Include user vars and $-prefixed system vars
501
+ # Skip environment (&), column (@), local (~), parameter (#) vars
502
+ if not name or name[0] in ("&", "@", "~", "#"):
503
+ continue
504
+ key = name.lstrip("$")
505
+ final_vars[key] = str(value) if value is not None else ""
506
+
507
+ # Close connection if we own it
508
+ if owns_connection:
509
+ try:
510
+ ctx.dbs.closeall()
511
+ except Exception:
512
+ pass
513
+
514
+ return ScriptResult(
515
+ success=len(errors) == 0,
516
+ commands_run=ctx.cmds_run,
517
+ elapsed=elapsed,
518
+ errors=errors,
519
+ variables=final_vars,
520
+ )
521
+
522
+
523
+ # ---------------------------------------------------------------------------
524
+ # Helpers
525
+ # ---------------------------------------------------------------------------
526
+
527
+
528
+ def _capture_errors(ctx: RuntimeContext, errors: list[ScriptError]) -> None:
529
+ """Extract error info from the current context after a SystemExit."""
530
+ last_error = None
531
+ error_msg = None
532
+ if ctx.subvars is not None:
533
+ subs = dict(ctx.subvars.substitutions)
534
+ last_error = subs.get("$LAST_ERROR")
535
+ error_msg = subs.get("$ERROR_MESSAGE")
536
+
537
+ msg = error_msg or last_error or "Script execution failed"
538
+ errors.append(
539
+ ScriptError(
540
+ message=str(msg),
541
+ source=_last_source(ctx),
542
+ line=_last_line(ctx),
543
+ sql=str(last_error) if last_error else None,
544
+ ),
545
+ )
546
+
547
+
548
+ def _last_source(ctx: RuntimeContext) -> str:
549
+ """Get the source file from the last executed command."""
550
+ lc = ctx.last_command
551
+ if lc is not None and hasattr(lc, "source"):
552
+ return lc.source
553
+ return "<unknown>"
554
+
555
+
556
+ def _last_line(ctx: RuntimeContext) -> int | None:
557
+ """Get the line number from the last executed command."""
558
+ lc = ctx.last_command
559
+ if lc is not None and hasattr(lc, "line_no"):
560
+ return lc.line_no
561
+ return None
562
+
563
+
564
+ class _NoOpLogger:
565
+ """Minimal logger that silently discards all messages.
566
+
567
+ Satisfies the full ``exec_log`` (Logger) interface without writing
568
+ to disk. All methods are no-ops.
569
+ """
570
+
571
+ run_id: str = "library"
572
+
573
+ def __getattr__(self, name: str) -> Any:
574
+ """Return a no-op callable for any unimplemented log method."""
575
+ if name.startswith("log_"):
576
+ return lambda *args, **kwargs: None
577
+ raise AttributeError(f"_NoOpLogger has no attribute {name!r}")
578
+
579
+ def close(self) -> None:
580
+ pass