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.
Files changed (50) hide show
  1. execsql/__main__.py +4 -5
  2. execsql/api.py +5 -0
  3. execsql/cli/__init__.py +3 -14
  4. execsql/cli/application.py +5 -3
  5. execsql/cli/commands/lint.py +4 -1
  6. execsql/cli/commands/lsp.py +42 -0
  7. execsql/cli/dsn.py +3 -1
  8. execsql/cli/lint.py +14 -6
  9. execsql/cli/run.py +17 -9
  10. execsql/config.py +32 -6
  11. execsql/db/duckdb.py +96 -14
  12. execsql/gui/__init__.py +3 -31
  13. execsql/lsp/_util.py +21 -0
  14. execsql/lsp/cache.py +73 -0
  15. execsql/lsp/code_actions.py +10 -14
  16. execsql/lsp/completion.py +8 -15
  17. execsql/lsp/diagnostics.py +14 -2
  18. execsql/lsp/document.py +139 -41
  19. execsql/lsp/hover.py +5 -13
  20. execsql/lsp/navigation.py +7 -10
  21. execsql/lsp/server.py +111 -29
  22. execsql/manifest.py +29 -9
  23. execsql/metacommands/__init__.py +0 -2
  24. execsql/metacommands/io_fileops.py +5 -0
  25. execsql/script/executor.py +2 -21
  26. execsql/state.py +6 -3
  27. execsql/state.pyi +2 -0
  28. execsql/utils/errors.py +19 -36
  29. execsql/utils/fileio.py +44 -48
  30. execsql/utils/regex.py +0 -5
  31. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/METADATA +2 -4
  32. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/RECORD +50 -48
  33. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/README.md +0 -0
  34. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  35. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  36. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  37. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  38. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  39. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  40. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  41. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  42. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  43. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/script_template.sql +0 -0
  44. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  45. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  46. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  47. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/WHEEL +0 -0
  48. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/entry_points.txt +0 -0
  49. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/licenses/LICENSE.txt +0 -0
  50. {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, read_included, same_file_key
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 and pending lint tasks."""
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
- execsql reads config files from the working directory, so each
72
- folder's settings are read from inside it. The server itself works
73
- from the workspace root: a file in no folder gets the settings read
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
- root = self.workspace.root_path
77
- if root and os.path.isdir(root):
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
- if folder is not None:
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 (rebuilt per request: a few milliseconds).
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 read_included(path)
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 publish_all(self) -> None:
184
- """Lint every open document again: a file one of them includes, or the config, changed."""
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.publish(uri)
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
- ls.publish_all()
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
- if any(Path(to_fs_path(change.uri) or "").name.lower() == "execsql.conf" for change in params.changes):
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
- ls.publish_all()
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.publish_all()
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
@@ -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}")
@@ -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
- these five attributes; everything is derived from ``node`` on access, so
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", "_source_dir")
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: _mp.Queue | None = None
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
- Where in execsql it was raised is for the log: see :func:`error_origin`.
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, exc_strval, _filename, _lineno, _linetext = exception_info()
95
- return f"{exc_type}: {exc_strval}"
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: