execsql2 2.29.0__py3-none-any.whl → 2.31.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- execsql/__main__.py +4 -5
- execsql/api.py +6 -0
- execsql/cli/__init__.py +3 -14
- execsql/cli/application.py +6 -4
- execsql/cli/commands/lint.py +5 -2
- execsql/cli/commands/lsp.py +42 -0
- execsql/cli/dsn.py +38 -2
- execsql/cli/lint.py +24 -7
- execsql/cli/run.py +22 -9
- execsql/config.py +58 -10
- execsql/data/execsql.conf.template +8 -2
- execsql/db/access.py +7 -5
- execsql/db/base.py +18 -0
- execsql/db/duckdb.py +96 -14
- execsql/db/factory.py +11 -1
- execsql/db/mysql.py +56 -3
- execsql/db/odbc.py +45 -0
- execsql/db/postgres.py +1 -1
- execsql/db/sqlserver.py +37 -21
- execsql/exporters/ods.py +146 -41
- execsql/exporters/values.py +166 -9
- execsql/gui/__init__.py +3 -31
- execsql/importers/base.py +1 -1
- execsql/importers/csv.py +1 -1
- execsql/importers/ods.py +2 -0
- 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 +15 -3
- execsql/lsp/document.py +142 -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 +16 -5
- execsql/metacommands/reference.py +1 -1
- execsql/metacommands/upsert.py +10 -1
- execsql/script/executor.py +2 -21
- execsql/state.py +6 -3
- execsql/state.pyi +2 -0
- execsql/utils/auth.py +21 -14
- execsql/utils/errors.py +20 -37
- execsql/utils/fileio.py +44 -48
- execsql/utils/paths.py +31 -0
- execsql/utils/regex.py +0 -5
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/README.md +9 -8
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_compare.sql +53 -29
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_glossary.sql +20 -20
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_upsert.sql +59 -66
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_compare.sql +19 -22
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_glossary.sql +13 -17
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/script_template.sql +302 -299
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_compare.sql +39 -29
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_glossary.sql +13 -17
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_upsert.sql +56 -66
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/METADATA +17 -43
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/RECORD +66 -63
- execsql2-2.29.0.data/data/execsql2_extras/pg_upsert.sql +0 -2791
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/WHEEL +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/entry_points.txt +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.29.0.dist-info → execsql2-2.31.1.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
|
@@ -297,6 +297,7 @@ def _connect_from_dsn(dsn: str, new_db: bool = False) -> Any:
|
|
|
297
297
|
user=user,
|
|
298
298
|
password=password,
|
|
299
299
|
port=port,
|
|
300
|
+
trust_server_certificate=params["trust_server_certificate"],
|
|
300
301
|
)
|
|
301
302
|
elif db_type == "o":
|
|
302
303
|
return db_Oracle(
|
|
@@ -594,6 +595,11 @@ def run(
|
|
|
594
595
|
pass
|
|
595
596
|
if ctx.tempfiles is not None:
|
|
596
597
|
ctx.tempfiles.remove_all()
|
|
598
|
+
if ctx.cd_from is not None: # the script ran CD: the caller's directory is theirs
|
|
599
|
+
try:
|
|
600
|
+
os.chdir(ctx.cd_from)
|
|
601
|
+
except OSError:
|
|
602
|
+
pass # it no longer exists
|
|
597
603
|
|
|
598
604
|
return ScriptResult(
|
|
599
605
|
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
|
|
|
@@ -280,7 +282,7 @@ def _online_help_callback(value: bool) -> None:
|
|
|
280
282
|
if value:
|
|
281
283
|
import webbrowser
|
|
282
284
|
|
|
283
|
-
webbrowser.open("https://execsql2.readthedocs.io/en/
|
|
285
|
+
webbrowser.open("https://execsql2.readthedocs.io/en/stable/", new=2, autoraise=True)
|
|
284
286
|
raise typer.Exit()
|
|
285
287
|
|
|
286
288
|
|
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]
|
|
@@ -140,7 +143,7 @@ class LintFormat(str, Enum):
|
|
|
140
143
|
"Every issue names its rule code, which --select and --ignore accept, as a full "
|
|
141
144
|
"code (V001) or a prefix (V). Parse errors (P001) are always reported. Exits 1 "
|
|
142
145
|
"when any error is found; warnings alone exit 0 unless --strict is given.\n\n"
|
|
143
|
-
"Rules: https://execsql2.readthedocs.io/en/
|
|
146
|
+
"Rules: https://execsql2.readthedocs.io/en/stable/reference/lint/"
|
|
144
147
|
),
|
|
145
148
|
)
|
|
146
149
|
def lint_cmd(
|
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
|
@@ -40,7 +40,9 @@ def _parse_connection_string(dsn: str) -> dict:
|
|
|
40
40
|
written ``%40``, ``%3A``, ``%2F``, ``%3F``, ``%23``, ``%25``.
|
|
41
41
|
|
|
42
42
|
Returns a dict with keys: ``db_type``, ``server``, ``db``, ``db_file``,
|
|
43
|
-
``user``, ``password``, ``port
|
|
43
|
+
``user``, ``password``, ``port`` and ``trust_server_certificate`` (from
|
|
44
|
+
``?trust_server_certificate=yes`` on an ``mssql://`` URL). Absent
|
|
45
|
+
components are ``None``.
|
|
44
46
|
|
|
45
47
|
Raises :class:`~execsql.exceptions.ConfigError` for an unrecognised
|
|
46
48
|
URL scheme or a completely un-parseable string.
|
|
@@ -50,7 +52,9 @@ def _parse_connection_string(dsn: str) -> dict:
|
|
|
50
52
|
parsed = urlparse(dsn)
|
|
51
53
|
scheme = parsed.scheme.lower()
|
|
52
54
|
if not scheme:
|
|
53
|
-
|
|
55
|
+
# Echoed so the user sees what went wrong, but never with its password.
|
|
56
|
+
shown = dsn.replace(f":{parsed.password}@", ":***@") if parsed.password else dsn
|
|
57
|
+
raise ConfigError(f"Cannot parse connection string (no scheme): {shown!r}")
|
|
54
58
|
if scheme not in _SCHEME_TO_DBTYPE:
|
|
55
59
|
raise ConfigError(
|
|
56
60
|
f"Unrecognised connection-string scheme {scheme!r}. "
|
|
@@ -84,6 +88,8 @@ def _parse_connection_string(dsn: str) -> dict:
|
|
|
84
88
|
# Remove leading "/"
|
|
85
89
|
db = raw_path.lstrip("/") or None
|
|
86
90
|
|
|
91
|
+
trust_server_certificate = _query_options(parsed.query, db_type)
|
|
92
|
+
|
|
87
93
|
return {
|
|
88
94
|
"db_type": db_type,
|
|
89
95
|
"server": server,
|
|
@@ -92,4 +98,34 @@ def _parse_connection_string(dsn: str) -> dict:
|
|
|
92
98
|
"user": user,
|
|
93
99
|
"password": password,
|
|
94
100
|
"port": port,
|
|
101
|
+
"trust_server_certificate": trust_server_certificate,
|
|
95
102
|
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_TRUE = ("yes", "true", "on", "1")
|
|
106
|
+
_FALSE = ("no", "false", "off", "0")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _query_options(query: str, db_type: str) -> bool | None:
|
|
110
|
+
"""Return ``trust_server_certificate`` from a DSN's query string, or None if absent.
|
|
111
|
+
|
|
112
|
+
It is the only query parameter execsql reads, and only for SQL Server.
|
|
113
|
+
Anything else is an error rather than ignored, so a misspelt option
|
|
114
|
+
cannot silently leave a connection without the setting it asked for.
|
|
115
|
+
"""
|
|
116
|
+
from urllib.parse import parse_qsl
|
|
117
|
+
|
|
118
|
+
trust: bool | None = None
|
|
119
|
+
for key, value in parse_qsl(query, keep_blank_values=True):
|
|
120
|
+
if key.lower() != "trust_server_certificate" or db_type != "s":
|
|
121
|
+
raise ConfigError(
|
|
122
|
+
f"Unrecognised connection-string parameter {key!r}. "
|
|
123
|
+
"The only one execsql reads is trust_server_certificate, for mssql:// URLs.",
|
|
124
|
+
)
|
|
125
|
+
if value.lower() in _TRUE:
|
|
126
|
+
trust = True
|
|
127
|
+
elif value.lower() in _FALSE:
|
|
128
|
+
trust = False
|
|
129
|
+
else:
|
|
130
|
+
raise ConfigError(f"trust_server_certificate must be yes or no, not {value!r}.")
|
|
131
|
+
return trust
|
execsql/cli/lint.py
CHANGED
|
@@ -56,6 +56,7 @@ from collections.abc import Iterable
|
|
|
56
56
|
from pathlib import Path
|
|
57
57
|
from typing import TYPE_CHECKING, NamedTuple
|
|
58
58
|
|
|
59
|
+
from execsql.utils.paths import is_network_path
|
|
59
60
|
from execsql.exceptions import ErrInfo
|
|
60
61
|
from execsql.script.ast import (
|
|
61
62
|
BatchBlock,
|
|
@@ -461,6 +462,8 @@ def _read_ini_vars(
|
|
|
461
462
|
language server; a file that is unreadable or not valid INI defines
|
|
462
463
|
nothing.
|
|
463
464
|
"""
|
|
465
|
+
if is_network_path(ini_file): # see _check_include_path
|
|
466
|
+
return
|
|
464
467
|
p = Path(ini_file)
|
|
465
468
|
if not p.is_absolute() and script_dir is not None:
|
|
466
469
|
p = script_dir / p
|
|
@@ -471,7 +474,7 @@ def _read_ini_vars(
|
|
|
471
474
|
if stat.st_size > _MAX_INI_BYTES:
|
|
472
475
|
return
|
|
473
476
|
resolved = str(p.resolve())
|
|
474
|
-
except OSError:
|
|
477
|
+
except (OSError, ValueError): # unreadable, or a name the OS rejects
|
|
475
478
|
return
|
|
476
479
|
defined_vars.update(_ini_section_keys(resolved, stat.st_size, stat.st_mtime_ns, section))
|
|
477
480
|
|
|
@@ -582,12 +585,23 @@ def _check_include_path(
|
|
|
582
585
|
line_no: int,
|
|
583
586
|
issues: list[_Issue],
|
|
584
587
|
) -> None:
|
|
585
|
-
"""Warn if the INCLUDE target does not exist on disk.
|
|
588
|
+
"""Warn if the INCLUDE target does not exist on disk.
|
|
589
|
+
|
|
590
|
+
A target written as a network path is not checked: on Windows, even
|
|
591
|
+
asking whether it exists contacts the host it names.
|
|
592
|
+
"""
|
|
593
|
+
if is_network_path(raw_path):
|
|
594
|
+
return
|
|
586
595
|
p = Path(raw_path)
|
|
587
596
|
if not p.is_absolute() and script_dir is not None:
|
|
588
597
|
p = script_dir / p
|
|
589
598
|
|
|
590
|
-
|
|
599
|
+
try:
|
|
600
|
+
exists = p.exists()
|
|
601
|
+
except OSError as exc: # a name the file system rejects outright (too long, ...)
|
|
602
|
+
issues.append(_issue("I001", source, line_no, f"INCLUDE path cannot be checked: {exc.strerror or exc}"))
|
|
603
|
+
return
|
|
604
|
+
if not exists:
|
|
591
605
|
issues.append(
|
|
592
606
|
_issue("I001", source, line_no, f"INCLUDE file does not exist: {raw_path}"),
|
|
593
607
|
)
|
|
@@ -701,7 +715,7 @@ _RX_ANY_VAR = re.compile(r"!(['\"]?)![$&@~#+]?\w+!\1!|!\{[$&@~#+]?\w+\}!")
|
|
|
701
715
|
_PLACEHOLDERS = ("TRUE", "1", "x")
|
|
702
716
|
|
|
703
717
|
|
|
704
|
-
def _with_placeholders(text: str, value: str
|
|
718
|
+
def _with_placeholders(text: str, value: str) -> str:
|
|
705
719
|
"""Replace each substitution variable with the literal *value*."""
|
|
706
720
|
return _RX_ANY_VAR.sub(value, text)
|
|
707
721
|
|
|
@@ -997,6 +1011,7 @@ def _lint_nodes(
|
|
|
997
1011
|
def lint(
|
|
998
1012
|
script: Script,
|
|
999
1013
|
script_path: str | None = None,
|
|
1014
|
+
base_dir: str | None = None,
|
|
1000
1015
|
) -> list[_Issue]:
|
|
1001
1016
|
"""Perform static analysis on an AST-parsed script.
|
|
1002
1017
|
|
|
@@ -1004,6 +1019,8 @@ def lint(
|
|
|
1004
1019
|
script: The parsed :class:`Script` tree.
|
|
1005
1020
|
script_path: Path to the source file (for resolving relative
|
|
1006
1021
|
INCLUDE paths). ``None`` for inline scripts.
|
|
1022
|
+
base_dir: Where an inline script's relative paths resolve, in place
|
|
1023
|
+
of the working directory.
|
|
1007
1024
|
|
|
1008
1025
|
Returns:
|
|
1009
1026
|
Every :class:`Issue` found, unfiltered.
|
|
@@ -1014,7 +1031,7 @@ def lint(
|
|
|
1014
1031
|
issues.append(_issue("S001", script_path or script.source, 0, "script is empty"))
|
|
1015
1032
|
return issues
|
|
1016
1033
|
|
|
1017
|
-
script_dir = Path(script_path).resolve().parent if script_path else None
|
|
1034
|
+
script_dir = Path(script_path).resolve().parent if script_path else (Path(base_dir) if base_dir else None)
|
|
1018
1035
|
script_blocks = _collect_script_blocks(script)
|
|
1019
1036
|
|
|
1020
1037
|
# Pass 1: collect all variable definitions
|
|
@@ -1036,7 +1053,7 @@ def lint(
|
|
|
1036
1053
|
return issues
|
|
1037
1054
|
|
|
1038
1055
|
|
|
1039
|
-
def lint_source(source: str, label: str, script_path: str | None = None) -> list[_Issue]:
|
|
1056
|
+
def lint_source(source: str, label: str, script_path: str | None = None, base_dir: str | None = None) -> list[_Issue]:
|
|
1040
1057
|
"""Parse *source* and lint it, unfiltered.
|
|
1041
1058
|
|
|
1042
1059
|
Each block-structure error (an IF without ENDIF, a stray END LOOP, ...)
|
|
@@ -1047,7 +1064,7 @@ def lint_source(source: str, label: str, script_path: str | None = None) -> list
|
|
|
1047
1064
|
|
|
1048
1065
|
errors: list[ErrInfo] = []
|
|
1049
1066
|
tree = parse_string(source, label, errors=errors)
|
|
1050
|
-
return [parse_error(label, exc) for exc in errors] + lint(tree, script_path=script_path)
|
|
1067
|
+
return [parse_error(label, exc) for exc in errors] + lint(tree, script_path=script_path, base_dir=base_dir)
|
|
1051
1068
|
|
|
1052
1069
|
|
|
1053
1070
|
# ---------------------------------------------------------------------------
|
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,33 @@ 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,
|
|
284
|
+
local_only: bool = False,
|
|
282
285
|
) -> ConfigData:
|
|
283
286
|
"""Load and merge configuration files for the given script.
|
|
284
287
|
|
|
288
|
+
*start_dir* stands in for the working directory (see :class:`ConfigData`).
|
|
285
289
|
Returns a fully populated :class:`ConfigData` instance.
|
|
286
290
|
"""
|
|
287
|
-
|
|
288
|
-
|
|
291
|
+
if script_name:
|
|
292
|
+
script_path = str(Path(script_name).resolve().parent)
|
|
293
|
+
else: # resolved, as os.getcwd() is, so the start directory's execsql.conf is not read twice
|
|
294
|
+
script_path = str(Path(start_dir).resolve()) if start_dir else os.getcwd()
|
|
295
|
+
return ConfigData(script_path, subvars, config_file=config_file, start_dir=start_dir, local_only=local_only)
|
|
289
296
|
|
|
290
297
|
|
|
291
|
-
def _tool_config(config_file: str | None) -> ConfigData:
|
|
298
|
+
def _tool_config(config_file: str | None, start_dir: str | None = None) -> ConfigData:
|
|
292
299
|
"""The config ``execsql format`` and ``execsql lint`` read.
|
|
293
300
|
|
|
294
301
|
Read once per invocation, from the system, user and working-directory
|
|
295
302
|
locations plus *config_file* — not per script, so a script directory's
|
|
296
|
-
own ``execsql.conf`` applies only when passed with ``--config``.
|
|
303
|
+
own ``execsql.conf`` applies only when passed with ``--config``. The
|
|
304
|
+
language server passes a workspace folder as *start_dir*: the settings
|
|
305
|
+
those commands would read when run from it. Config chains written as
|
|
306
|
+
network paths are skipped untouched (``local_only``): these tools read
|
|
307
|
+
config from repositories other people write.
|
|
297
308
|
"""
|
|
298
|
-
return _load_config(None, _seed_early_subvars(), config_file)
|
|
309
|
+
return _load_config(None, _seed_early_subvars(start_dir), config_file, start_dir, local_only=True)
|
|
299
310
|
|
|
300
311
|
|
|
301
312
|
def _configured_script_encoding(conf: ConfigData) -> str | None:
|
|
@@ -358,6 +369,8 @@ def _apply_dsn(dsn: str, conf: ConfigData, db_type: str | None) -> tuple[str | N
|
|
|
358
369
|
conf.db = parsed["db"]
|
|
359
370
|
if parsed["db_file"]:
|
|
360
371
|
conf.db_file = parsed["db_file"]
|
|
372
|
+
if parsed["trust_server_certificate"] is not None:
|
|
373
|
+
conf.trust_server_certificate = parsed["trust_server_certificate"]
|
|
361
374
|
user = parsed["user"] # may be None
|
|
362
375
|
if parsed["password"]:
|
|
363
376
|
conf.db_password = parsed["password"]
|
|
@@ -561,7 +574,7 @@ def _setup_logging(
|
|
|
561
574
|
for n, repl in enumerate(sub_vars):
|
|
562
575
|
var = f"$ARG_{n + 1}"
|
|
563
576
|
subvars.add_substitution(var, repl)
|
|
564
|
-
logger.add_redaction_value(repl)
|
|
577
|
+
logger.add_redaction_value(repl, argument=True)
|
|
565
578
|
# `-a` values are positional and opaque (no name to denylist
|
|
566
579
|
# against). High-entropy secrets (sk-live-*, AKIA*, ghp_*,
|
|
567
580
|
# JWTs) pass any substring/value heuristic, so log the
|
|
@@ -574,7 +587,7 @@ def _setup_logging(
|
|
|
574
587
|
# redacted for the same reason as -a's.
|
|
575
588
|
for name, value in named_vars or ():
|
|
576
589
|
subvars.add_substitution(name, value)
|
|
577
|
-
logger.add_redaction_value(value)
|
|
590
|
+
logger.add_redaction_value(value, argument=True)
|
|
578
591
|
logger.log_status_info(f"Command-line substitution variable assignment: {name} set to {{***}}")
|
|
579
592
|
|
|
580
593
|
return logger
|
execsql/config.py
CHANGED
|
@@ -23,6 +23,7 @@ from configparser import ConfigParser
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import Protocol
|
|
25
25
|
|
|
26
|
+
from execsql.utils.paths import is_network_path
|
|
26
27
|
from execsql.exceptions import ConfigError, ConfigFileError
|
|
27
28
|
from execsql.utils.crypto import Encrypt
|
|
28
29
|
|
|
@@ -281,6 +282,7 @@ class ConfigData:
|
|
|
281
282
|
self._get_bool(cp, self._CONNECT_SECTION, "password_prompt", "passwd_prompt")
|
|
282
283
|
self._get_bool(cp, self._CONNECT_SECTION, "use_keyring", "use_keyring")
|
|
283
284
|
self._get_bool(cp, self._CONNECT_SECTION, "new_db", "new_db")
|
|
285
|
+
self._get_bool(cp, self._CONNECT_SECTION, "trust_server_certificate", "trust_server_certificate")
|
|
284
286
|
# --- [encoding] ---
|
|
285
287
|
self._get_str(cp, self._ENCODING_SECTION, "database", "db_encoding")
|
|
286
288
|
self._get_str(cp, self._ENCODING_SECTION, "script", "script_encoding", required=True)
|
|
@@ -401,6 +403,8 @@ class ConfigData:
|
|
|
401
403
|
variable_pool: _VariablePool,
|
|
402
404
|
*,
|
|
403
405
|
config_file: str | None = None,
|
|
406
|
+
start_dir: str | None = None,
|
|
407
|
+
local_only: bool = False,
|
|
404
408
|
) -> None:
|
|
405
409
|
"""Load and merge all discoverable execsql.conf files for the given script path.
|
|
406
410
|
|
|
@@ -414,12 +418,23 @@ class ConfigData:
|
|
|
414
418
|
Loaded after the implicit search paths so its values take
|
|
415
419
|
precedence over system, user, script, and working-directory
|
|
416
420
|
config files.
|
|
421
|
+
start_dir: The directory a run starts in: its ``execsql.conf`` is
|
|
422
|
+
read, and relative ``config_file`` paths (``--config`` and
|
|
423
|
+
``[config]`` chains) resolve against it. Defaults to the
|
|
424
|
+
working directory. The language server passes each
|
|
425
|
+
workspace folder, so it never changes the process's directory.
|
|
426
|
+
local_only: Skip a ``config_file`` chain or ``[include_*]`` path
|
|
427
|
+
written as a network path (:func:`~execsql.utils.paths.is_network_path`)
|
|
428
|
+
without touching it. The static tools pass it: they read
|
|
429
|
+
config files a cloned repository may supply.
|
|
417
430
|
"""
|
|
431
|
+
self._start_dir = start_dir
|
|
432
|
+
self._local_only = local_only
|
|
418
433
|
self._set_defaults()
|
|
419
434
|
current_script = str(Path(sys.argv[0]).resolve())
|
|
420
435
|
from collections import deque
|
|
421
436
|
|
|
422
|
-
config_queue: deque[str] = deque(self._search_paths(script_path, config_file))
|
|
437
|
+
config_queue: deque[str] = deque(self._search_paths(script_path, config_file, start_dir))
|
|
423
438
|
self.files_read: list = []
|
|
424
439
|
# Warm the schema registry by running the option reader against an
|
|
425
440
|
# empty ConfigParser. cp.has_option() is False for everything so no
|
|
@@ -455,6 +470,11 @@ class ConfigData:
|
|
|
455
470
|
if cp.has_option(section, key):
|
|
456
471
|
self.sources[attr] = configfile
|
|
457
472
|
|
|
473
|
+
#: Where relative paths resolve; ``None`` is the working directory.
|
|
474
|
+
_start_dir: str | None = None
|
|
475
|
+
#: Whether paths written as network paths are skipped (see ``__init__``).
|
|
476
|
+
_local_only: bool = False
|
|
477
|
+
|
|
458
478
|
#: Guard against circular config_file references.
|
|
459
479
|
_MAX_CONFIG_CHAIN = 20
|
|
460
480
|
|
|
@@ -471,6 +491,7 @@ class ConfigData:
|
|
|
471
491
|
self.use_keyring = True
|
|
472
492
|
self.db_file: str | None = None
|
|
473
493
|
self.new_db = False
|
|
494
|
+
self.trust_server_certificate = False
|
|
474
495
|
self.user_logfile = False
|
|
475
496
|
self.db_encoding: str | None = None
|
|
476
497
|
self.script_encoding = "utf8"
|
|
@@ -562,7 +583,7 @@ class ConfigData:
|
|
|
562
583
|
self.lint_strict = False
|
|
563
584
|
|
|
564
585
|
@classmethod
|
|
565
|
-
def _search_paths(cls, script_path: str, config_file: str | None) -> list[str]:
|
|
586
|
+
def _search_paths(cls, script_path: str, config_file: str | None, start_dir: str | None = None) -> list[str]:
|
|
566
587
|
"""The files a run reads, lowest precedence first: system, user, script dir, working dir, ``--config``.
|
|
567
588
|
|
|
568
589
|
Files that ``[config] config_file`` and its OS-specific variants chain
|
|
@@ -574,22 +595,33 @@ class ConfigData:
|
|
|
574
595
|
sys_config_file = str(Path(os.path.expandvars(r"%APPDATA%")) / cls.config_file_name)
|
|
575
596
|
user_config_file = str(Path("~/.config").expanduser() / cls.config_file_name)
|
|
576
597
|
script_config_file = str(Path(script_path) / cls.config_file_name)
|
|
577
|
-
|
|
598
|
+
start = Path(start_dir or ".")
|
|
599
|
+
startdir_config_file = str(start.resolve() / cls.config_file_name)
|
|
578
600
|
if startdir_config_file != script_config_file:
|
|
579
601
|
config_files = [sys_config_file, user_config_file, script_config_file, startdir_config_file]
|
|
580
602
|
else:
|
|
581
603
|
config_files = [sys_config_file, user_config_file, startdir_config_file]
|
|
582
604
|
if config_file:
|
|
583
|
-
config_files.append(str(
|
|
605
|
+
config_files.append(str((start / config_file).resolve()))
|
|
584
606
|
return config_files
|
|
585
607
|
|
|
586
608
|
@classmethod
|
|
587
|
-
def _chain_target(
|
|
609
|
+
def _chain_target(
|
|
610
|
+
cls,
|
|
611
|
+
value: str,
|
|
612
|
+
variable_pool: _VariablePool,
|
|
613
|
+
*,
|
|
614
|
+
expand_home: bool,
|
|
615
|
+
start_dir: str | None = None,
|
|
616
|
+
local_only: bool = False,
|
|
617
|
+
) -> str:
|
|
588
618
|
"""Resolve a ``config_file``-style value to the file it names.
|
|
589
619
|
|
|
590
620
|
A leading ``~`` is expanded when *expand_home* is true, substitution
|
|
591
621
|
variables are replaced, and a directory means the ``execsql.conf``
|
|
592
622
|
inside it. The result may not exist; callers decide what that means.
|
|
623
|
+
With *local_only*, a value that is a network path once substituted
|
|
624
|
+
gives ``""`` without being touched.
|
|
593
625
|
"""
|
|
594
626
|
conffile = value
|
|
595
627
|
if expand_home and conffile and conffile[0] == "~":
|
|
@@ -598,10 +630,18 @@ class ConfigData:
|
|
|
598
630
|
elif len(conffile) > 1 and conffile[1] == os.sep:
|
|
599
631
|
conffile = str(Path("~").expanduser() / conffile[2:])
|
|
600
632
|
conffile = variable_pool.substitute(conffile)[0]
|
|
633
|
+
if local_only and is_network_path(conffile):
|
|
634
|
+
return ""
|
|
635
|
+
if start_dir and not Path(conffile).is_absolute():
|
|
636
|
+
conffile = str(Path(start_dir) / conffile)
|
|
601
637
|
if not Path(conffile).is_file():
|
|
602
638
|
conffile = str(Path(conffile) / cls.config_file_name)
|
|
603
639
|
return conffile
|
|
604
640
|
|
|
641
|
+
def _skips(self, path: str) -> bool:
|
|
642
|
+
"""Whether *path* is left untouched: a network path when ``local_only``."""
|
|
643
|
+
return self._local_only and is_network_path(path)
|
|
644
|
+
|
|
605
645
|
def _apply_file(self, cp: ConfigParser, variable_pool: _VariablePool, current_script: str) -> list[str]:
|
|
606
646
|
"""Apply one parsed config file to ``self``; return the files it chains to.
|
|
607
647
|
|
|
@@ -651,8 +691,10 @@ class ConfigData:
|
|
|
651
691
|
cp.get(self._CONFIG_SECTION, "config_file"),
|
|
652
692
|
variable_pool,
|
|
653
693
|
expand_home=os.name == "posix",
|
|
694
|
+
start_dir=self._start_dir,
|
|
695
|
+
local_only=self._local_only,
|
|
654
696
|
)
|
|
655
|
-
if Path(conffile).is_file():
|
|
697
|
+
if conffile and Path(conffile).is_file():
|
|
656
698
|
# Silently ignore a non-existent file, for cross-OS compatibility.
|
|
657
699
|
chained.append(conffile)
|
|
658
700
|
# OS-specific additional config files.
|
|
@@ -664,8 +706,14 @@ class ConfigData:
|
|
|
664
706
|
elif os.name == "nt" and cp.has_option(self._CONFIG_SECTION, "win_config_file"):
|
|
665
707
|
_os_config_key = "win_config_file"
|
|
666
708
|
if _os_config_key:
|
|
667
|
-
conffile = self._chain_target(
|
|
668
|
-
|
|
709
|
+
conffile = self._chain_target(
|
|
710
|
+
cp.get(self._CONFIG_SECTION, _os_config_key),
|
|
711
|
+
variable_pool,
|
|
712
|
+
expand_home=True,
|
|
713
|
+
start_dir=self._start_dir,
|
|
714
|
+
local_only=self._local_only,
|
|
715
|
+
)
|
|
716
|
+
if conffile and Path(conffile).is_file():
|
|
669
717
|
chained.append(conffile)
|
|
670
718
|
# dao_flush_delay_secs has a specific error message — keep inline
|
|
671
719
|
if cp.has_option(self._CONFIG_SECTION, "dao_flush_delay_secs"):
|
|
@@ -701,7 +749,7 @@ class ConfigData:
|
|
|
701
749
|
if cp.has_section(self._INCLUDE_REQ_SECTION):
|
|
702
750
|
imp_items = cp.items(self._INCLUDE_REQ_SECTION)
|
|
703
751
|
ord_items = sorted([(int(i[0]), i[1]) for i in imp_items], key=lambda x: x[0])
|
|
704
|
-
newfiles = [str(Path(f[1]).resolve()) for f in ord_items]
|
|
752
|
+
newfiles = [str(Path(f[1]).resolve()) for f in ord_items if not self._skips(f[1])]
|
|
705
753
|
u_files = []
|
|
706
754
|
for f in newfiles:
|
|
707
755
|
if not (f in u_files or f in self.include_req or f in self.include_opt) and f != current_script:
|
|
@@ -712,7 +760,7 @@ class ConfigData:
|
|
|
712
760
|
if cp.has_section(self._INCLUDE_OPT_SECTION):
|
|
713
761
|
imp_items = cp.items(self._INCLUDE_OPT_SECTION)
|
|
714
762
|
ord_items = sorted([(int(i[0]), i[1]) for i in imp_items], key=lambda x: x[0])
|
|
715
|
-
newfiles = [str(Path(f[1]).resolve()) for f in ord_items]
|
|
763
|
+
newfiles = [str(Path(f[1]).resolve()) for f in ord_items if not self._skips(f[1])]
|
|
716
764
|
u_files = []
|
|
717
765
|
for f in newfiles:
|
|
718
766
|
if (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# execsql.conf — Configuration file for execsql
|
|
2
2
|
#
|
|
3
|
-
# Documentation: https://execsql2.readthedocs.io/en/
|
|
3
|
+
# Documentation: https://execsql2.readthedocs.io/en/stable/reference/configuration/
|
|
4
4
|
#
|
|
5
5
|
# This file uses INI format. Section names are case-sensitive (lowercase).
|
|
6
6
|
# Property names are not case-sensitive. Lines starting with # are comments.
|
|
@@ -54,6 +54,12 @@
|
|
|
54
54
|
# Values: Yes or No. Default: No.
|
|
55
55
|
#new_db=No
|
|
56
56
|
|
|
57
|
+
# SQL Server only: accept the server's TLS certificate without verifying it,
|
|
58
|
+
# for a server with a self-signed certificate. ODBC Driver 18 otherwise
|
|
59
|
+
# refuses one it cannot verify. Applies to CONNECT as well.
|
|
60
|
+
# Values: Yes or No. Default: No.
|
|
61
|
+
#trust_server_certificate=No
|
|
62
|
+
|
|
57
63
|
|
|
58
64
|
[encoding]
|
|
59
65
|
# Character encoding for text input and output.
|
|
@@ -279,7 +285,7 @@
|
|
|
279
285
|
#message_css=
|
|
280
286
|
|
|
281
287
|
# Obfuscated password (XOR, not cryptographically secure — use keyring instead).
|
|
282
|
-
# See: https://execsql2.readthedocs.io/en/
|
|
288
|
+
# See: https://execsql2.readthedocs.io/en/stable/reference/security/#credentials
|
|
283
289
|
#enc_password=
|
|
284
290
|
|
|
285
291
|
|