execsql2 2.29.0__py3-none-any.whl → 2.30.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. execsql/__main__.py +4 -5
  2. execsql/api.py +5 -0
  3. execsql/cli/__init__.py +3 -14
  4. execsql/cli/application.py +5 -3
  5. execsql/cli/commands/lint.py +4 -1
  6. execsql/cli/commands/lsp.py +42 -0
  7. execsql/cli/dsn.py +3 -1
  8. execsql/cli/lint.py +14 -6
  9. execsql/cli/run.py +17 -9
  10. execsql/config.py +32 -6
  11. execsql/db/duckdb.py +96 -14
  12. execsql/gui/__init__.py +3 -31
  13. execsql/lsp/_util.py +21 -0
  14. execsql/lsp/cache.py +73 -0
  15. execsql/lsp/code_actions.py +10 -14
  16. execsql/lsp/completion.py +8 -15
  17. execsql/lsp/diagnostics.py +14 -2
  18. execsql/lsp/document.py +139 -41
  19. execsql/lsp/hover.py +5 -13
  20. execsql/lsp/navigation.py +7 -10
  21. execsql/lsp/server.py +111 -29
  22. execsql/manifest.py +29 -9
  23. execsql/metacommands/__init__.py +0 -2
  24. execsql/metacommands/io_fileops.py +5 -0
  25. execsql/script/executor.py +2 -21
  26. execsql/state.py +6 -3
  27. execsql/state.pyi +2 -0
  28. execsql/utils/errors.py +19 -36
  29. execsql/utils/fileio.py +44 -48
  30. execsql/utils/regex.py +0 -5
  31. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/METADATA +2 -4
  32. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/RECORD +50 -48
  33. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/README.md +0 -0
  34. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  35. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  36. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  37. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  38. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  39. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  40. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  41. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  42. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  43. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/script_template.sql +0 -0
  44. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  45. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  46. {execsql2-2.29.0.data → execsql2-2.30.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  47. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/WHEEL +0 -0
  48. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/entry_points.txt +0 -0
  49. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/licenses/LICENSE.txt +0 -0
  50. {execsql2-2.29.0.dist-info → execsql2-2.30.0.dist-info}/licenses/NOTICE +0 -0
execsql/__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
@@ -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.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
 
@@ -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]
@@ -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
- raise ConfigError(f"Cannot parse connection string (no scheme): {dsn!r}")
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
- if not p.exists():
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 = "TRUE") -> 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
- script_path = str(Path(script_name).resolve().parent) if script_name else os.getcwd()
288
- return ConfigData(script_path, subvars, config_file=config_file)
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
- startdir_config_file = str(Path(".").resolve() / cls.config_file_name)
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(Path(config_file).resolve()))
594
+ config_files.append(str((start / config_file).resolve()))
584
595
  return config_files
585
596
 
586
597
  @classmethod
587
- def _chain_target(cls, value: str, variable_pool: _VariablePool, *, expand_home: bool) -> str:
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(cp.get(self._CONFIG_SECTION, _os_config_key), variable_pool, expand_home=True)
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/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
- """The connection, used as a cursor so a statement joins its open transaction.
32
-
33
- A DuckDB cursor is a separate connection with a transaction of its own,
34
- and closing it rolls that transaction back. ``close()`` therefore leaves
35
- the connection open.
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, conn: Any) -> None:
39
- self._conn = conn
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
- pass
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. While it is open, every statement runs on the connection
55
- itself rather than on a cursor (see :class:`_SharedCursor`).
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, or while a transaction is open, the connection itself."""
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
- if self.in_transaction:
90
- return _SharedCursor(self.conn)
91
- return self.conn.cursor()
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
 
execsql/gui/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """GUI backend factory and manager thread for execsql.
1
+ """The GUI manager thread for execsql.
2
2
 
3
3
  Usage
4
4
  -----
@@ -7,7 +7,7 @@ and start the manager thread. All dialog requests are then placed on
7
7
  ``_state.gui_manager_queue`` as ``GuiSpec`` objects and dispatched by
8
8
  ``gui_manager_loop()``.
9
9
 
10
- Backend selection (framework-based, not level-based):
10
+ ``enable_gui()`` picks the backend by framework, not by level:
11
11
 
12
12
  framework "tkinter" → TkinterBackend → TextualBackend → ConsoleBackend
13
13
  framework "textual" → TextualBackend → ConsoleBackend
@@ -21,35 +21,7 @@ from typing import TYPE_CHECKING, Any
21
21
  if TYPE_CHECKING:
22
22
  from execsql.gui.base import GuiBackend
23
23
 
24
- __all__ = ["get_backend", "gui_manager_loop"]
25
-
26
-
27
- def get_backend(framework: str = "tkinter") -> GuiBackend:
28
- """Return the best available backend for *framework*.
29
-
30
- Falls back gracefully if the preferred backend's dependencies are missing.
31
- """
32
- if framework == "tkinter":
33
- try:
34
- from execsql.gui.desktop import TkinterBackend
35
-
36
- backend = TkinterBackend()
37
- backend.start()
38
- return backend
39
- except (ImportError, RuntimeError, Exception):
40
- pass
41
-
42
- # Textual path (framework == "textual", or tkinter unavailable)
43
- try:
44
- from execsql.gui.tui import TextualBackend
45
-
46
- return TextualBackend()
47
- except ImportError:
48
- pass
49
-
50
- from execsql.gui.console import ConsoleBackend
51
-
52
- return ConsoleBackend()
24
+ __all__ = ["gui_manager_loop"]
53
25
 
54
26
 
55
27
  def gui_manager_loop(q: queue.Queue[Any], backend: GuiBackend) -> None: