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.
- execsql/api.py +169 -160
- execsql/cli/__init__.py +19 -586
- execsql/cli/application.py +309 -0
- execsql/cli/commands/__init__.py +13 -0
- execsql/cli/commands/config.py +205 -0
- execsql/cli/commands/format.py +100 -0
- execsql/cli/commands/init.py +135 -0
- execsql/cli/commands/lint.py +225 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +459 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/dsn.py +11 -4
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +780 -100
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +96 -32
- execsql/config.py +256 -173
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +38 -5
- execsql/data/script_header.sql +23 -0
- execsql/db/access.py +29 -8
- execsql/db/base.py +40 -8
- execsql/db/dsn.py +13 -4
- execsql/db/duckdb.py +3 -1
- execsql/db/factory.py +13 -4
- execsql/db/firebird.py +21 -7
- execsql/db/mysql.py +37 -16
- execsql/db/oracle.py +49 -16
- execsql/db/postgres.py +30 -6
- execsql/db/sqlite.py +7 -9
- execsql/db/sqlserver.py +25 -8
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +115 -76
- execsql/gui/desktop.py +1 -3
- execsql/gui/tui.py +2 -2
- execsql/importers/base.py +35 -1
- execsql/importers/csv.py +3 -1
- execsql/interactive.py +232 -0
- execsql/manifest.py +200 -0
- execsql/metacommands/conditions.py +7 -8
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/effects.py +124 -0
- execsql/metacommands/io_fileops.py +62 -37
- execsql/metacommands/io_import.py +0 -1
- execsql/metacommands/prompt.py +2 -0
- execsql/metacommands/upsert.py +5 -2
- execsql/models.py +3 -2
- execsql/parser.py +14 -5
- execsql/plugins.py +24 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +46 -104
- execsql/script/executor.py +162 -39
- execsql/script/parser.py +167 -118
- execsql/state.py +34 -11
- execsql/state.pyi +11 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/color.py +18 -0
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +82 -10
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
- execsql2-2.25.0.dist-info/RECORD +140 -0
- execsql2-2.25.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.1.dist-info/RECORD +0 -124
- execsql2-2.23.1.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
execsql/manifest.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""The run manifest: a JSON record of one ``execsql run``, for ``--manifest FILE``.
|
|
2
|
+
|
|
3
|
+
The manifest says what a run did — the script, the database, how many
|
|
4
|
+
statements ran, the files it read, wrote and deleted, the connections it
|
|
5
|
+
opened, and how it ended — in a form an orchestrator or an audit can read
|
|
6
|
+
without parsing the log. It is written when the run ends, whether it
|
|
7
|
+
succeeded, failed or was cancelled.
|
|
8
|
+
|
|
9
|
+
Files are recorded as the executor runs each metacommand, after variable
|
|
10
|
+
substitution, so paths are the ones actually used. What a metacommand touches
|
|
11
|
+
is decided by :mod:`execsql.metacommands.effects`. Variable values and passwords are never written:
|
|
12
|
+
the run log hides ``-a`` and ``--var`` values because they can be secrets, and
|
|
13
|
+
the manifest lists only their names.
|
|
14
|
+
|
|
15
|
+
One recorder is active per run; :func:`current` returns it, or ``None`` when
|
|
16
|
+
no manifest was asked for, so the executor's hooks cost one check. Writing the
|
|
17
|
+
manifest ends the recording, so a later run in the same process starts clean.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import atexit
|
|
23
|
+
import datetime
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from execsql import __version__
|
|
32
|
+
|
|
33
|
+
__all__ = ["RunManifest", "check_writable", "current", "start"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def current() -> RunManifest | None:
|
|
37
|
+
"""The active run's manifest recorder, or ``None`` when ``--manifest`` was not given.
|
|
38
|
+
|
|
39
|
+
Held on the run's :class:`~execsql.state.RuntimeContext`, so a library
|
|
40
|
+
``execsql.run()`` in the same process never records into a CLI manifest.
|
|
41
|
+
"""
|
|
42
|
+
import execsql.state as _state
|
|
43
|
+
|
|
44
|
+
return _state.manifest
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_writable(path: str) -> str | None:
|
|
48
|
+
"""Why a manifest cannot be written to *path*, or ``None`` if it can.
|
|
49
|
+
|
|
50
|
+
Checked before a run starts, so a bad ``--manifest`` path fails before any
|
|
51
|
+
SQL runs rather than after.
|
|
52
|
+
"""
|
|
53
|
+
target = Path(path)
|
|
54
|
+
if target.is_dir():
|
|
55
|
+
return f"{path} is a directory"
|
|
56
|
+
try:
|
|
57
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
fd, probe = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp")
|
|
59
|
+
os.close(fd)
|
|
60
|
+
os.unlink(probe)
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
return exc.strerror or str(exc)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def start(path: str, script: str | None, variables: list[str]) -> RunManifest:
|
|
67
|
+
"""Begin recording a run; the manifest is written to *path* when it ends.
|
|
68
|
+
|
|
69
|
+
If nothing else writes it (``exit_now`` on an error, the end of a run), an
|
|
70
|
+
``atexit`` fallback does.
|
|
71
|
+
"""
|
|
72
|
+
import execsql.state as _state
|
|
73
|
+
|
|
74
|
+
manifest = RunManifest(path, script, variables)
|
|
75
|
+
_state.manifest = manifest
|
|
76
|
+
atexit.register(manifest.finish, None)
|
|
77
|
+
return manifest
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _now() -> datetime.datetime:
|
|
81
|
+
return datetime.datetime.now(tz=datetime.timezone.utc)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RunManifest:
|
|
85
|
+
"""Collects what one run does and writes it as JSON when the run ends."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, path: str, script: str | None, variables: list[str]) -> None:
|
|
88
|
+
self.path = Path(path)
|
|
89
|
+
self.script = script or "<inline>"
|
|
90
|
+
self.variables = variables
|
|
91
|
+
self.started = _now()
|
|
92
|
+
self.config_files: list[str] = []
|
|
93
|
+
self.database: dict[str, Any] | None = None
|
|
94
|
+
self.sql_statements = 0
|
|
95
|
+
self.metacommands = 0
|
|
96
|
+
self.reads: list[dict[str, Any]] = []
|
|
97
|
+
self.writes: list[dict[str, Any]] = []
|
|
98
|
+
self.deletes: list[dict[str, Any]] = []
|
|
99
|
+
self.connections: list[dict[str, Any]] = []
|
|
100
|
+
self.errors: list[dict[str, Any]] = []
|
|
101
|
+
self.written = False
|
|
102
|
+
|
|
103
|
+
# -- recording -------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def set_database(self, db: Any) -> None:
|
|
106
|
+
"""Record the initial connection: DBMS, server, database or file, user — no password."""
|
|
107
|
+
self.database = {
|
|
108
|
+
"dbms": db.type.dbms_id if getattr(db, "type", None) else None,
|
|
109
|
+
"server": getattr(db, "server_name", None),
|
|
110
|
+
"database": getattr(db, "db_name", None),
|
|
111
|
+
"user": getattr(db, "user", None),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
def record_sql(self) -> None:
|
|
115
|
+
self.sql_statements += 1
|
|
116
|
+
|
|
117
|
+
def record_metacommand(self, command: str, source: str, line: int, metacommandlist: Any) -> None:
|
|
118
|
+
"""Count a metacommand that ran, and record the files and connections it touched.
|
|
119
|
+
|
|
120
|
+
*command* is the text after substitution, so paths are the ones used.
|
|
121
|
+
"""
|
|
122
|
+
from execsql.metacommands.effects import effects
|
|
123
|
+
|
|
124
|
+
self.metacommands += 1
|
|
125
|
+
hit = metacommandlist.get_match(command)
|
|
126
|
+
if hit is None:
|
|
127
|
+
return
|
|
128
|
+
mc, m = hit
|
|
129
|
+
by = mc.description or command.split(None, 1)[0].upper()
|
|
130
|
+
for effect in effects(mc.exec_fn.__name__, m.groupdict()):
|
|
131
|
+
entry = {"source": source, "line": line, "by": by}
|
|
132
|
+
if effect.role in ("connect", "use"):
|
|
133
|
+
self.connections.append(
|
|
134
|
+
{**entry, "by": effect.role.upper(), "target": effect.target, "detail": effect.detail},
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
{"read": self.reads, "write": self.writes, "delete": self.deletes}[effect.role].append(
|
|
138
|
+
{**entry, "path": effect.target},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def record_include(self, path: str, source: str, line: int) -> None:
|
|
142
|
+
self.reads.append({"source": source, "line": line, "by": "INCLUDE", "path": path})
|
|
143
|
+
|
|
144
|
+
# -- finishing -------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def add_error(self, error: Any) -> None:
|
|
147
|
+
"""Record *error* (an :class:`ErrInfo`) in the manifest's ``errors``."""
|
|
148
|
+
# str(ErrInfo) is its concise message; errmsg() is the multi-line
|
|
149
|
+
# console block with a timestamp, which a reader of JSON does not want.
|
|
150
|
+
self.errors.append(
|
|
151
|
+
{
|
|
152
|
+
"message": " ".join(str(error).split()),
|
|
153
|
+
"type": getattr(error, "type", None),
|
|
154
|
+
"source": getattr(error, "script_file", None),
|
|
155
|
+
"line": getattr(error, "script_line_no", None),
|
|
156
|
+
"command": getattr(error, "cmd", None) or getattr(error, "command", None),
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def finish(self, exit_status: int | None, error: Any = None) -> None:
|
|
161
|
+
"""Write the manifest once. Later calls (the atexit fallback) do nothing."""
|
|
162
|
+
if self.written:
|
|
163
|
+
return
|
|
164
|
+
if error is not None:
|
|
165
|
+
self.add_error(error)
|
|
166
|
+
finished = _now()
|
|
167
|
+
data = {
|
|
168
|
+
"execsql_version": __version__,
|
|
169
|
+
"script": self.script,
|
|
170
|
+
"started": self.started.isoformat(timespec="seconds"),
|
|
171
|
+
"finished": finished.isoformat(timespec="seconds"),
|
|
172
|
+
"duration_s": round((finished - self.started).total_seconds(), 3),
|
|
173
|
+
"exit_status": exit_status,
|
|
174
|
+
"config_files": self.config_files,
|
|
175
|
+
"database": self.database,
|
|
176
|
+
"variables": self.variables,
|
|
177
|
+
"statements": {"sql": self.sql_statements, "metacommands": self.metacommands},
|
|
178
|
+
"files_read": self.reads,
|
|
179
|
+
"files_written": self.writes,
|
|
180
|
+
"files_deleted": self.deletes,
|
|
181
|
+
"connections": self.connections,
|
|
182
|
+
"errors": self.errors,
|
|
183
|
+
}
|
|
184
|
+
self.written = True
|
|
185
|
+
import execsql.state as _state
|
|
186
|
+
|
|
187
|
+
if _state.manifest is self:
|
|
188
|
+
_state.manifest = None
|
|
189
|
+
atexit.unregister(self.finish)
|
|
190
|
+
try:
|
|
191
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
# Write beside the target and rename, so a reader never sees half a file.
|
|
193
|
+
fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=f".{self.path.name}.", suffix=".tmp")
|
|
194
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
195
|
+
json.dump(data, f, indent=2, default=str)
|
|
196
|
+
f.write("\n")
|
|
197
|
+
os.replace(tmp, self.path)
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
# The run itself is over; report the lost manifest without a traceback.
|
|
200
|
+
sys.stderr.write(f"execsql: could not write --manifest {self.path}: {exc.strerror or exc}\n")
|
|
@@ -22,7 +22,7 @@ registration time for quoted, unquoted, and bracketed arguments):
|
|
|
22
22
|
``xf_row_count_eq``, ``xf_row_count_lt``.
|
|
23
23
|
- Runtime state: ``xf_sqlerror``, ``xf_dialogcanceled``, ``xf_dbms``.
|
|
24
24
|
|
|
25
|
-
The full list is also surfaced by ``execsql
|
|
25
|
+
The full list is also surfaced by ``execsql list keywords``.
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
import os
|
|
@@ -100,12 +100,11 @@ def xf_hasrows(**kwargs: Any) -> bool:
|
|
|
100
100
|
return bool(nrows > 0)
|
|
101
101
|
|
|
102
102
|
|
|
103
|
-
def _row_count(queryname: str,
|
|
103
|
+
def _row_count(queryname: str, metacommandline: str) -> int:
|
|
104
104
|
"""Return the number of rows in *queryname*, raising ErrInfo on failure.
|
|
105
105
|
|
|
106
106
|
Args:
|
|
107
107
|
queryname: Table or view name to count rows in.
|
|
108
|
-
sql_context: The SQL string to include in error messages.
|
|
109
108
|
metacommandline: The full metacommand line for error context.
|
|
110
109
|
|
|
111
110
|
Returns:
|
|
@@ -167,7 +166,7 @@ def xf_row_count_gt(**kwargs: Any) -> bool:
|
|
|
167
166
|
queryname = kwargs["queryname"]
|
|
168
167
|
mcl = kwargs["metacommandline"]
|
|
169
168
|
n = _parse_row_count_n(kwargs["n"], mcl)
|
|
170
|
-
return _row_count(queryname,
|
|
169
|
+
return _row_count(queryname, mcl) > n
|
|
171
170
|
|
|
172
171
|
|
|
173
172
|
def xf_row_count_gte(**kwargs: Any) -> bool:
|
|
@@ -183,7 +182,7 @@ def xf_row_count_gte(**kwargs: Any) -> bool:
|
|
|
183
182
|
queryname = kwargs["queryname"]
|
|
184
183
|
mcl = kwargs["metacommandline"]
|
|
185
184
|
n = _parse_row_count_n(kwargs["n"], mcl)
|
|
186
|
-
return _row_count(queryname,
|
|
185
|
+
return _row_count(queryname, mcl) >= n
|
|
187
186
|
|
|
188
187
|
|
|
189
188
|
def xf_row_count_eq(**kwargs: Any) -> bool:
|
|
@@ -199,7 +198,7 @@ def xf_row_count_eq(**kwargs: Any) -> bool:
|
|
|
199
198
|
queryname = kwargs["queryname"]
|
|
200
199
|
mcl = kwargs["metacommandline"]
|
|
201
200
|
n = _parse_row_count_n(kwargs["n"], mcl)
|
|
202
|
-
return _row_count(queryname,
|
|
201
|
+
return _row_count(queryname, mcl) == n
|
|
203
202
|
|
|
204
203
|
|
|
205
204
|
def xf_row_count_lt(**kwargs: Any) -> bool:
|
|
@@ -215,7 +214,7 @@ def xf_row_count_lt(**kwargs: Any) -> bool:
|
|
|
215
214
|
queryname = kwargs["queryname"]
|
|
216
215
|
mcl = kwargs["metacommandline"]
|
|
217
216
|
n = _parse_row_count_n(kwargs["n"], mcl)
|
|
218
|
-
return _row_count(queryname,
|
|
217
|
+
return _row_count(queryname, mcl) < n
|
|
219
218
|
|
|
220
219
|
|
|
221
220
|
def xf_sqlerror(**kwargs: Any) -> bool:
|
|
@@ -428,7 +427,7 @@ def xf_newer_date(**kwargs: Any) -> bool:
|
|
|
428
427
|
datestr = unquoted(kwargs["datestr"])
|
|
429
428
|
if not Path(file1).exists():
|
|
430
429
|
raise ErrInfo(type="cmd", other_msg=f"File {file1} does not exist.")
|
|
431
|
-
dt_value = parse_datetime(datestr)
|
|
430
|
+
dt_value = parse_datetime(datestr, strict=False)
|
|
432
431
|
if not dt_value:
|
|
433
432
|
raise ErrInfo(type="cmd", other_msg=f"{datestr} can't be interpreted as a date/time.")
|
|
434
433
|
return os.stat(file1).st_mtime > time.mktime(dt_value.timetuple())
|
execsql/metacommands/control.py
CHANGED
|
@@ -77,9 +77,8 @@ def x_assert(**kwargs: Any) -> None:
|
|
|
77
77
|
def _ast_only_stub(name: str):
|
|
78
78
|
"""Return an ErrInfo for a metacommand the AST executor handles structurally.
|
|
79
79
|
|
|
80
|
-
These handlers stay registered in the dispatch table so
|
|
81
|
-
the VS Code grammar generator
|
|
82
|
-
keyword. The AST parser converts the source form (``IF`` / ``ENDIF`` /
|
|
80
|
+
These handlers stay registered in the dispatch table so ``execsql list
|
|
81
|
+
keywords`` and the VS Code grammar generator still see the keyword. The AST parser converts the source form (``IF`` / ``ENDIF`` /
|
|
83
82
|
``LOOP`` / ``BEGIN BATCH`` / ``BREAK`` / ``ELSE`` / ``ELSEIF`` / ``ANDIF``
|
|
84
83
|
/ ``ORIF``) into structural AST nodes that the executor walks directly —
|
|
85
84
|
none of these dispatch handlers fire for parsed scripts. Reaching one
|
execsql/metacommands/data.py
CHANGED
|
@@ -36,6 +36,7 @@ from execsql.parser import NumericParser
|
|
|
36
36
|
from execsql.script import current_script_line
|
|
37
37
|
from execsql.utils.crypto import Encrypt
|
|
38
38
|
from execsql.utils.errors import exception_desc, exit_now
|
|
39
|
+
from execsql.utils.fileio import register_secret
|
|
39
40
|
from execsql.utils.gui import GUI_SELECTSUB, GuiSpec, enable_gui
|
|
40
41
|
from execsql.utils.strings import get_subvarset, unquoted
|
|
41
42
|
|
|
@@ -126,7 +127,9 @@ def x_sub_encrypt(**kwargs: Any) -> None:
|
|
|
126
127
|
def x_sub_decrypt(**kwargs: Any) -> None:
|
|
127
128
|
varname = kwargs["match"]
|
|
128
129
|
subvarset, varname = get_subvarset(varname, kwargs["metacommandline"])
|
|
129
|
-
|
|
130
|
+
plaintext = Encrypt().decrypt(kwargs["crypttext"])
|
|
131
|
+
register_secret(plaintext)
|
|
132
|
+
subvarset.add_substitution(varname, plaintext)
|
|
130
133
|
return None
|
|
131
134
|
|
|
132
135
|
|
execsql/metacommands/debug.py
CHANGED
|
@@ -52,10 +52,8 @@ def x_debug_commandliststack(**kwargs: Any) -> None:
|
|
|
52
52
|
``<main>`` script, ``EXECUTE SCRIPT`` calls, ``INCLUDE``'d files,
|
|
53
53
|
``IF``/``ELSEIF``/``ELSE`` branches, ``LOOP`` iterations (with iteration
|
|
54
54
|
count), and ``BATCH`` blocks. Each frame shows source file and line.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
because it only records SCRIPT call frames; the unified ``ast_exec_stack``
|
|
58
|
-
is the authoritative debugger view.
|
|
55
|
+
The metacommand keeps its upstream name; what it prints is the AST
|
|
56
|
+
executor's ``ast_exec_stack``.
|
|
59
57
|
"""
|
|
60
58
|
stack = getattr(_state, "ast_exec_stack", None) or []
|
|
61
59
|
_state.output.write(f"Execution Stack (depth: {len(stack)}):\n")
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""What a matched metacommand touches: files it reads, writes or deletes, and connections.
|
|
2
|
+
|
|
3
|
+
Used by ``run --manifest``, which records the metacommands a run executed.
|
|
4
|
+
It classifies from the handler a metacommand dispatched to and the groups its
|
|
5
|
+
regex captured, so any other reader of a script (a static inspector) can share
|
|
6
|
+
the same answer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
__all__ = ["Effect", "effects"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Effect:
|
|
18
|
+
"""One thing a metacommand touches.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
role: ``"read"``, ``"write"`` or ``"delete"`` for a file;
|
|
22
|
+
``"connect"`` or ``"use"`` for a database.
|
|
23
|
+
target: The file path, or for a connection ``alias: server/db`` or
|
|
24
|
+
``alias: file``.
|
|
25
|
+
detail: For a connection, the DBMS and user. Never a password.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
role: str
|
|
29
|
+
target: str
|
|
30
|
+
detail: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_IMPORTS = (
|
|
34
|
+
"x_import",
|
|
35
|
+
"x_import_file",
|
|
36
|
+
"x_import_feather",
|
|
37
|
+
"x_import_json",
|
|
38
|
+
"x_import_ods",
|
|
39
|
+
"x_import_ods_pattern",
|
|
40
|
+
"x_import_parquet",
|
|
41
|
+
"x_import_xls",
|
|
42
|
+
"x_import_xls_pattern",
|
|
43
|
+
"x_sub_ini",
|
|
44
|
+
"x_serve",
|
|
45
|
+
)
|
|
46
|
+
_EXPORTS = (
|
|
47
|
+
"x_export",
|
|
48
|
+
"x_export_query",
|
|
49
|
+
"x_export_metadata",
|
|
50
|
+
"x_export_with_template",
|
|
51
|
+
"x_export_query_with_template",
|
|
52
|
+
"x_export_ods_multiple",
|
|
53
|
+
"x_export_xlsx_multiple",
|
|
54
|
+
)
|
|
55
|
+
_WRITES = (
|
|
56
|
+
"x_write",
|
|
57
|
+
"x_writescript",
|
|
58
|
+
"x_consolesave",
|
|
59
|
+
"x_cancel_halt_write",
|
|
60
|
+
"x_error_halt_write",
|
|
61
|
+
"x_debug_write_config",
|
|
62
|
+
"x_debug_write_metacommands",
|
|
63
|
+
"x_debug_write_odbc_drivers",
|
|
64
|
+
"x_debug_write_subvars",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
#: Handler name → {regex group: role}. Groups not listed (passwords,
|
|
68
|
+
#: encodings, prompt paths) are never reported.
|
|
69
|
+
FILE_ROLES: dict[str, dict[str, str]] = {
|
|
70
|
+
**{name: {"filename": "read"} for name in _IMPORTS},
|
|
71
|
+
**{name: {"filename": "write", "zipfilename": "write", "template": "read"} for name in _EXPORTS},
|
|
72
|
+
**{name: {"filename": "write"} for name in _WRITES},
|
|
73
|
+
**{
|
|
74
|
+
name: {"filename": "read", "outfile": "write"}
|
|
75
|
+
for name in ("x_write_create_table", "x_write_create_table_ods", "x_write_create_table_xls")
|
|
76
|
+
},
|
|
77
|
+
"x_write_create_table_alias": {"filename": "read"},
|
|
78
|
+
**{
|
|
79
|
+
name: {"att_file": "read", "msg_file": "read"}
|
|
80
|
+
for name in ("x_email", "x_cancel_halt_email", "x_error_halt_email")
|
|
81
|
+
},
|
|
82
|
+
"x_zip": {"filename": "read", "zipfilename": "write"},
|
|
83
|
+
"x_rm_file": {"filename": "delete"},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
DBMS = {
|
|
87
|
+
"pg": "PostgreSQL",
|
|
88
|
+
"mysql": "MySQL",
|
|
89
|
+
"ssvr": "SQL Server",
|
|
90
|
+
"ora": "Oracle",
|
|
91
|
+
"fb": "Firebird",
|
|
92
|
+
"sqlite": "SQLite",
|
|
93
|
+
"duckdb": "DuckDB",
|
|
94
|
+
"access": "MS Access",
|
|
95
|
+
"dsn": "ODBC DSN",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _connection(handler: str, groups: dict[str, str | None]) -> Effect | None:
|
|
100
|
+
if handler == "x_use":
|
|
101
|
+
return Effect("use", groups.get("db_alias") or "")
|
|
102
|
+
if not handler.startswith("x_connect_"):
|
|
103
|
+
return None
|
|
104
|
+
kind = handler.removeprefix("x_connect_").removeprefix("user_")
|
|
105
|
+
target = groups.get("filename") or groups.get("dsn") or ""
|
|
106
|
+
if groups.get("server"):
|
|
107
|
+
target = f"{groups['server']}/{groups.get('db_name') or ''}"
|
|
108
|
+
detail = DBMS.get(kind, kind)
|
|
109
|
+
if groups.get("user"):
|
|
110
|
+
detail += f", user {groups['user']}"
|
|
111
|
+
return Effect("connect", f"{groups.get('db_alias') or ''}: {target}".strip(), detail)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def effects(handler: str, groups: dict[str, str | None]) -> list[Effect]:
|
|
115
|
+
"""Everything a metacommand dispatched to *handler* with *groups* touches."""
|
|
116
|
+
connection = _connection(handler, groups)
|
|
117
|
+
if connection is not None:
|
|
118
|
+
return [connection]
|
|
119
|
+
found = []
|
|
120
|
+
for group, role in FILE_ROLES.get(handler, {}).items():
|
|
121
|
+
value = groups.get(group)
|
|
122
|
+
if value:
|
|
123
|
+
found.append(Effect(role, value.strip().strip('"')))
|
|
124
|
+
return found
|
|
@@ -8,14 +8,18 @@ Implements ``x_include``, ``x_copy``, ``x_copy_query``, ``x_zip``,
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
|
+
import pickle
|
|
12
|
+
import struct
|
|
11
13
|
import sys
|
|
12
|
-
|
|
14
|
+
import tempfile
|
|
15
|
+
from collections.abc import Iterator
|
|
13
16
|
from pathlib import Path
|
|
14
17
|
from shutil import copyfileobj
|
|
15
|
-
from typing import Any, Literal
|
|
18
|
+
from typing import Any, Literal
|
|
16
19
|
|
|
17
20
|
import execsql.state as _state
|
|
18
21
|
from execsql.exceptions import ErrInfo
|
|
22
|
+
from execsql.importers.base import refuse_existing_table
|
|
19
23
|
from execsql.models import DataTable
|
|
20
24
|
from execsql.script import current_script_line
|
|
21
25
|
from execsql.utils.errors import exception_desc
|
|
@@ -23,6 +27,39 @@ from execsql.utils.fileio import filewriter_close
|
|
|
23
27
|
from execsql.utils.strings import unquoted
|
|
24
28
|
|
|
25
29
|
|
|
30
|
+
class _RowSpool:
|
|
31
|
+
"""Pass rows through while writing each to a temporary file, to be read back once more.
|
|
32
|
+
|
|
33
|
+
``COPY ... TO NEW`` needs every row twice, once to describe the new table
|
|
34
|
+
and once to load it, but runs the source query once. The rows are kept
|
|
35
|
+
in memory up to *max_size* bytes and on disk beyond that. Each row is
|
|
36
|
+
pickled on its own, so neither pass keeps a reference to earlier rows.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_LENGTH = struct.Struct("<Q")
|
|
40
|
+
|
|
41
|
+
def __init__(self, rows: Any, max_size: int = 8 * 1024 * 1024) -> None:
|
|
42
|
+
self._rows = rows
|
|
43
|
+
self._file = tempfile.SpooledTemporaryFile(max_size=max_size) # noqa: SIM115 - closed by close()
|
|
44
|
+
|
|
45
|
+
def __iter__(self) -> Iterator[Any]:
|
|
46
|
+
for row in self._rows:
|
|
47
|
+
data = pickle.dumps(row, protocol=pickle.HIGHEST_PROTOCOL)
|
|
48
|
+
self._file.write(self._LENGTH.pack(len(data)))
|
|
49
|
+
self._file.write(data)
|
|
50
|
+
yield row
|
|
51
|
+
|
|
52
|
+
def replay(self) -> Iterator[Any]:
|
|
53
|
+
"""Yield the rows passed through, in order."""
|
|
54
|
+
self._file.seek(0)
|
|
55
|
+
while header := self._file.read(self._LENGTH.size):
|
|
56
|
+
(size,) = self._LENGTH.unpack(header)
|
|
57
|
+
yield pickle.loads(self._file.read(size))
|
|
58
|
+
|
|
59
|
+
def close(self) -> None:
|
|
60
|
+
self._file.close()
|
|
61
|
+
|
|
62
|
+
|
|
26
63
|
def _close_rowsource(rows: Any) -> None:
|
|
27
64
|
close = getattr(rows, "close", None)
|
|
28
65
|
if close:
|
|
@@ -63,29 +100,16 @@ def x_copy(**kwargs: Any) -> None:
|
|
|
63
100
|
db2 = _state.dbs.aliased_as(alias2)
|
|
64
101
|
tbl1 = db1.schema_qualified_table_name(schema1, table1)
|
|
65
102
|
tbl2 = db2.schema_qualified_table_name(schema2, table2)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
type="cmd",
|
|
70
|
-
command_text=kwargs["metacommandline"],
|
|
71
|
-
other_msg=f"Table {tbl1} does not exist",
|
|
72
|
-
)
|
|
73
|
-
except Exception:
|
|
74
|
-
pass # Best-effort check; some adapters lack information_schema.
|
|
103
|
+
# No check that the source exists: the database may fold an unquoted name
|
|
104
|
+
# (PostgreSQL reads Orders as orders), and the SELECT below names a missing
|
|
105
|
+
# source in the database's own words.
|
|
75
106
|
if new_tbl2 and new_tbl2 == "new":
|
|
76
|
-
|
|
77
|
-
if db2.table_exists(table2, schema2):
|
|
78
|
-
raise ErrInfo(
|
|
79
|
-
type="cmd",
|
|
80
|
-
command_text=kwargs["metacommandline"],
|
|
81
|
-
other_msg=f"Table {tbl2} already exists",
|
|
82
|
-
)
|
|
83
|
-
except Exception:
|
|
84
|
-
pass # Best-effort check; some adapters lack information_schema.
|
|
107
|
+
refuse_existing_table(db2, schema2, table2)
|
|
85
108
|
select_stmt = f"select * from {tbl1};"
|
|
86
109
|
|
|
87
110
|
get_ts_tablespec = None
|
|
88
111
|
rows_to_close = None
|
|
112
|
+
spool: _RowSpool | None = None
|
|
89
113
|
rows_for_insert: Any
|
|
90
114
|
|
|
91
115
|
if new_tbl2:
|
|
@@ -96,9 +120,10 @@ def x_copy(**kwargs: Any) -> None:
|
|
|
96
120
|
except Exception as e:
|
|
97
121
|
raise ErrInfo("db", select_stmt, exception_msg=exception_desc()) from e
|
|
98
122
|
rows_to_close = rows
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
get_ts_tablespec = DataTable(hdrs,
|
|
123
|
+
# Values come from a database cursor, so a text value stays text.
|
|
124
|
+
spool = _RowSpool(rows)
|
|
125
|
+
get_ts_tablespec = DataTable(hdrs, spool, infer_strings=False)
|
|
126
|
+
rows_for_insert = spool.replay()
|
|
102
127
|
tbl_desc = get_ts_tablespec
|
|
103
128
|
create_tbl = tbl_desc.create_table(db2.type, schema2, table2)
|
|
104
129
|
if new_tbl2 == "replacement":
|
|
@@ -129,7 +154,7 @@ def x_copy(**kwargs: Any) -> None:
|
|
|
129
154
|
nonlocal get_ts_tablespec
|
|
130
155
|
if get_ts_tablespec is None:
|
|
131
156
|
ts_hdrs, ts_rows = db1.select_rowsource(select_stmt)
|
|
132
|
-
get_ts_tablespec = DataTable(ts_hdrs, ts_rows)
|
|
157
|
+
get_ts_tablespec = DataTable(ts_hdrs, ts_rows, infer_strings=False)
|
|
133
158
|
return get_ts_tablespec
|
|
134
159
|
|
|
135
160
|
try:
|
|
@@ -138,6 +163,9 @@ def x_copy(**kwargs: Any) -> None:
|
|
|
138
163
|
except BaseException:
|
|
139
164
|
_close_rowsource(rows_to_close)
|
|
140
165
|
raise
|
|
166
|
+
finally:
|
|
167
|
+
if spool is not None:
|
|
168
|
+
spool.close()
|
|
141
169
|
|
|
142
170
|
|
|
143
171
|
def x_copy_query(**kwargs: Any) -> None:
|
|
@@ -164,18 +192,11 @@ def x_copy_query(**kwargs: Any) -> None:
|
|
|
164
192
|
db2 = _state.dbs.aliased_as(alias2)
|
|
165
193
|
tbl2 = db2.schema_qualified_table_name(schema2, table2)
|
|
166
194
|
if new_tbl2 and new_tbl2 == "new":
|
|
167
|
-
|
|
168
|
-
if db2.table_exists(table2, schema2):
|
|
169
|
-
raise ErrInfo(
|
|
170
|
-
type="cmd",
|
|
171
|
-
command_text=kwargs["metacommandline"],
|
|
172
|
-
other_msg=f"Table {tbl2} already exists",
|
|
173
|
-
)
|
|
174
|
-
except Exception:
|
|
175
|
-
pass # Best-effort check; some adapters lack information_schema.
|
|
195
|
+
refuse_existing_table(db2, schema2, table2)
|
|
176
196
|
|
|
177
197
|
get_ts_tablespec = None
|
|
178
198
|
rows_to_close = None
|
|
199
|
+
spool: _RowSpool | None = None
|
|
179
200
|
rows_for_insert: Any
|
|
180
201
|
|
|
181
202
|
if new_tbl2:
|
|
@@ -186,9 +207,10 @@ def x_copy_query(**kwargs: Any) -> None:
|
|
|
186
207
|
except Exception as e:
|
|
187
208
|
raise ErrInfo("db", select_stmt, exception_msg=exception_desc()) from e
|
|
188
209
|
rows_to_close = rows
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
get_ts_tablespec = DataTable(hdrs,
|
|
210
|
+
# Values come from a database cursor, so a text value stays text.
|
|
211
|
+
spool = _RowSpool(rows)
|
|
212
|
+
get_ts_tablespec = DataTable(hdrs, spool, infer_strings=False)
|
|
213
|
+
rows_for_insert = spool.replay()
|
|
192
214
|
tbl_desc = get_ts_tablespec
|
|
193
215
|
create_tbl = tbl_desc.create_table(db2.type, schema2, table2)
|
|
194
216
|
if new_tbl2 == "replacement":
|
|
@@ -219,7 +241,7 @@ def x_copy_query(**kwargs: Any) -> None:
|
|
|
219
241
|
nonlocal get_ts_tablespec
|
|
220
242
|
if get_ts_tablespec is None:
|
|
221
243
|
ts_hdrs, ts_rows = db1.select_rowsource(select_stmt)
|
|
222
|
-
get_ts_tablespec = DataTable(ts_hdrs, ts_rows)
|
|
244
|
+
get_ts_tablespec = DataTable(ts_hdrs, ts_rows, infer_strings=False)
|
|
223
245
|
return get_ts_tablespec
|
|
224
246
|
|
|
225
247
|
try:
|
|
@@ -228,6 +250,9 @@ def x_copy_query(**kwargs: Any) -> None:
|
|
|
228
250
|
except BaseException:
|
|
229
251
|
_close_rowsource(rows_to_close)
|
|
230
252
|
raise
|
|
253
|
+
finally:
|
|
254
|
+
if spool is not None:
|
|
255
|
+
spool.close()
|
|
231
256
|
|
|
232
257
|
|
|
233
258
|
def x_zip(**kwargs: Any) -> None:
|
execsql/metacommands/prompt.py
CHANGED
|
@@ -67,6 +67,7 @@ from execsql.utils.gui import (
|
|
|
67
67
|
pause,
|
|
68
68
|
pause_win,
|
|
69
69
|
)
|
|
70
|
+
from execsql.utils.fileio import register_secret
|
|
70
71
|
from execsql.utils.strings import unquoted
|
|
71
72
|
|
|
72
73
|
|
|
@@ -144,6 +145,7 @@ def x_prompt_enter(**kwargs: Any) -> None:
|
|
|
144
145
|
subvarset.add_substitution(sub_var, txtval)
|
|
145
146
|
script_name, lno = current_script_line()
|
|
146
147
|
if as_pw:
|
|
148
|
+
register_secret(txtval)
|
|
147
149
|
_state.exec_log.log_status_info(f"Password assigned to variable {{{sub_var}}} on line {lno}.")
|
|
148
150
|
else:
|
|
149
151
|
_state.exec_log.log_status_info(f"Variable {{{sub_var}}} set to {{{txtval}}} on line {lno}.")
|
execsql/metacommands/upsert.py
CHANGED
|
@@ -4,7 +4,7 @@ Integrates pg-upsert (https://pg-upsert.readthedocs.io/) as an optional
|
|
|
4
4
|
dependency, providing QA-checked, FK-dependency-ordered upserts from a
|
|
5
5
|
staging schema to a base schema on PostgreSQL.
|
|
6
6
|
|
|
7
|
-
Requires
|
|
7
|
+
Requires the ``upsert`` extra (``execsql2[upsert]``).
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
@@ -213,7 +213,10 @@ def _require_pg_upsert() -> None:
|
|
|
213
213
|
except ImportError as exc:
|
|
214
214
|
raise ErrInfo(
|
|
215
215
|
"exception",
|
|
216
|
-
other_msg=(
|
|
216
|
+
other_msg=(
|
|
217
|
+
'PG_UPSERT requires the pg-upsert package. Install the upsert extra: uv tool install "execsql2[upsert]"'
|
|
218
|
+
' (or pip install "execsql2[upsert]").'
|
|
219
|
+
),
|
|
217
220
|
) from exc
|
|
218
221
|
|
|
219
222
|
|