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.
Files changed (67) hide show
  1. execsql/__main__.py +4 -5
  2. execsql/api.py +6 -0
  3. execsql/cli/__init__.py +3 -14
  4. execsql/cli/application.py +6 -4
  5. execsql/cli/commands/lint.py +5 -2
  6. execsql/cli/commands/lsp.py +42 -0
  7. execsql/cli/dsn.py +38 -2
  8. execsql/cli/lint.py +24 -7
  9. execsql/cli/run.py +22 -9
  10. execsql/config.py +58 -10
  11. execsql/data/execsql.conf.template +8 -2
  12. execsql/db/access.py +7 -5
  13. execsql/db/base.py +18 -0
  14. execsql/db/duckdb.py +96 -14
  15. execsql/db/factory.py +11 -1
  16. execsql/db/mysql.py +56 -3
  17. execsql/db/odbc.py +45 -0
  18. execsql/db/postgres.py +1 -1
  19. execsql/db/sqlserver.py +37 -21
  20. execsql/exporters/ods.py +146 -41
  21. execsql/exporters/values.py +166 -9
  22. execsql/gui/__init__.py +3 -31
  23. execsql/importers/base.py +1 -1
  24. execsql/importers/csv.py +1 -1
  25. execsql/importers/ods.py +2 -0
  26. execsql/lsp/_util.py +21 -0
  27. execsql/lsp/cache.py +73 -0
  28. execsql/lsp/code_actions.py +10 -14
  29. execsql/lsp/completion.py +8 -15
  30. execsql/lsp/diagnostics.py +15 -3
  31. execsql/lsp/document.py +142 -41
  32. execsql/lsp/hover.py +5 -13
  33. execsql/lsp/navigation.py +7 -10
  34. execsql/lsp/server.py +111 -29
  35. execsql/manifest.py +29 -9
  36. execsql/metacommands/__init__.py +0 -2
  37. execsql/metacommands/io_fileops.py +16 -5
  38. execsql/metacommands/reference.py +1 -1
  39. execsql/metacommands/upsert.py +10 -1
  40. execsql/script/executor.py +2 -21
  41. execsql/state.py +6 -3
  42. execsql/state.pyi +2 -0
  43. execsql/utils/auth.py +21 -14
  44. execsql/utils/errors.py +20 -37
  45. execsql/utils/fileio.py +44 -48
  46. execsql/utils/paths.py +31 -0
  47. execsql/utils/regex.py +0 -5
  48. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/README.md +9 -8
  49. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_compare.sql +53 -29
  50. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_glossary.sql +20 -20
  51. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/md_upsert.sql +59 -66
  52. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_compare.sql +19 -22
  53. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/pg_glossary.sql +13 -17
  54. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/script_template.sql +302 -299
  55. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_compare.sql +39 -29
  56. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_glossary.sql +13 -17
  57. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/ss_upsert.sql +56 -66
  58. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/METADATA +17 -43
  59. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/RECORD +66 -63
  60. execsql2-2.29.0.data/data/execsql2_extras/pg_upsert.sql +0 -2791
  61. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  62. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  63. {execsql2-2.29.0.data → execsql2-2.31.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  64. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/WHEEL +0 -0
  65. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/entry_points.txt +0 -0
  66. {execsql2-2.29.0.dist-info → execsql2-2.31.1.dist-info}/licenses/LICENSE.txt +0 -0
  67. {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
- from __future__ import annotations
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
- Delegates immediately to :func:`execsql.cli.main` so the package can be
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.commands.run import main
26
- from execsql.cli.dsn import _parse_connection_string, _SCHEME_TO_DBTYPE # noqa: F401 — re-export
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
- # Legacy entry point (kept for backwards compat with pyproject.toml script)
38
+ # Entry point for ``python -m execsql`` (the ``execsql`` script is cli.dispatch:dispatch)
50
39
  # ---------------------------------------------------------------------------
51
40
 
52
41
 
@@ -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(limit)) for name, cmd in commands],
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/latest/", new=2, autoraise=True)
285
+ webbrowser.open("https://execsql2.readthedocs.io/en/stable/", new=2, autoraise=True)
284
286
  raise typer.Exit()
285
287
 
286
288
 
@@ -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
- found = lint_source(source, label, script_path=None if stdin else label)
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/latest/reference/lint/"
146
+ "Rules: https://execsql2.readthedocs.io/en/stable/reference/lint/"
144
147
  ),
145
148
  )
146
149
  def lint_cmd(
@@ -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``. Absent components are ``None``.
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
- raise ConfigError(f"Cannot parse connection string (no scheme): {dsn!r}")
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
- if not p.exists():
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 = "TRUE") -> 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
- script_path = str(Path(script_name).resolve().parent) if script_name else os.getcwd()
288
- return ConfigData(script_path, subvars, config_file=config_file)
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
- startdir_config_file = str(Path(".").resolve() / cls.config_file_name)
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(Path(config_file).resolve()))
605
+ config_files.append(str((start / config_file).resolve()))
584
606
  return config_files
585
607
 
586
608
  @classmethod
587
- def _chain_target(cls, value: str, variable_pool: _VariablePool, *, expand_home: bool) -> str:
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(cp.get(self._CONFIG_SECTION, _os_config_key), variable_pool, expand_home=True)
668
- if Path(conffile).is_file():
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/latest/reference/configuration/
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/latest/reference/security/#credentials
288
+ # See: https://execsql2.readthedocs.io/en/stable/reference/security/#credentials
283
289
  #enc_password=
284
290
 
285
291