execsql2 2.29.0__py3-none-any.whl → 2.30.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/__main__.py +4 -5
- execsql/api.py +5 -0
- execsql/cli/__init__.py +3 -14
- execsql/cli/application.py +5 -3
- execsql/cli/commands/lint.py +4 -1
- execsql/cli/commands/lsp.py +42 -0
- execsql/cli/dsn.py +3 -1
- execsql/cli/lint.py +14 -6
- execsql/cli/run.py +17 -9
- execsql/config.py +32 -6
- execsql/db/duckdb.py +96 -14
- execsql/gui/__init__.py +3 -31
- execsql/lsp/_util.py +21 -0
- execsql/lsp/cache.py +73 -0
- execsql/lsp/code_actions.py +10 -14
- execsql/lsp/completion.py +8 -15
- execsql/lsp/diagnostics.py +14 -2
- execsql/lsp/document.py +139 -41
- execsql/lsp/hover.py +5 -13
- execsql/lsp/navigation.py +7 -10
- execsql/lsp/server.py +111 -29
- execsql/manifest.py +29 -9
- execsql/metacommands/__init__.py +0 -2
- execsql/metacommands/io_fileops.py +5 -0
- execsql/script/executor.py +2 -21
- execsql/state.py +6 -3
- execsql/state.pyi +2 -0
- execsql/utils/errors.py +19 -36
- execsql/utils/fileio.py +44 -48
- execsql/utils/regex.py +0 -5
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/METADATA +2 -4
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/RECORD +50 -48
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/WHEEL +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/licenses/NOTICE +0 -0
execsql/lsp/server.py
CHANGED
|
@@ -6,6 +6,7 @@ import asyncio
|
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
|
+
from collections.abc import Iterable
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
@@ -14,10 +15,11 @@ from pygls.lsp.server import LanguageServer
|
|
|
14
15
|
from pygls.uris import to_fs_path
|
|
15
16
|
|
|
16
17
|
from execsql import __version__
|
|
18
|
+
from execsql.lsp.cache import IndexCache
|
|
17
19
|
from execsql.lsp.code_actions import code_actions
|
|
18
20
|
from execsql.lsp.completion import completions
|
|
19
|
-
from execsql.lsp.diagnostics import diagnostics, too_deep_diagnostic
|
|
20
|
-
from execsql.lsp.document import ScriptIndex, index_script,
|
|
21
|
+
from execsql.lsp.diagnostics import diagnostics, failure_diagnostic, too_deep_diagnostic
|
|
22
|
+
from execsql.lsp.document import ScriptIndex, index_script, same_file_key
|
|
21
23
|
from execsql.lsp.folding import folding_ranges
|
|
22
24
|
from execsql.lsp.formatting import format_document
|
|
23
25
|
from execsql.lsp.hover import hover
|
|
@@ -26,6 +28,8 @@ from execsql.lsp.navigation import definition, document_links, document_symbols,
|
|
|
26
28
|
|
|
27
29
|
__all__ = ["ExecsqlLanguageServer", "create_server"]
|
|
28
30
|
|
|
31
|
+
_log = logging.getLogger(__name__)
|
|
32
|
+
|
|
29
33
|
# Pause after the last keystroke before linting, so a burst of typing lints once.
|
|
30
34
|
LINT_DELAY_SECONDS = 0.2
|
|
31
35
|
|
|
@@ -54,34 +58,42 @@ class Settings:
|
|
|
54
58
|
select: tuple[str, ...] = ()
|
|
55
59
|
ignore: tuple[str, ...] = ()
|
|
56
60
|
format_options: dict[str, Any] = field(default_factory=dict) # format_document keywords, from [format]
|
|
61
|
+
config_files: tuple[str, ...] = () # the files read, lowest precedence first
|
|
57
62
|
|
|
58
63
|
|
|
59
64
|
class ExecsqlLanguageServer(LanguageServer):
|
|
60
|
-
"""Holds what the handlers share: each workspace folder's settings
|
|
65
|
+
"""Holds what the handlers share: each workspace folder's settings, pending lint tasks and the index cache."""
|
|
61
66
|
|
|
62
67
|
def __init__(self) -> None:
|
|
63
68
|
super().__init__("execsql", __version__)
|
|
64
69
|
self.settings: dict[str, Settings] = {} # by same_file_key of each workspace folder
|
|
65
70
|
self.fallback = Settings() # for a file in no workspace folder
|
|
66
71
|
self.pending: dict[str, asyncio.Task[Any]] = {}
|
|
72
|
+
self.cache = IndexCache()
|
|
67
73
|
|
|
68
74
|
def load_settings(self) -> None:
|
|
69
75
|
"""Read ``[lint]`` and ``[format]`` for each workspace folder, as ``execsql lint`` / ``execsql format`` run there would.
|
|
70
76
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
there, and an unsaved script's relative ``INCLUDE`` resolves there.
|
|
77
|
+
Each folder stands in for the working directory those commands read
|
|
78
|
+
config from; the server's own working directory never changes. A file
|
|
79
|
+
in no folder gets the settings of :meth:`base_dir`.
|
|
75
80
|
"""
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
os.chdir(root)
|
|
79
|
-
self.fallback = self._read_settings(None)
|
|
81
|
+
self.fallback = self._read_settings(self.base_dir())
|
|
82
|
+
_log_settings("files in no workspace folder", self.fallback)
|
|
80
83
|
self.settings = {}
|
|
81
84
|
for folder in self.workspace.folders.values():
|
|
82
85
|
path = to_fs_path(folder.uri)
|
|
83
86
|
if path and os.path.isdir(path):
|
|
84
|
-
self.settings[same_file_key(path)] = self._read_settings(path)
|
|
87
|
+
self.settings[same_file_key(path)] = settings = self._read_settings(path)
|
|
88
|
+
_log_settings(path, settings)
|
|
89
|
+
|
|
90
|
+
def base_dir(self) -> str | None:
|
|
91
|
+
"""The workspace root, if it still exists: an unsaved script's relative paths resolve there.
|
|
92
|
+
|
|
93
|
+
``None`` (no root) leaves them to the server's working directory.
|
|
94
|
+
"""
|
|
95
|
+
root = self.workspace.root_path
|
|
96
|
+
return root if root and os.path.isdir(root) else None
|
|
85
97
|
|
|
86
98
|
def _read_settings(self, folder: str | None) -> Settings:
|
|
87
99
|
"""The settings ``execsql lint`` run from *folder* (the working directory if ``None``) reads.
|
|
@@ -91,11 +103,8 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
91
103
|
from execsql.cli.lint import resolve_selectors
|
|
92
104
|
from execsql.cli.run import _tool_config
|
|
93
105
|
|
|
94
|
-
here = os.getcwd()
|
|
95
106
|
try:
|
|
96
|
-
|
|
97
|
-
os.chdir(folder)
|
|
98
|
-
conf = _tool_config(None)
|
|
107
|
+
conf = _tool_config(None, start_dir=folder)
|
|
99
108
|
return Settings(
|
|
100
109
|
select=resolve_selectors([conf.lint_select]) if conf.lint_select else (),
|
|
101
110
|
ignore=resolve_selectors([conf.lint_ignore]) if conf.lint_ignore else (),
|
|
@@ -105,14 +114,14 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
105
114
|
"leading_comma": conf.format_leading_comma,
|
|
106
115
|
"rewrite_sql": conf.format_rewrite_sql,
|
|
107
116
|
},
|
|
117
|
+
config_files=tuple(conf.files_read),
|
|
108
118
|
)
|
|
109
119
|
except Exception as exc: # a bad config must not take the server down
|
|
120
|
+
_log.warning("execsql config for %s: %s", folder or "the working directory", exc)
|
|
110
121
|
self.window_show_message(
|
|
111
122
|
types.ShowMessageParams(type=types.MessageType.Warning, message=f"execsql config: {exc}"),
|
|
112
123
|
)
|
|
113
124
|
return Settings()
|
|
114
|
-
finally:
|
|
115
|
-
os.chdir(here)
|
|
116
125
|
|
|
117
126
|
def settings_for(self, uri: str) -> Settings:
|
|
118
127
|
"""The settings of the innermost workspace folder holding *uri*'s file, else :attr:`fallback`."""
|
|
@@ -124,7 +133,7 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
124
133
|
return self.settings[max(holding, key=len)] if holding else self.fallback
|
|
125
134
|
|
|
126
135
|
def index(self, uri: str) -> ScriptIndex:
|
|
127
|
-
"""The current document's index
|
|
136
|
+
"""The current document's index, rebuilt per request from :attr:`cache`d per-file facts.
|
|
128
137
|
|
|
129
138
|
A script the parser cannot handle (nesting past Python's recursion
|
|
130
139
|
limit) gets an empty index, so features fall back to what needs none.
|
|
@@ -132,9 +141,12 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
132
141
|
document = self.workspace.get_text_document(uri)
|
|
133
142
|
path = to_fs_path(uri)
|
|
134
143
|
try:
|
|
135
|
-
index = index_script(document.source, path, self.read_included)
|
|
144
|
+
index = index_script(document.source, path, self.read_included, self.cache.facts, self.base_dir())
|
|
136
145
|
except RecursionError:
|
|
137
146
|
index = ScriptIndex(path=path)
|
|
147
|
+
except Exception: # the features that need an index answer with nothing; the cause goes to the log
|
|
148
|
+
_log.exception("execsql lsp: could not index %s", uri)
|
|
149
|
+
index = ScriptIndex(path=path)
|
|
138
150
|
index.open_uris = self.open_uris()
|
|
139
151
|
return index
|
|
140
152
|
|
|
@@ -161,7 +173,7 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
161
173
|
uri = self.open_uris().get(same_file_key(str(path)))
|
|
162
174
|
if uri is not None:
|
|
163
175
|
return self.workspace.get_text_document(uri).source
|
|
164
|
-
return
|
|
176
|
+
return self.cache.text(path)
|
|
165
177
|
|
|
166
178
|
def supports_snippets(self) -> bool:
|
|
167
179
|
caps = self.client_capabilities.text_document
|
|
@@ -173,17 +185,41 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
173
185
|
document = self.workspace.get_text_document(uri)
|
|
174
186
|
settings = self.settings_for(uri)
|
|
175
187
|
try:
|
|
176
|
-
found = diagnostics(document.source, to_fs_path(uri), settings.select, settings.ignore)
|
|
188
|
+
found = diagnostics(document.source, to_fs_path(uri), settings.select, settings.ignore, self.base_dir())
|
|
177
189
|
except RecursionError:
|
|
178
190
|
found = [too_deep_diagnostic()]
|
|
191
|
+
except Exception as exc: # shown in the editor, not lost in an unawaited task
|
|
192
|
+
_log.exception("execsql lsp: could not lint %s", uri)
|
|
193
|
+
found = [failure_diagnostic(exc)]
|
|
179
194
|
self.text_document_publish_diagnostics(
|
|
180
195
|
types.PublishDiagnosticsParams(uri=uri, version=document.version, diagnostics=found),
|
|
181
196
|
)
|
|
182
197
|
|
|
183
|
-
def
|
|
184
|
-
"""Lint
|
|
198
|
+
def relint(self, changed: Iterable[str] | None = None) -> None:
|
|
199
|
+
"""Lint again, after :data:`LINT_DELAY_SECONDS`, each open document a change to the *changed* files can affect.
|
|
200
|
+
|
|
201
|
+
Lint reads no included file's text; it checks only that each file a
|
|
202
|
+
script ``INCLUDE``s exists. So a document is affected by a change to
|
|
203
|
+
itself or to a file it includes. ``None`` means everything changed
|
|
204
|
+
(the config, the workspace folders). Going through the debounce, the
|
|
205
|
+
editor's save and its file watcher's report of the same save lint once.
|
|
206
|
+
"""
|
|
207
|
+
keys = None if changed is None else {same_file_key(path) for path in changed}
|
|
185
208
|
for uri in list(self.workspace.text_documents):
|
|
186
|
-
self.
|
|
209
|
+
if keys is None or self._affected(uri, keys):
|
|
210
|
+
self.publish_later(uri)
|
|
211
|
+
|
|
212
|
+
def _affected(self, uri: str, keys: set[str]) -> bool:
|
|
213
|
+
path = to_fs_path(uri)
|
|
214
|
+
if path is None:
|
|
215
|
+
return False
|
|
216
|
+
if same_file_key(path) in keys:
|
|
217
|
+
return True
|
|
218
|
+
try:
|
|
219
|
+
facts = self.cache.facts(self.workspace.get_text_document(uri).source, path)
|
|
220
|
+
except Exception: # when unsure, lint again
|
|
221
|
+
return True
|
|
222
|
+
return any(same_file_key(target) in keys for target in facts.includes)
|
|
187
223
|
|
|
188
224
|
def watch_files(self) -> None:
|
|
189
225
|
"""Ask the client to report changes to :data:`WATCHED_FILES`, if it can."""
|
|
@@ -219,6 +255,36 @@ class ExecsqlLanguageServer(LanguageServer):
|
|
|
219
255
|
self.pending[uri] = asyncio.ensure_future(run())
|
|
220
256
|
|
|
221
257
|
|
|
258
|
+
def _log_settings(where: str, settings: Settings) -> None:
|
|
259
|
+
_log.info(
|
|
260
|
+
"settings for %s: config files %s; lint select %s, ignore %s; format %s",
|
|
261
|
+
where,
|
|
262
|
+
", ".join(settings.config_files) or "none",
|
|
263
|
+
",".join(settings.select) or "all",
|
|
264
|
+
",".join(settings.ignore) or "none",
|
|
265
|
+
settings.format_options or "defaults",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def startup_record() -> str:
|
|
270
|
+
"""What a bug report needs first: which execsql, run by which Python, with which optional parts."""
|
|
271
|
+
import sys
|
|
272
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
273
|
+
from importlib.util import find_spec
|
|
274
|
+
|
|
275
|
+
def installed(name: str) -> str:
|
|
276
|
+
try:
|
|
277
|
+
return version(name)
|
|
278
|
+
except PackageNotFoundError:
|
|
279
|
+
return "not installed"
|
|
280
|
+
|
|
281
|
+
formatter = "available" if find_spec("sqlglot") is not None else "not installed (Format Document needs it)"
|
|
282
|
+
return (
|
|
283
|
+
f"execsql lsp {__version__}; Python {sys.version.split()[0]} at {sys.executable}; "
|
|
284
|
+
f"pygls {installed('pygls')}; formatter {formatter}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
222
288
|
def create_server() -> ExecsqlLanguageServer:
|
|
223
289
|
"""A server with every feature registered."""
|
|
224
290
|
server = ExecsqlLanguageServer()
|
|
@@ -228,9 +294,19 @@ def create_server() -> ExecsqlLanguageServer:
|
|
|
228
294
|
|
|
229
295
|
@server.feature(types.INITIALIZED)
|
|
230
296
|
def initialized(ls: ExecsqlLanguageServer, params: types.InitializedParams) -> None:
|
|
297
|
+
_log.info(startup_record())
|
|
298
|
+
folders = [to_fs_path(f.uri) or f.uri for f in ls.workspace.folders.values()]
|
|
299
|
+
_log.info("workspace folders: %s", ", ".join(folders) or "none")
|
|
231
300
|
ls.load_settings()
|
|
232
301
|
ls.watch_files()
|
|
233
302
|
|
|
303
|
+
@server.feature(types.SHUTDOWN)
|
|
304
|
+
def shutdown(ls: ExecsqlLanguageServer, params: None) -> None:
|
|
305
|
+
# No diagnostics may follow the answer to shutdown.
|
|
306
|
+
for task in ls.pending.values():
|
|
307
|
+
task.cancel()
|
|
308
|
+
ls.pending.clear()
|
|
309
|
+
|
|
234
310
|
@server.feature(types.TEXT_DOCUMENT_DID_OPEN)
|
|
235
311
|
def did_open(ls: ExecsqlLanguageServer, params: types.DidOpenTextDocumentParams) -> None:
|
|
236
312
|
ls.publish(params.text_document.uri)
|
|
@@ -242,19 +318,25 @@ def create_server() -> ExecsqlLanguageServer:
|
|
|
242
318
|
@server.feature(types.TEXT_DOCUMENT_DID_SAVE)
|
|
243
319
|
def did_save(ls: ExecsqlLanguageServer, params: types.DidSaveTextDocumentParams) -> None:
|
|
244
320
|
# Another open script may INCLUDE this one; a client that cannot watch files reports nothing else.
|
|
245
|
-
|
|
321
|
+
path = to_fs_path(params.text_document.uri)
|
|
322
|
+
ls.relint([path] if path else [])
|
|
246
323
|
|
|
247
324
|
@server.feature(types.WORKSPACE_DID_CHANGE_WATCHED_FILES)
|
|
248
325
|
def did_change_watched_files(ls: ExecsqlLanguageServer, params: types.DidChangeWatchedFilesParams) -> None:
|
|
249
|
-
|
|
326
|
+
paths = [path for change in params.changes if (path := to_fs_path(change.uri))]
|
|
327
|
+
for path in paths:
|
|
328
|
+
ls.cache.forget(path)
|
|
329
|
+
if any(Path(path).name.lower() == "execsql.conf" for path in paths):
|
|
250
330
|
ls.load_settings()
|
|
251
|
-
|
|
331
|
+
ls.relint()
|
|
332
|
+
else:
|
|
333
|
+
ls.relint(paths)
|
|
252
334
|
|
|
253
335
|
@server.feature(types.WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS)
|
|
254
336
|
def did_change_folders(ls: ExecsqlLanguageServer, params: types.DidChangeWorkspaceFoldersParams) -> None:
|
|
255
337
|
# pygls has already updated ls.workspace.folders.
|
|
256
338
|
ls.load_settings()
|
|
257
|
-
ls.
|
|
339
|
+
ls.relint()
|
|
258
340
|
|
|
259
341
|
@server.feature(types.TEXT_DOCUMENT_DID_CLOSE)
|
|
260
342
|
def did_close(ls: ExecsqlLanguageServer, params: types.DidCloseTextDocumentParams) -> None:
|
execsql/manifest.py
CHANGED
|
@@ -10,7 +10,11 @@ Files are recorded as the executor runs each metacommand, after variable
|
|
|
10
10
|
substitution, so paths are the ones actually used. What a metacommand touches
|
|
11
11
|
is decided by :mod:`execsql.metacommands.effects`. Variable values and passwords are never written:
|
|
12
12
|
the run log hides ``-a`` and ``--var`` values because they can be secrets, and
|
|
13
|
-
the manifest lists only their names.
|
|
13
|
+
the manifest lists only their names. A password or decrypted value that reaches
|
|
14
|
+
a path, a connection target or an error message is written as ``***``, as are
|
|
15
|
+
common secret shapes (a URL's password, ``password=...``, API tokens). ``-a`` and
|
|
16
|
+
``--var`` values, which the log also masks, are kept: they are usually part of
|
|
17
|
+
the paths the manifest exists to report.
|
|
14
18
|
|
|
15
19
|
One recorder is active per run; :func:`current` returns it, or ``None`` when
|
|
16
20
|
no manifest was asked for, so the executor's hooks cost one check. Writing the
|
|
@@ -81,6 +85,22 @@ def _now() -> datetime.datetime:
|
|
|
81
85
|
return datetime.datetime.now(tz=datetime.timezone.utc)
|
|
82
86
|
|
|
83
87
|
|
|
88
|
+
def _masked(value: Any) -> Any:
|
|
89
|
+
"""*value* with the run's passwords and common secret shapes replaced by ``***`` in every string it holds.
|
|
90
|
+
|
|
91
|
+
``-a`` / ``--var`` values are left in place; times, counts and line numbers are not strings.
|
|
92
|
+
"""
|
|
93
|
+
from execsql.utils.fileio import redact
|
|
94
|
+
|
|
95
|
+
if isinstance(value, str):
|
|
96
|
+
return redact(value, arguments=False)
|
|
97
|
+
if isinstance(value, list):
|
|
98
|
+
return [_masked(item) for item in value]
|
|
99
|
+
if isinstance(value, dict):
|
|
100
|
+
return {key: _masked(item) for key, item in value.items()}
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
84
104
|
class RunManifest:
|
|
85
105
|
"""Collects what one run does and writes it as JSON when the run ends."""
|
|
86
106
|
|
|
@@ -166,20 +186,20 @@ class RunManifest:
|
|
|
166
186
|
finished = _now()
|
|
167
187
|
data = {
|
|
168
188
|
"execsql_version": __version__,
|
|
169
|
-
"script": self.script,
|
|
189
|
+
"script": _masked(self.script),
|
|
170
190
|
"started": self.started.isoformat(timespec="seconds"),
|
|
171
191
|
"finished": finished.isoformat(timespec="seconds"),
|
|
172
192
|
"duration_s": round((finished - self.started).total_seconds(), 3),
|
|
173
193
|
"exit_status": exit_status,
|
|
174
|
-
"config_files": self.config_files,
|
|
175
|
-
"database": self.database,
|
|
194
|
+
"config_files": _masked(self.config_files),
|
|
195
|
+
"database": _masked(self.database),
|
|
176
196
|
"variables": self.variables,
|
|
177
197
|
"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,
|
|
198
|
+
"files_read": _masked(self.reads),
|
|
199
|
+
"files_written": _masked(self.writes),
|
|
200
|
+
"files_deleted": _masked(self.deletes),
|
|
201
|
+
"connections": _masked(self.connections),
|
|
202
|
+
"errors": _masked(self.errors),
|
|
183
203
|
}
|
|
184
204
|
self.written = True
|
|
185
205
|
import execsql.state as _state
|
execsql/metacommands/__init__.py
CHANGED
|
@@ -242,7 +242,6 @@ from execsql.metacommands.system import (
|
|
|
242
242
|
# Regex helper functions (from utils/regex.py)
|
|
243
243
|
from execsql.utils.regex import (
|
|
244
244
|
ins_rxs,
|
|
245
|
-
ins_quoted_rx,
|
|
246
245
|
ins_schema_rxs,
|
|
247
246
|
ins_table_rxs,
|
|
248
247
|
ins_table_list_rxs,
|
|
@@ -443,7 +442,6 @@ __all__ = [
|
|
|
443
442
|
"x_pg_upsert_qa",
|
|
444
443
|
# regex helpers
|
|
445
444
|
"ins_rxs",
|
|
446
|
-
"ins_quoted_rx",
|
|
447
445
|
"ins_schema_rxs",
|
|
448
446
|
"ins_table_rxs",
|
|
449
447
|
"ins_table_list_rxs",
|
|
@@ -307,6 +307,11 @@ def x_cd(**kwargs: Any) -> None:
|
|
|
307
307
|
command_text=kwargs["metacommandline"],
|
|
308
308
|
other_msg="Directory does not exist",
|
|
309
309
|
)
|
|
310
|
+
if _state.cd_from is None:
|
|
311
|
+
try:
|
|
312
|
+
_state.cd_from = os.getcwd()
|
|
313
|
+
except OSError: # the directory is gone: nowhere to return to
|
|
314
|
+
pass
|
|
310
315
|
os.chdir(new_dir)
|
|
311
316
|
script, lno = current_script_line()
|
|
312
317
|
_state.exec_log.log_status_info(f"Current directory changed to {new_dir} at line {lno} of {script}")
|
execsql/script/executor.py
CHANGED
|
@@ -957,16 +957,15 @@ class ExecutingStatement:
|
|
|
957
957
|
rather than copying fields out of it, so there is nothing to keep in sync
|
|
958
958
|
and no second source of truth about what is executing. Error reporting
|
|
959
959
|
(:mod:`execsql.utils.errors`), the debug REPL, and ``api.run()`` read
|
|
960
|
-
|
|
960
|
+
its attributes; everything is derived from ``node`` on access, so
|
|
961
961
|
constructing one costs a single small object per statement rather than
|
|
962
962
|
the two classes the previous shim built each time.
|
|
963
963
|
"""
|
|
964
964
|
|
|
965
|
-
__slots__ = ("node",
|
|
965
|
+
__slots__ = ("node",)
|
|
966
966
|
|
|
967
967
|
def __init__(self, node: Node) -> None:
|
|
968
968
|
self.node = node
|
|
969
|
-
self._source_dir: str | None = None
|
|
970
969
|
|
|
971
970
|
@property
|
|
972
971
|
def source(self) -> str:
|
|
@@ -976,18 +975,6 @@ class ExecutingStatement:
|
|
|
976
975
|
def line_no(self) -> int:
|
|
977
976
|
return self.node.span.start_line
|
|
978
977
|
|
|
979
|
-
@property
|
|
980
|
-
def source_name(self) -> str:
|
|
981
|
-
return Path(self.node.span.file).name
|
|
982
|
-
|
|
983
|
-
@property
|
|
984
|
-
def source_dir(self) -> str:
|
|
985
|
-
# resolve() touches the filesystem, so it is done once per statement
|
|
986
|
-
# object rather than on every read.
|
|
987
|
-
if self._source_dir is None:
|
|
988
|
-
self._source_dir = str(Path(self.node.span.file).resolve().parent) + os.sep
|
|
989
|
-
return self._source_dir
|
|
990
|
-
|
|
991
978
|
@property
|
|
992
979
|
def command_type(self) -> str:
|
|
993
980
|
return "sql" if isinstance(self.node, SqlStatement) else "cmd"
|
|
@@ -1004,12 +991,6 @@ class ExecutingStatement:
|
|
|
1004
991
|
def __repr__(self) -> str:
|
|
1005
992
|
return f"ExecutingStatement({self.source}:{self.line_no}, {self.command_type})"
|
|
1006
993
|
|
|
1007
|
-
def current_script_line(self) -> tuple[str, int]:
|
|
1008
|
-
return (self.source, self.line_no)
|
|
1009
|
-
|
|
1010
|
-
def commandline(self) -> str:
|
|
1011
|
-
return cast(str, self.command.commandline())
|
|
1012
|
-
|
|
1013
994
|
|
|
1014
995
|
# ---------------------------------------------------------------------------
|
|
1015
996
|
# Node type/text helpers for profiling
|
execsql/state.py
CHANGED
|
@@ -89,8 +89,6 @@ class ExecFrame:
|
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
if TYPE_CHECKING:
|
|
92
|
-
import multiprocessing as _mp
|
|
93
|
-
|
|
94
92
|
from execsql.config import ConfigData, StatObj, WriteHooks
|
|
95
93
|
from execsql.db.base import DatabasePool
|
|
96
94
|
from execsql.exporters.base import ExportMetadata, WriteSpec
|
|
@@ -230,6 +228,7 @@ _CONTEXT_ATTRS: frozenset[str] = frozenset(
|
|
|
230
228
|
"manifest",
|
|
231
229
|
"halt_error",
|
|
232
230
|
"halt_output_error",
|
|
231
|
+
"cd_from",
|
|
233
232
|
# Lazy singletons
|
|
234
233
|
"counters",
|
|
235
234
|
"timer",
|
|
@@ -288,6 +287,7 @@ class RuntimeContext:
|
|
|
288
287
|
"manifest",
|
|
289
288
|
"halt_error",
|
|
290
289
|
"halt_output_error",
|
|
290
|
+
"cd_from",
|
|
291
291
|
# Lazy singletons
|
|
292
292
|
"counters",
|
|
293
293
|
"timer",
|
|
@@ -344,6 +344,9 @@ class RuntimeContext:
|
|
|
344
344
|
self.halt_error: ErrInfo | None = None
|
|
345
345
|
# File output lost when the run halted (set by ``exit_now``), or None.
|
|
346
346
|
self.halt_output_error: ErrInfo | None = None
|
|
347
|
+
# The working directory before this run's first CD, or None: the
|
|
348
|
+
# library's run() returns its caller there.
|
|
349
|
+
self.cd_from: str | None = None
|
|
347
350
|
|
|
348
351
|
# Lazy singletons
|
|
349
352
|
self.counters: CounterVars | None = None
|
|
@@ -356,7 +359,7 @@ class RuntimeContext:
|
|
|
356
359
|
|
|
357
360
|
# GUI
|
|
358
361
|
self.gui_console: Any = None
|
|
359
|
-
self.gui_manager_queue:
|
|
362
|
+
self.gui_manager_queue: Any = None # a queue.Queue or a _TkinterSyncQueue; only put() is used
|
|
360
363
|
self.gui_manager_thread: threading.Thread | None = None
|
|
361
364
|
|
|
362
365
|
# Profiling — None means profiling is disabled; a list means it is enabled.
|
execsql/state.pyi
CHANGED
|
@@ -59,6 +59,7 @@ filewriter: FileWriter | None
|
|
|
59
59
|
manifest: RunManifest | None
|
|
60
60
|
halt_error: ErrInfo | None
|
|
61
61
|
halt_output_error: ErrInfo | None
|
|
62
|
+
cd_from: str | None
|
|
62
63
|
counters: CounterVars
|
|
63
64
|
timer: Timer
|
|
64
65
|
dbs: DatabasePool
|
|
@@ -97,6 +98,7 @@ class RuntimeContext:
|
|
|
97
98
|
manifest: RunManifest | None
|
|
98
99
|
halt_error: ErrInfo | None
|
|
99
100
|
halt_output_error: ErrInfo | None
|
|
101
|
+
cd_from: str | None
|
|
100
102
|
counters: CounterVars | None
|
|
101
103
|
timer: Timer | None
|
|
102
104
|
dbs: DatabasePool | None
|
execsql/utils/errors.py
CHANGED
|
@@ -6,8 +6,6 @@ Error handling and reporting utilities for execsql.
|
|
|
6
6
|
Provides the functions that form the error-reporting pipeline used
|
|
7
7
|
throughout the codebase:
|
|
8
8
|
|
|
9
|
-
- :func:`exception_info` — extracts type, value, and source location
|
|
10
|
-
from the current exception.
|
|
11
9
|
- :func:`exception_desc` — ``Type: message`` for the exception being
|
|
12
10
|
handled, as shown on screen.
|
|
13
11
|
- :func:`error_origin` / :func:`logged_error` — where in execsql an error
|
|
@@ -28,7 +26,6 @@ import execsql.state as _state
|
|
|
28
26
|
from execsql.exceptions import ErrInfo
|
|
29
27
|
|
|
30
28
|
__all__ = [
|
|
31
|
-
"exception_info",
|
|
32
29
|
"error_origin",
|
|
33
30
|
"exception_desc",
|
|
34
31
|
"logged_error",
|
|
@@ -56,43 +53,29 @@ def install_hint(extra: str) -> str:
|
|
|
56
53
|
)
|
|
57
54
|
|
|
58
55
|
|
|
59
|
-
def exception_info() -> tuple:
|
|
60
|
-
# Returns the exception type, value, source file name, source line number, and source line text.
|
|
61
|
-
strace = traceback.extract_tb(sys.exc_info()[2])[-1:]
|
|
62
|
-
traces = traceback.extract_tb(sys.exc_info()[2])
|
|
63
|
-
xline = 0
|
|
64
|
-
for trace in traces:
|
|
65
|
-
if "execsql" in trace[0]:
|
|
66
|
-
xline = trace[1]
|
|
67
|
-
exc_message = ""
|
|
68
|
-
exc_param = sys.exc_info()[1]
|
|
69
|
-
if isinstance(exc_param, str):
|
|
70
|
-
exc_message = exc_param
|
|
71
|
-
elif exc_param is not None:
|
|
72
|
-
message = getattr(exc_param, "message", None)
|
|
73
|
-
value = getattr(exc_param, "value", None)
|
|
74
|
-
if isinstance(message, str) and len(message) > 0:
|
|
75
|
-
exc_message = message
|
|
76
|
-
elif isinstance(value, str) and len(value) > 0:
|
|
77
|
-
exc_message = value
|
|
78
|
-
else:
|
|
79
|
-
exc_message = str(exc_param)
|
|
80
|
-
try:
|
|
81
|
-
exc_message = str(exc_message)
|
|
82
|
-
except Exception:
|
|
83
|
-
exc_message = repr(exc_message)
|
|
84
|
-
xinfo = sys.exc_info()[0]
|
|
85
|
-
xname = getattr(xinfo, "__name__", "")
|
|
86
|
-
return xname, exc_message, strace[0][0], xline, strace[0][3]
|
|
87
|
-
|
|
88
|
-
|
|
89
56
|
def exception_desc() -> str:
|
|
90
57
|
"""``Type: message`` for the exception being handled, as shown to the person running the script.
|
|
91
58
|
|
|
92
|
-
|
|
59
|
+
The message is the exception's ``message`` or ``value`` attribute when it
|
|
60
|
+
has a non-empty string one (as some drivers' exceptions do), else its
|
|
61
|
+
``str()``. Where in execsql it was raised is for the log: see
|
|
62
|
+
:func:`error_origin`.
|
|
93
63
|
"""
|
|
94
|
-
exc_type,
|
|
95
|
-
|
|
64
|
+
exc_type, exc = sys.exc_info()[:2]
|
|
65
|
+
if isinstance(exc, str):
|
|
66
|
+
message = exc
|
|
67
|
+
elif exc is None:
|
|
68
|
+
message = ""
|
|
69
|
+
else:
|
|
70
|
+
attr = next(
|
|
71
|
+
(v for v in (getattr(exc, "message", None), getattr(exc, "value", None)) if isinstance(v, str) and v),
|
|
72
|
+
None,
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
message = attr if attr is not None else str(exc)
|
|
76
|
+
except Exception:
|
|
77
|
+
message = repr(exc)
|
|
78
|
+
return f"{getattr(exc_type, '__name__', '')}: {message}"
|
|
96
79
|
|
|
97
80
|
|
|
98
81
|
def error_origin(exc: BaseException) -> str | None:
|