execsql2 2.28.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/base.py +6 -9
- execsql/db/duckdb.py +96 -14
- execsql/db/mysql.py +19 -3
- execsql/db/postgres.py +1 -1
- execsql/exporters/xls.py +3 -2
- 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/script/variables.py +42 -10
- execsql/state.py +6 -3
- execsql/state.pyi +2 -0
- execsql/types.py +8 -0
- execsql/utils/errors.py +19 -36
- execsql/utils/fileio.py +44 -48
- execsql/utils/regex.py +0 -5
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/METADATA +2 -4
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/RECORD +56 -54
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.28.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/WHEEL +0 -0
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.28.0.dist-info → execsql2-2.30.0.dist-info}/licenses/NOTICE +0 -0
execsql/__main__.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
"""Entry point for ``python -m execsql``.
|
|
2
2
|
|
|
3
|
+
Calls :func:`execsql.cli._legacy_main`, so the package runs without the
|
|
4
|
+
installed ``execsql`` script.
|
|
3
5
|
"""
|
|
4
|
-
Entry point for ``python -m execsql``.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
invoked directly without going through the installed ``execsql2`` script.
|
|
8
|
-
"""
|
|
7
|
+
from __future__ import annotations
|
|
9
8
|
|
|
10
9
|
from execsql.cli import _legacy_main
|
|
11
10
|
|
execsql/api.py
CHANGED
|
@@ -594,6 +594,11 @@ def run(
|
|
|
594
594
|
pass
|
|
595
595
|
if ctx.tempfiles is not None:
|
|
596
596
|
ctx.tempfiles.remove_all()
|
|
597
|
+
if ctx.cd_from is not None: # the script ran CD: the caller's directory is theirs
|
|
598
|
+
try:
|
|
599
|
+
os.chdir(ctx.cd_from)
|
|
600
|
+
except OSError:
|
|
601
|
+
pass # it no longer exists
|
|
597
602
|
|
|
598
603
|
return ScriptResult(
|
|
599
604
|
success=len(errors) == 0,
|
execsql/cli/__init__.py
CHANGED
|
@@ -22,31 +22,20 @@ from pathlib import Path
|
|
|
22
22
|
|
|
23
23
|
from execsql.cli import commands # noqa: F401 — registers every command on app
|
|
24
24
|
from execsql.cli.application import app
|
|
25
|
-
from execsql.cli.
|
|
26
|
-
from execsql.cli.
|
|
27
|
-
from execsql.cli.help import _console, _err_console, _init_config, _print_encodings, _print_metacommands # noqa: F401 — re-export
|
|
28
|
-
from execsql.cli.run import _connect_initial_db, _run # noqa: F401 — re-export
|
|
29
|
-
from execsql.cli.run import _end_run_on_sigterm
|
|
25
|
+
from execsql.cli.dsn import _parse_connection_string # noqa: F401 — re-export
|
|
26
|
+
from execsql.cli.run import _end_run_on_sigterm, _run # noqa: F401 — re-export
|
|
30
27
|
from execsql.exceptions import ConfigError, ConfigFileError, ErrInfo
|
|
31
28
|
|
|
32
29
|
__all__ = [
|
|
33
|
-
"_SCHEME_TO_DBTYPE",
|
|
34
|
-
"_connect_initial_db",
|
|
35
|
-
"_console",
|
|
36
|
-
"_err_console",
|
|
37
|
-
"_init_config",
|
|
38
30
|
"_legacy_main",
|
|
39
31
|
"_parse_connection_string",
|
|
40
|
-
"_print_encodings",
|
|
41
|
-
"_print_metacommands",
|
|
42
32
|
"_run",
|
|
43
33
|
"app",
|
|
44
|
-
"main",
|
|
45
34
|
]
|
|
46
35
|
|
|
47
36
|
|
|
48
37
|
# ---------------------------------------------------------------------------
|
|
49
|
-
#
|
|
38
|
+
# Entry point for ``python -m execsql`` (the ``execsql`` script is cli.dispatch:dispatch)
|
|
50
39
|
# ---------------------------------------------------------------------------
|
|
51
40
|
|
|
52
41
|
|
execsql/cli/application.py
CHANGED
|
@@ -6,6 +6,7 @@ Every command registers itself on :data:`app` from a module in
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import sys
|
|
9
10
|
from collections.abc import Iterator
|
|
10
11
|
from contextlib import contextmanager
|
|
11
12
|
from typing import Any
|
|
@@ -225,7 +226,9 @@ class ExecsqlGroup(TyperGroup):
|
|
|
225
226
|
self.format_commands(ctx, formatter)
|
|
226
227
|
|
|
227
228
|
def format_commands(self, ctx: Any, formatter: Any) -> None:
|
|
228
|
-
# Click's own version, with the command names colored
|
|
229
|
+
# Click's own version, with the command names colored and each
|
|
230
|
+
# summary (the first sentence of its help) whole: Click would cut it
|
|
231
|
+
# to fit the terminal with "...", write_dl wraps it instead.
|
|
229
232
|
commands = [
|
|
230
233
|
(name, cmd)
|
|
231
234
|
for name in self.list_commands(ctx)
|
|
@@ -233,10 +236,9 @@ class ExecsqlGroup(TyperGroup):
|
|
|
233
236
|
]
|
|
234
237
|
if not commands:
|
|
235
238
|
return
|
|
236
|
-
limit = formatter.width - 6 - max(len(name) for name, _ in commands)
|
|
237
239
|
with _section(formatter, "Commands"):
|
|
238
240
|
formatter.write_dl(
|
|
239
|
-
[(_style(name, fg="cyan", bold=True), cmd.get_short_help_str(
|
|
241
|
+
[(_style(name, fg="cyan", bold=True), cmd.get_short_help_str(sys.maxsize)) for name, cmd in commands],
|
|
240
242
|
)
|
|
241
243
|
|
|
242
244
|
|
execsql/cli/commands/lint.py
CHANGED
|
@@ -93,7 +93,10 @@ def lint_paths(
|
|
|
93
93
|
except OSError as exc:
|
|
94
94
|
found = [_issue("P001", label, 0, f"cannot read: {exc.strerror or exc}")]
|
|
95
95
|
else:
|
|
96
|
-
|
|
96
|
+
try:
|
|
97
|
+
found = lint_source(source, label, script_path=None if stdin else label)
|
|
98
|
+
except Exception as exc: # one script lint cannot check must not hide the others' findings
|
|
99
|
+
found = [_issue("P001", label, 0, f"execsql could not check this script ({type(exc).__name__}: {exc})")]
|
|
97
100
|
per_file.append((label, sorted(filter_issues(found, select, ignore), key=lambda i: (i.line, i.code))))
|
|
98
101
|
|
|
99
102
|
reported = [issue for _, issues in per_file for issue in issues]
|
execsql/cli/commands/lsp.py
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
5
9
|
import typer
|
|
6
10
|
from rich.markup import escape
|
|
7
11
|
|
|
@@ -28,6 +32,29 @@ def _pygls_version() -> str:
|
|
|
28
32
|
return "unknown"
|
|
29
33
|
|
|
30
34
|
|
|
35
|
+
class LogLevel(str, Enum):
|
|
36
|
+
error = "error"
|
|
37
|
+
warning = "warning"
|
|
38
|
+
info = "info"
|
|
39
|
+
debug = "debug"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _configure_logging(level: LogLevel, log_file: Path | None) -> None:
|
|
43
|
+
"""Send the server's log (execsql's and pygls's) to stderr, which editors show, or to *log_file*.
|
|
44
|
+
|
|
45
|
+
stdout carries the protocol, so nothing may be logged there.
|
|
46
|
+
"""
|
|
47
|
+
handler: logging.Handler = (
|
|
48
|
+
logging.FileHandler(log_file, encoding="utf-8") if log_file is not None else logging.StreamHandler()
|
|
49
|
+
)
|
|
50
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
51
|
+
root = logging.getLogger()
|
|
52
|
+
root.addHandler(handler)
|
|
53
|
+
root.setLevel(level.value.upper())
|
|
54
|
+
# pygls logs every message it sends at info, script text included: only debug shows them.
|
|
55
|
+
logging.getLogger("pygls").setLevel(logging.DEBUG if level is LogLevel.debug else logging.WARNING)
|
|
56
|
+
|
|
57
|
+
|
|
31
58
|
@app.command(
|
|
32
59
|
cls=ExecsqlCommand,
|
|
33
60
|
name="lsp",
|
|
@@ -43,6 +70,20 @@ def lsp_cmd(
|
|
|
43
70
|
# Editors' language clients pass --stdio (VS Code always does); stdio is the only transport.
|
|
44
71
|
stdio: bool = typer.Option(False, "--stdio", hidden=True),
|
|
45
72
|
check: bool = typer.Option(False, "--check", help="Report whether the language server can start, and exit."),
|
|
73
|
+
log_level: LogLevel = typer.Option(
|
|
74
|
+
LogLevel.warning,
|
|
75
|
+
"--log-level",
|
|
76
|
+
envvar="EXECSQL_LSP_LOG_LEVEL",
|
|
77
|
+
case_sensitive=False,
|
|
78
|
+
help="How much the server logs: error, warning, info (adds a startup record and each folder's config files) "
|
|
79
|
+
"or debug (adds every protocol message).",
|
|
80
|
+
),
|
|
81
|
+
log_file: Path | None = typer.Option(
|
|
82
|
+
None,
|
|
83
|
+
"--log-file",
|
|
84
|
+
dir_okay=False,
|
|
85
|
+
help="Append the log to this file instead of stderr, for an editor that does not show the server's stderr.",
|
|
86
|
+
),
|
|
46
87
|
) -> None:
|
|
47
88
|
"""The ``lsp`` command; its user-facing help is the decorator's ``help``."""
|
|
48
89
|
try:
|
|
@@ -61,4 +102,5 @@ def lsp_cmd(
|
|
|
61
102
|
if check:
|
|
62
103
|
typer.echo(f"execsql lsp is ready (pygls {_pygls_version()}).")
|
|
63
104
|
return
|
|
105
|
+
_configure_logging(log_level, log_file)
|
|
64
106
|
create_server().start_io()
|
execsql/cli/dsn.py
CHANGED
|
@@ -50,7 +50,9 @@ def _parse_connection_string(dsn: str) -> dict:
|
|
|
50
50
|
parsed = urlparse(dsn)
|
|
51
51
|
scheme = parsed.scheme.lower()
|
|
52
52
|
if not scheme:
|
|
53
|
-
|
|
53
|
+
# Echoed so the user sees what went wrong, but never with its password.
|
|
54
|
+
shown = dsn.replace(f":{parsed.password}@", ":***@") if parsed.password else dsn
|
|
55
|
+
raise ConfigError(f"Cannot parse connection string (no scheme): {shown!r}")
|
|
54
56
|
if scheme not in _SCHEME_TO_DBTYPE:
|
|
55
57
|
raise ConfigError(
|
|
56
58
|
f"Unrecognised connection-string scheme {scheme!r}. "
|
execsql/cli/lint.py
CHANGED
|
@@ -471,7 +471,7 @@ def _read_ini_vars(
|
|
|
471
471
|
if stat.st_size > _MAX_INI_BYTES:
|
|
472
472
|
return
|
|
473
473
|
resolved = str(p.resolve())
|
|
474
|
-
except OSError:
|
|
474
|
+
except (OSError, ValueError): # unreadable, or a name the OS rejects
|
|
475
475
|
return
|
|
476
476
|
defined_vars.update(_ini_section_keys(resolved, stat.st_size, stat.st_mtime_ns, section))
|
|
477
477
|
|
|
@@ -587,7 +587,12 @@ def _check_include_path(
|
|
|
587
587
|
if not p.is_absolute() and script_dir is not None:
|
|
588
588
|
p = script_dir / p
|
|
589
589
|
|
|
590
|
-
|
|
590
|
+
try:
|
|
591
|
+
exists = p.exists()
|
|
592
|
+
except OSError as exc: # a name the file system rejects outright (too long, ...)
|
|
593
|
+
issues.append(_issue("I001", source, line_no, f"INCLUDE path cannot be checked: {exc.strerror or exc}"))
|
|
594
|
+
return
|
|
595
|
+
if not exists:
|
|
591
596
|
issues.append(
|
|
592
597
|
_issue("I001", source, line_no, f"INCLUDE file does not exist: {raw_path}"),
|
|
593
598
|
)
|
|
@@ -701,7 +706,7 @@ _RX_ANY_VAR = re.compile(r"!(['\"]?)![$&@~#+]?\w+!\1!|!\{[$&@~#+]?\w+\}!")
|
|
|
701
706
|
_PLACEHOLDERS = ("TRUE", "1", "x")
|
|
702
707
|
|
|
703
708
|
|
|
704
|
-
def _with_placeholders(text: str, value: str
|
|
709
|
+
def _with_placeholders(text: str, value: str) -> str:
|
|
705
710
|
"""Replace each substitution variable with the literal *value*."""
|
|
706
711
|
return _RX_ANY_VAR.sub(value, text)
|
|
707
712
|
|
|
@@ -997,6 +1002,7 @@ def _lint_nodes(
|
|
|
997
1002
|
def lint(
|
|
998
1003
|
script: Script,
|
|
999
1004
|
script_path: str | None = None,
|
|
1005
|
+
base_dir: str | None = None,
|
|
1000
1006
|
) -> list[_Issue]:
|
|
1001
1007
|
"""Perform static analysis on an AST-parsed script.
|
|
1002
1008
|
|
|
@@ -1004,6 +1010,8 @@ def lint(
|
|
|
1004
1010
|
script: The parsed :class:`Script` tree.
|
|
1005
1011
|
script_path: Path to the source file (for resolving relative
|
|
1006
1012
|
INCLUDE paths). ``None`` for inline scripts.
|
|
1013
|
+
base_dir: Where an inline script's relative paths resolve, in place
|
|
1014
|
+
of the working directory.
|
|
1007
1015
|
|
|
1008
1016
|
Returns:
|
|
1009
1017
|
Every :class:`Issue` found, unfiltered.
|
|
@@ -1014,7 +1022,7 @@ def lint(
|
|
|
1014
1022
|
issues.append(_issue("S001", script_path or script.source, 0, "script is empty"))
|
|
1015
1023
|
return issues
|
|
1016
1024
|
|
|
1017
|
-
script_dir = Path(script_path).resolve().parent if script_path else None
|
|
1025
|
+
script_dir = Path(script_path).resolve().parent if script_path else (Path(base_dir) if base_dir else None)
|
|
1018
1026
|
script_blocks = _collect_script_blocks(script)
|
|
1019
1027
|
|
|
1020
1028
|
# Pass 1: collect all variable definitions
|
|
@@ -1036,7 +1044,7 @@ def lint(
|
|
|
1036
1044
|
return issues
|
|
1037
1045
|
|
|
1038
1046
|
|
|
1039
|
-
def lint_source(source: str, label: str, script_path: str | None = None) -> list[_Issue]:
|
|
1047
|
+
def lint_source(source: str, label: str, script_path: str | None = None, base_dir: str | None = None) -> list[_Issue]:
|
|
1040
1048
|
"""Parse *source* and lint it, unfiltered.
|
|
1041
1049
|
|
|
1042
1050
|
Each block-structure error (an IF without ENDIF, a stray END LOOP, ...)
|
|
@@ -1047,7 +1055,7 @@ def lint_source(source: str, label: str, script_path: str | None = None) -> list
|
|
|
1047
1055
|
|
|
1048
1056
|
errors: list[ErrInfo] = []
|
|
1049
1057
|
tree = parse_string(source, label, errors=errors)
|
|
1050
|
-
return [parse_error(label, exc) for exc in errors] + lint(tree, script_path=script_path)
|
|
1058
|
+
return [parse_error(label, exc) for exc in errors] + lint(tree, script_path=script_path, base_dir=base_dir)
|
|
1051
1059
|
|
|
1052
1060
|
|
|
1053
1061
|
# ---------------------------------------------------------------------------
|
execsql/cli/run.py
CHANGED
|
@@ -233,11 +233,12 @@ _SENSITIVE_SUBSTRINGS = (
|
|
|
233
233
|
)
|
|
234
234
|
|
|
235
235
|
|
|
236
|
-
def _seed_early_subvars() -> SubVarSet:
|
|
236
|
+
def _seed_early_subvars(start_dir: str | None = None) -> SubVarSet:
|
|
237
237
|
"""Create and populate the initial substitution variable pool.
|
|
238
238
|
|
|
239
239
|
Seeds environment variables (filtering secrets), timestamps, system info,
|
|
240
240
|
and placeholder variables. Called once at the start of :func:`_run`.
|
|
241
|
+
``$STARTING_PATH`` is *start_dir*, or the working directory.
|
|
241
242
|
"""
|
|
242
243
|
subvars = SubVarSet()
|
|
243
244
|
|
|
@@ -262,7 +263,7 @@ def _seed_early_subvars() -> SubVarSet:
|
|
|
262
263
|
subvars.add_substitution("$LAST_ERROR", "")
|
|
263
264
|
subvars.add_substitution("$ERROR_MESSAGE", "")
|
|
264
265
|
subvars.add_substitution("$USER", getpass.getuser())
|
|
265
|
-
subvars.add_substitution("$STARTING_PATH", os.getcwd() + os.sep)
|
|
266
|
+
subvars.add_substitution("$STARTING_PATH", (start_dir or os.getcwd()) + os.sep)
|
|
266
267
|
subvars.add_substitution("$PATHSEP", os.sep)
|
|
267
268
|
osys = sys.platform
|
|
268
269
|
if osys.startswith("linux"):
|
|
@@ -279,23 +280,30 @@ def _load_config(
|
|
|
279
280
|
script_name: str | None,
|
|
280
281
|
subvars: SubVarSet,
|
|
281
282
|
config_file: str | None,
|
|
283
|
+
start_dir: str | None = None,
|
|
282
284
|
) -> ConfigData:
|
|
283
285
|
"""Load and merge configuration files for the given script.
|
|
284
286
|
|
|
287
|
+
*start_dir* stands in for the working directory (see :class:`ConfigData`).
|
|
285
288
|
Returns a fully populated :class:`ConfigData` instance.
|
|
286
289
|
"""
|
|
287
|
-
|
|
288
|
-
|
|
290
|
+
if script_name:
|
|
291
|
+
script_path = str(Path(script_name).resolve().parent)
|
|
292
|
+
else: # resolved, as os.getcwd() is, so the start directory's execsql.conf is not read twice
|
|
293
|
+
script_path = str(Path(start_dir).resolve()) if start_dir else os.getcwd()
|
|
294
|
+
return ConfigData(script_path, subvars, config_file=config_file, start_dir=start_dir)
|
|
289
295
|
|
|
290
296
|
|
|
291
|
-
def _tool_config(config_file: str | None) -> ConfigData:
|
|
297
|
+
def _tool_config(config_file: str | None, start_dir: str | None = None) -> ConfigData:
|
|
292
298
|
"""The config ``execsql format`` and ``execsql lint`` read.
|
|
293
299
|
|
|
294
300
|
Read once per invocation, from the system, user and working-directory
|
|
295
301
|
locations plus *config_file* — not per script, so a script directory's
|
|
296
|
-
own ``execsql.conf`` applies only when passed with ``--config``.
|
|
302
|
+
own ``execsql.conf`` applies only when passed with ``--config``. The
|
|
303
|
+
language server passes a workspace folder as *start_dir*: the settings
|
|
304
|
+
those commands would read when run from it.
|
|
297
305
|
"""
|
|
298
|
-
return _load_config(None, _seed_early_subvars(), config_file)
|
|
306
|
+
return _load_config(None, _seed_early_subvars(start_dir), config_file, start_dir)
|
|
299
307
|
|
|
300
308
|
|
|
301
309
|
def _configured_script_encoding(conf: ConfigData) -> str | None:
|
|
@@ -561,7 +569,7 @@ def _setup_logging(
|
|
|
561
569
|
for n, repl in enumerate(sub_vars):
|
|
562
570
|
var = f"$ARG_{n + 1}"
|
|
563
571
|
subvars.add_substitution(var, repl)
|
|
564
|
-
logger.add_redaction_value(repl)
|
|
572
|
+
logger.add_redaction_value(repl, argument=True)
|
|
565
573
|
# `-a` values are positional and opaque (no name to denylist
|
|
566
574
|
# against). High-entropy secrets (sk-live-*, AKIA*, ghp_*,
|
|
567
575
|
# JWTs) pass any substring/value heuristic, so log the
|
|
@@ -574,7 +582,7 @@ def _setup_logging(
|
|
|
574
582
|
# redacted for the same reason as -a's.
|
|
575
583
|
for name, value in named_vars or ():
|
|
576
584
|
subvars.add_substitution(name, value)
|
|
577
|
-
logger.add_redaction_value(value)
|
|
585
|
+
logger.add_redaction_value(value, argument=True)
|
|
578
586
|
logger.log_status_info(f"Command-line substitution variable assignment: {name} set to {{***}}")
|
|
579
587
|
|
|
580
588
|
return logger
|
execsql/config.py
CHANGED
|
@@ -401,6 +401,7 @@ class ConfigData:
|
|
|
401
401
|
variable_pool: _VariablePool,
|
|
402
402
|
*,
|
|
403
403
|
config_file: str | None = None,
|
|
404
|
+
start_dir: str | None = None,
|
|
404
405
|
) -> None:
|
|
405
406
|
"""Load and merge all discoverable execsql.conf files for the given script path.
|
|
406
407
|
|
|
@@ -414,12 +415,18 @@ class ConfigData:
|
|
|
414
415
|
Loaded after the implicit search paths so its values take
|
|
415
416
|
precedence over system, user, script, and working-directory
|
|
416
417
|
config files.
|
|
418
|
+
start_dir: The directory a run starts in: its ``execsql.conf`` is
|
|
419
|
+
read, and relative ``config_file`` paths (``--config`` and
|
|
420
|
+
``[config]`` chains) resolve against it. Defaults to the
|
|
421
|
+
working directory. The language server passes each
|
|
422
|
+
workspace folder, so it never changes the process's directory.
|
|
417
423
|
"""
|
|
424
|
+
self._start_dir = start_dir
|
|
418
425
|
self._set_defaults()
|
|
419
426
|
current_script = str(Path(sys.argv[0]).resolve())
|
|
420
427
|
from collections import deque
|
|
421
428
|
|
|
422
|
-
config_queue: deque[str] = deque(self._search_paths(script_path, config_file))
|
|
429
|
+
config_queue: deque[str] = deque(self._search_paths(script_path, config_file, start_dir))
|
|
423
430
|
self.files_read: list = []
|
|
424
431
|
# Warm the schema registry by running the option reader against an
|
|
425
432
|
# empty ConfigParser. cp.has_option() is False for everything so no
|
|
@@ -455,6 +462,9 @@ class ConfigData:
|
|
|
455
462
|
if cp.has_option(section, key):
|
|
456
463
|
self.sources[attr] = configfile
|
|
457
464
|
|
|
465
|
+
#: Where relative paths resolve; ``None`` is the working directory.
|
|
466
|
+
_start_dir: str | None = None
|
|
467
|
+
|
|
458
468
|
#: Guard against circular config_file references.
|
|
459
469
|
_MAX_CONFIG_CHAIN = 20
|
|
460
470
|
|
|
@@ -562,7 +572,7 @@ class ConfigData:
|
|
|
562
572
|
self.lint_strict = False
|
|
563
573
|
|
|
564
574
|
@classmethod
|
|
565
|
-
def _search_paths(cls, script_path: str, config_file: str | None) -> list[str]:
|
|
575
|
+
def _search_paths(cls, script_path: str, config_file: str | None, start_dir: str | None = None) -> list[str]:
|
|
566
576
|
"""The files a run reads, lowest precedence first: system, user, script dir, working dir, ``--config``.
|
|
567
577
|
|
|
568
578
|
Files that ``[config] config_file`` and its OS-specific variants chain
|
|
@@ -574,17 +584,25 @@ class ConfigData:
|
|
|
574
584
|
sys_config_file = str(Path(os.path.expandvars(r"%APPDATA%")) / cls.config_file_name)
|
|
575
585
|
user_config_file = str(Path("~/.config").expanduser() / cls.config_file_name)
|
|
576
586
|
script_config_file = str(Path(script_path) / cls.config_file_name)
|
|
577
|
-
|
|
587
|
+
start = Path(start_dir or ".")
|
|
588
|
+
startdir_config_file = str(start.resolve() / cls.config_file_name)
|
|
578
589
|
if startdir_config_file != script_config_file:
|
|
579
590
|
config_files = [sys_config_file, user_config_file, script_config_file, startdir_config_file]
|
|
580
591
|
else:
|
|
581
592
|
config_files = [sys_config_file, user_config_file, startdir_config_file]
|
|
582
593
|
if config_file:
|
|
583
|
-
config_files.append(str(
|
|
594
|
+
config_files.append(str((start / config_file).resolve()))
|
|
584
595
|
return config_files
|
|
585
596
|
|
|
586
597
|
@classmethod
|
|
587
|
-
def _chain_target(
|
|
598
|
+
def _chain_target(
|
|
599
|
+
cls,
|
|
600
|
+
value: str,
|
|
601
|
+
variable_pool: _VariablePool,
|
|
602
|
+
*,
|
|
603
|
+
expand_home: bool,
|
|
604
|
+
start_dir: str | None = None,
|
|
605
|
+
) -> str:
|
|
588
606
|
"""Resolve a ``config_file``-style value to the file it names.
|
|
589
607
|
|
|
590
608
|
A leading ``~`` is expanded when *expand_home* is true, substitution
|
|
@@ -598,6 +616,8 @@ class ConfigData:
|
|
|
598
616
|
elif len(conffile) > 1 and conffile[1] == os.sep:
|
|
599
617
|
conffile = str(Path("~").expanduser() / conffile[2:])
|
|
600
618
|
conffile = variable_pool.substitute(conffile)[0]
|
|
619
|
+
if start_dir and not Path(conffile).is_absolute():
|
|
620
|
+
conffile = str(Path(start_dir) / conffile)
|
|
601
621
|
if not Path(conffile).is_file():
|
|
602
622
|
conffile = str(Path(conffile) / cls.config_file_name)
|
|
603
623
|
return conffile
|
|
@@ -651,6 +671,7 @@ class ConfigData:
|
|
|
651
671
|
cp.get(self._CONFIG_SECTION, "config_file"),
|
|
652
672
|
variable_pool,
|
|
653
673
|
expand_home=os.name == "posix",
|
|
674
|
+
start_dir=self._start_dir,
|
|
654
675
|
)
|
|
655
676
|
if Path(conffile).is_file():
|
|
656
677
|
# Silently ignore a non-existent file, for cross-OS compatibility.
|
|
@@ -664,7 +685,12 @@ class ConfigData:
|
|
|
664
685
|
elif os.name == "nt" and cp.has_option(self._CONFIG_SECTION, "win_config_file"):
|
|
665
686
|
_os_config_key = "win_config_file"
|
|
666
687
|
if _os_config_key:
|
|
667
|
-
conffile = self._chain_target(
|
|
688
|
+
conffile = self._chain_target(
|
|
689
|
+
cp.get(self._CONFIG_SECTION, _os_config_key),
|
|
690
|
+
variable_pool,
|
|
691
|
+
expand_home=True,
|
|
692
|
+
start_dir=self._start_dir,
|
|
693
|
+
)
|
|
668
694
|
if Path(conffile).is_file():
|
|
669
695
|
chained.append(conffile)
|
|
670
696
|
# dao_flush_delay_secs has a specific error message — keep inline
|
execsql/db/base.py
CHANGED
|
@@ -215,21 +215,18 @@ class Database(ABC):
|
|
|
215
215
|
def quote_literal(self, value: Any) -> str:
|
|
216
216
|
"""Return *value* as a SQL string literal, safely escaped.
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
Override per-DBMS only when the wire protocol requires a different
|
|
225
|
-
literal form.
|
|
218
|
+
Standard SQL: wrap in single quotes and double embedded apostrophes;
|
|
219
|
+
a backslash is an ordinary character. Embedded NUL bytes are
|
|
220
|
+
rejected (most wire protocols truncate or reject them). A DBMS
|
|
221
|
+
that reads a backslash in a literal as an escape (MySQL) overrides
|
|
222
|
+
this.
|
|
226
223
|
"""
|
|
227
224
|
if value is None:
|
|
228
225
|
return "NULL"
|
|
229
226
|
s = str(value)
|
|
230
227
|
if "\x00" in s:
|
|
231
228
|
raise ValueError("SQL literal contains a NUL byte; refusing to quote.")
|
|
232
|
-
return "'" + s.replace("
|
|
229
|
+
return "'" + s.replace("'", "''") + "'"
|
|
233
230
|
|
|
234
231
|
def paramsubs(self, paramcount: int) -> str:
|
|
235
232
|
"""Return a comma-separated string of *paramcount* parameter placeholders."""
|
execsql/db/duckdb.py
CHANGED
|
@@ -9,6 +9,7 @@ on the CLI.
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import re
|
|
12
|
+
from collections import deque
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
@@ -28,21 +29,83 @@ _TRANSACTION_RX = re.compile(r"(?P<begin>BEGIN|START\s+TRANSACTION)\b|(?P<end>CO
|
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
class _SharedCursor:
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
A DuckDB cursor is a separate connection
|
|
34
|
-
|
|
35
|
-
the connection
|
|
32
|
+
"""A cursor that runs on the database's one connection.
|
|
33
|
+
|
|
34
|
+
A DuckDB cursor is a separate connection: a temporary table created on
|
|
35
|
+
one is invisible to the next, and a cursor's transaction is its own. So
|
|
36
|
+
every statement runs on the connection itself. A connection holds one
|
|
37
|
+
pending result, though, and the next statement discards it; COPY reads
|
|
38
|
+
its source twice at once (the rows, and a scan for their types). So
|
|
39
|
+
before another statement runs, the result this cursor still has to
|
|
40
|
+
deliver is read into memory (:meth:`DuckDBDatabase._take_connection`).
|
|
41
|
+
An EXPORT, which runs nothing else while it reads, still streams.
|
|
36
42
|
"""
|
|
37
43
|
|
|
38
|
-
def __init__(self,
|
|
39
|
-
self.
|
|
44
|
+
def __init__(self, db: DuckDBDatabase) -> None:
|
|
45
|
+
self._db = db
|
|
46
|
+
self._conn = db.conn
|
|
47
|
+
self._buffer: deque[Any] | None = None # the rest of the result, once read ahead
|
|
48
|
+
self._pending = False # a result the connection still holds for this cursor
|
|
49
|
+
self.description: Any = None
|
|
50
|
+
self.arraysize = 1000
|
|
40
51
|
|
|
41
52
|
def __getattr__(self, name: str) -> Any:
|
|
42
53
|
return getattr(self._conn, name)
|
|
43
54
|
|
|
55
|
+
def execute(self, sql: Any, params: Any = None) -> _SharedCursor:
|
|
56
|
+
self._db._take_connection(self)
|
|
57
|
+
if params is None:
|
|
58
|
+
self._conn.execute(sql)
|
|
59
|
+
else:
|
|
60
|
+
self._conn.execute(sql, params)
|
|
61
|
+
self.description = self._conn.description
|
|
62
|
+
self._buffer = None
|
|
63
|
+
self._pending = self.description is not None
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def executemany(self, sql: Any, params: Any) -> _SharedCursor:
|
|
67
|
+
self._db._take_connection(self)
|
|
68
|
+
self._conn.executemany(sql, params)
|
|
69
|
+
self.description = self._conn.description
|
|
70
|
+
self._buffer = None
|
|
71
|
+
self._pending = False
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def read_ahead(self) -> None:
|
|
75
|
+
"""Move the rest of the pending result into memory, freeing the connection."""
|
|
76
|
+
if self._pending:
|
|
77
|
+
self._buffer = deque(self._conn.fetchall())
|
|
78
|
+
self._pending = False
|
|
79
|
+
|
|
80
|
+
def fetchmany(self, size: int | None = None) -> list[Any]:
|
|
81
|
+
size = size or self.arraysize
|
|
82
|
+
if self._buffer is not None:
|
|
83
|
+
return [self._buffer.popleft() for _ in range(min(size, len(self._buffer)))]
|
|
84
|
+
if not self._pending:
|
|
85
|
+
return []
|
|
86
|
+
rows: list[Any] = self._conn.fetchmany(size)
|
|
87
|
+
if not rows:
|
|
88
|
+
self._pending = False
|
|
89
|
+
return rows
|
|
90
|
+
|
|
91
|
+
def fetchone(self) -> Any:
|
|
92
|
+
rows = self.fetchmany(1)
|
|
93
|
+
return rows[0] if rows else None
|
|
94
|
+
|
|
95
|
+
def fetchall(self) -> list[Any]:
|
|
96
|
+
if self._buffer is not None:
|
|
97
|
+
rows, self._buffer = list(self._buffer), deque()
|
|
98
|
+
return rows
|
|
99
|
+
if not self._pending:
|
|
100
|
+
return []
|
|
101
|
+
self._pending = False
|
|
102
|
+
return list(self._conn.fetchall())
|
|
103
|
+
|
|
44
104
|
def close(self) -> None:
|
|
45
|
-
|
|
105
|
+
"""Leave the connection, and any transaction on it, open."""
|
|
106
|
+
self._buffer = None
|
|
107
|
+
self._pending = False
|
|
108
|
+
self._db._release_connection(self)
|
|
46
109
|
|
|
47
110
|
|
|
48
111
|
class DuckDBDatabase(Database):
|
|
@@ -51,12 +114,15 @@ class DuckDBDatabase(Database):
|
|
|
51
114
|
The duckdb driver commits each statement unless a transaction was begun,
|
|
52
115
|
and has no way to ask whether one is open, so this adapter begins one
|
|
53
116
|
itself while statements are held (AUTOCOMMIT OFF, BEGIN BATCH) and keeps
|
|
54
|
-
track of it.
|
|
55
|
-
|
|
117
|
+
track of it. Every statement runs on the connection itself rather than
|
|
118
|
+
on a cursor (see :class:`_SharedCursor`), so it joins that transaction
|
|
119
|
+
and sees the run's temporary tables.
|
|
56
120
|
"""
|
|
57
121
|
|
|
58
122
|
#: A transaction this adapter or a script's own BEGIN opened, not yet ended.
|
|
59
123
|
in_transaction: bool = False
|
|
124
|
+
#: The cursor whose result the connection holds, if any.
|
|
125
|
+
_holder: _SharedCursor | None = None
|
|
60
126
|
|
|
61
127
|
def __init__(self, DuckDB_fn: str) -> None:
|
|
62
128
|
try:
|
|
@@ -83,12 +149,21 @@ class DuckDBDatabase(Database):
|
|
|
83
149
|
return f"DuckDBDatabase({self.db_name!r})"
|
|
84
150
|
|
|
85
151
|
def cursor(self) -> Any:
|
|
86
|
-
"""A cursor
|
|
152
|
+
"""A cursor on the database's one connection (see :class:`_SharedCursor`)."""
|
|
87
153
|
if self.conn is None:
|
|
88
154
|
self.open_db()
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
155
|
+
return _SharedCursor(self)
|
|
156
|
+
|
|
157
|
+
def _take_connection(self, cursor: _SharedCursor | None = None) -> None:
|
|
158
|
+
"""Before a statement runs, read ahead the result another cursor has not finished with."""
|
|
159
|
+
holder = self._holder
|
|
160
|
+
if holder is not None and holder is not cursor:
|
|
161
|
+
holder.read_ahead()
|
|
162
|
+
self._holder = cursor
|
|
163
|
+
|
|
164
|
+
def _release_connection(self, cursor: _SharedCursor) -> None:
|
|
165
|
+
if self._holder is cursor:
|
|
166
|
+
self._holder = None
|
|
92
167
|
|
|
93
168
|
def begin_transaction(self) -> None:
|
|
94
169
|
"""Open a transaction if none is open."""
|
|
@@ -96,6 +171,7 @@ class DuckDBDatabase(Database):
|
|
|
96
171
|
self.open_db()
|
|
97
172
|
if not self.in_transaction:
|
|
98
173
|
# A second BEGIN would abort the open transaction, hence the tracking.
|
|
174
|
+
self._take_connection()
|
|
99
175
|
self.conn.execute("BEGIN TRANSACTION")
|
|
100
176
|
self.in_transaction = True
|
|
101
177
|
|
|
@@ -124,17 +200,23 @@ class DuckDBDatabase(Database):
|
|
|
124
200
|
|
|
125
201
|
def commit(self) -> None:
|
|
126
202
|
"""Commit the open transaction if autocommit is enabled."""
|
|
203
|
+
self._take_connection()
|
|
127
204
|
super().commit()
|
|
128
205
|
if self.autocommit:
|
|
129
206
|
self.in_transaction = False
|
|
130
207
|
|
|
131
208
|
def rollback(self) -> None:
|
|
132
209
|
"""Roll back the open transaction."""
|
|
210
|
+
try:
|
|
211
|
+
self._take_connection()
|
|
212
|
+
except Exception: # best effort, as the rollback is: a failed transaction may not give up its rows
|
|
213
|
+
self._holder = None
|
|
133
214
|
super().rollback()
|
|
134
215
|
self.in_transaction = False
|
|
135
216
|
|
|
136
217
|
def close(self) -> None:
|
|
137
218
|
"""Close the connection; an open transaction is rolled back."""
|
|
219
|
+
self._holder = None
|
|
138
220
|
super().close()
|
|
139
221
|
self.in_transaction = False
|
|
140
222
|
|