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.
- execsql/__init__.py +4 -0
- execsql/api.py +580 -0
- execsql/cli/__init__.py +106 -0
- execsql/cli/lint_ast.py +439 -0
- execsql/cli/run.py +431 -263
- execsql/config.py +10 -1
- execsql/db/access.py +1 -0
- execsql/db/dsn.py +3 -2
- execsql/db/duckdb.py +1 -1
- execsql/db/factory.py +3 -0
- execsql/db/firebird.py +2 -1
- execsql/db/mysql.py +2 -1
- execsql/db/oracle.py +2 -1
- execsql/db/postgres.py +2 -1
- execsql/db/sqlite.py +1 -1
- execsql/db/sqlserver.py +3 -2
- execsql/exporters/base.py +6 -4
- execsql/exporters/delimited.py +11 -3
- execsql/exporters/pretty.py +9 -12
- execsql/metacommands/__init__.py +3 -0
- execsql/metacommands/connect.py +1 -1
- execsql/metacommands/control.py +8 -14
- execsql/metacommands/debug.py +6 -4
- execsql/metacommands/io_export.py +117 -315
- execsql/metacommands/io_fileops.py +7 -13
- execsql/metacommands/io_write.py +1 -1
- execsql/metacommands/script_ext.py +8 -5
- execsql/metacommands/upsert.py +40 -0
- execsql/models.py +8 -12
- execsql/plugins.py +414 -0
- execsql/script/__init__.py +36 -12
- execsql/script/ast.py +562 -0
- execsql/script/engine.py +59 -368
- execsql/script/executor.py +926 -0
- execsql/script/parser.py +663 -0
- execsql/script/variables.py +11 -0
- execsql/state.py +118 -44
- execsql/utils/crypto.py +14 -10
- execsql/utils/errors.py +31 -8
- execsql/utils/mail.py +15 -12
- {execsql2-2.15.11.dist-info → execsql2-2.16.1.dist-info}/METADATA +93 -27
- execsql2-2.16.1.dist-info/RECORD +122 -0
- execsql2-2.15.11.dist-info/RECORD +0 -116
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.15.11.data → execsql2-2.16.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.15.11.dist-info → execsql2-2.16.1.dist-info}/WHEEL +0 -0
- {execsql2-2.15.11.dist-info → execsql2-2.16.1.dist-info}/entry_points.txt +0 -0
- {execsql2-2.15.11.dist-info → execsql2-2.16.1.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.15.11.dist-info → execsql2-2.16.1.dist-info}/licenses/NOTICE +0 -0
execsql/__init__.py
CHANGED
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
|