execsql2 2.26.0__py3-none-any.whl → 2.28.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 (62) hide show
  1. execsql/cli/commands/__init__.py +1 -0
  2. execsql/cli/commands/format.py +2 -1
  3. execsql/cli/commands/listing.py +32 -5
  4. execsql/cli/commands/lsp.py +64 -0
  5. execsql/cli/dispatch.py +1 -1
  6. execsql/cli/help.py +44 -96
  7. execsql/cli/lint.py +58 -22
  8. execsql/db/access.py +4 -4
  9. execsql/db/base.py +17 -3
  10. execsql/db/dsn.py +2 -2
  11. execsql/db/duckdb.py +14 -2
  12. execsql/db/firebird.py +2 -3
  13. execsql/db/mysql.py +2 -2
  14. execsql/db/oracle.py +5 -5
  15. execsql/db/postgres.py +2 -3
  16. execsql/db/sqlserver.py +2 -2
  17. execsql/exporters/duckdb.py +2 -2
  18. execsql/exporters/ods.py +2 -2
  19. execsql/exporters/templates.py +2 -2
  20. execsql/exporters/xls.py +3 -3
  21. execsql/exporters/xlsx.py +2 -2
  22. execsql/exporters/yaml.py +3 -4
  23. execsql/format.py +71 -21
  24. execsql/importers/base.py +6 -10
  25. execsql/lsp/__init__.py +6 -0
  26. execsql/lsp/code_actions.py +217 -0
  27. execsql/lsp/completion.py +203 -0
  28. execsql/lsp/diagnostics.py +66 -0
  29. execsql/lsp/document.py +210 -0
  30. execsql/lsp/folding.py +59 -0
  31. execsql/lsp/formatting.py +37 -0
  32. execsql/lsp/hover.py +119 -0
  33. execsql/lsp/navigation.py +169 -0
  34. execsql/lsp/rename.py +91 -0
  35. execsql/lsp/server.py +358 -0
  36. execsql/metacommands/debug.py +2 -2
  37. execsql/metacommands/dispatch.py +7 -0
  38. execsql/metacommands/io_fileops.py +6 -6
  39. execsql/metacommands/reference.py +1404 -0
  40. execsql/metacommands/upsert.py +2 -5
  41. execsql/models.py +11 -0
  42. execsql/utils/errors.py +16 -0
  43. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/METADATA +34 -18
  44. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/RECORD +62 -49
  45. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/README.md +0 -0
  46. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  47. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  48. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  49. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  50. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  51. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  52. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  53. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  54. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  55. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/script_template.sql +0 -0
  56. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  57. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  58. {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  59. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/WHEEL +0 -0
  60. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/entry_points.txt +0 -0
  61. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/LICENSE.txt +0 -0
  62. {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/NOTICE +0 -0
@@ -11,3 +11,4 @@ from execsql.cli.commands import lint # noqa: F401
11
11
  from execsql.cli.commands import config # noqa: F401
12
12
  from execsql.cli.commands import listing # noqa: F401
13
13
  from execsql.cli.commands import init # noqa: F401
14
+ from execsql.cli.commands import lsp # noqa: F401
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  import typer
8
+ from rich.markup import escape
8
9
 
9
10
  from execsql.cli.application import ExecsqlCommand, app
10
11
  from execsql.cli.help import _err_console
@@ -90,7 +91,7 @@ def format_cmd(
90
91
  try:
91
92
  _require_sqlglot()
92
93
  except ImportError as exc:
93
- _err_console.print(f"[bold red]Error:[/bold red] {exc}", highlight=False)
94
+ _err_console.print(f"[bold red]Error:[/bold red] {escape(str(exc))}", highlight=False)
94
95
  raise typer.Exit(code=1) from exc
95
96
  raise typer.Exit(
96
97
  code=run_formatter(
@@ -9,17 +9,21 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import sys
12
+ from typing import Annotated
12
13
 
13
14
  import typer
14
15
 
15
16
  from execsql.cli.application import ExecsqlCommand, ExecsqlSubGroup, app
16
17
  from execsql.cli.help import (
18
+ _err_console,
17
19
  _encoding_names,
18
- _metacommand_rows,
20
+ _metacommand_entries,
21
+ _metacommand_json,
19
22
  _plugins_data,
20
23
  _print_encodings,
21
24
  _print_keywords_json,
22
25
  _print_keywords_text,
26
+ _print_metacommand,
23
27
  _print_metacommands,
24
28
  _print_plugins,
25
29
  )
@@ -43,10 +47,33 @@ def _json(data: object) -> None:
43
47
 
44
48
 
45
49
  @list_app.command(cls=ExecsqlCommand, name="metacommands")
46
- def metacommands_cmd(output_format: OutputFormatOpt = OutputFormat.text) -> None:
47
- """Every metacommand and its syntax."""
48
- if output_format is OutputFormat.json:
49
- _json([{"name": name, "syntax": syntax} for name, syntax in _metacommand_rows()])
50
+ def metacommands_cmd(
51
+ keyword: Annotated[
52
+ list[str] | None,
53
+ typer.Argument(metavar="[KEYWORD]", help="Show one metacommand's syntax, e.g. EXPORT or export query."),
54
+ ] = None,
55
+ output_format: OutputFormatOpt = OutputFormat.text,
56
+ ) -> None:
57
+ """Every metacommand with a one-line summary, or the syntax of one."""
58
+ if keyword:
59
+ from execsql.metacommands.reference import metacommand
60
+
61
+ wanted = " ".join(keyword)
62
+ entry = metacommand(wanted)
63
+ if entry is None:
64
+ import difflib
65
+
66
+ names = [m.keyword for m in _metacommand_entries()]
67
+ close = difflib.get_close_matches(wanted.upper(), names, n=3)
68
+ hint = f" Did you mean {', '.join(close)}?" if close else ""
69
+ _err_console.print(f"[bold red]Error:[/bold red] No metacommand {wanted!r}.{hint}", highlight=False)
70
+ raise typer.Exit(code=2)
71
+ if output_format is OutputFormat.json:
72
+ _json(_metacommand_json(entry))
73
+ else:
74
+ _print_metacommand(entry)
75
+ elif output_format is OutputFormat.json:
76
+ _json([_metacommand_json(m) for m in _metacommand_entries()])
50
77
  else:
51
78
  _print_metacommands()
52
79
 
@@ -0,0 +1,64 @@
1
+ """The ``lsp`` command: the language server editors start for ``.sql`` files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.markup import escape
7
+
8
+ from execsql.cli.application import ExecsqlCommand, app
9
+ from execsql.cli.help import _err_console
10
+ from execsql.utils.errors import install_hint
11
+
12
+ __all__ = ["lsp_cmd"]
13
+
14
+
15
+ def _lsp_libraries_installed() -> bool:
16
+ """Whether pygls and lsprotocol are installed (whether or not this execsql can use them)."""
17
+ from importlib.util import find_spec
18
+
19
+ return all(find_spec(name) is not None for name in ("pygls", "lsprotocol"))
20
+
21
+
22
+ def _pygls_version() -> str:
23
+ from importlib.metadata import PackageNotFoundError, version
24
+
25
+ try:
26
+ return version("pygls")
27
+ except PackageNotFoundError:
28
+ return "unknown"
29
+
30
+
31
+ @app.command(
32
+ cls=ExecsqlCommand,
33
+ name="lsp",
34
+ help=(
35
+ "Run the execsql language server on stdin/stdout, for an editor to start. "
36
+ "Shows execsql lint findings as you type, completes metacommands, "
37
+ "conditional tests and variables, explains them on hover, and finds where variables, "
38
+ "SCRIPTs and included files are defined, offers quick fixes, and formats documents. Requires the lsp "
39
+ "extra; --check reports whether it is installed."
40
+ ),
41
+ )
42
+ def lsp_cmd(
43
+ # Editors' language clients pass --stdio (VS Code always does); stdio is the only transport.
44
+ stdio: bool = typer.Option(False, "--stdio", hidden=True),
45
+ check: bool = typer.Option(False, "--check", help="Report whether the language server can start, and exit."),
46
+ ) -> None:
47
+ """The ``lsp`` command; its user-facing help is the decorator's ``help``."""
48
+ try:
49
+ from execsql.lsp.server import create_server
50
+ except ImportError as exc:
51
+ if _lsp_libraries_installed():
52
+ # pygls is there but this execsql cannot use it (another major version, a broken install).
53
+ message = (
54
+ f"execsql lsp cannot use the installed pygls {_pygls_version()} ({exc}). "
55
+ f"It needs pygls 2.x, which the lsp extra installs. {install_hint('lsp')}"
56
+ )
57
+ else:
58
+ message = f"execsql lsp needs the lsp extra. {install_hint('lsp')}"
59
+ _err_console.print(f"[bold red]Error:[/bold red] {escape(message)}", highlight=False)
60
+ raise typer.Exit(code=1) from exc
61
+ if check:
62
+ typer.echo(f"execsql lsp is ready (pygls {_pygls_version()}).")
63
+ return
64
+ create_server().start_io()
execsql/cli/dispatch.py CHANGED
@@ -29,7 +29,7 @@ from __future__ import annotations
29
29
  __all__ = ["COMMANDS", "GLOBAL_FLAGS", "dispatch", "normalize"]
30
30
 
31
31
  #: Tokens that name a command.
32
- COMMANDS = ("run", "format", "lint", "config", "list", "init")
32
+ COMMANDS = ("run", "format", "lint", "config", "list", "init", "lsp")
33
33
 
34
34
  #: Options declared on the app rather than on a command, which must reach
35
35
  #: the parser without ``run`` in front of them. ``-m``, ``--encodings`` and
execsql/cli/help.py CHANGED
@@ -19,7 +19,9 @@ __all__ = [
19
19
  "_init_config",
20
20
  "_init_config_text",
21
21
  "_keywords_data",
22
- "_metacommand_rows",
22
+ "_metacommand_entries",
23
+ "_metacommand_json",
24
+ "_print_metacommand",
23
25
  "_plugins_data",
24
26
  "_print_encodings",
25
27
  "_print_keywords_json",
@@ -31,66 +33,6 @@ __all__ = [
31
33
  _console = Console()
32
34
  _err_console = Console(stderr=True)
33
35
 
34
- # ---------------------------------------------------------------------------
35
- # Metacommand syntax hints — paired with keywords from the dispatch table.
36
- # Keys must match the ``description`` values used in mcl.add() calls.
37
- # Entries here are validated by tests/test_registry.py.
38
- # ---------------------------------------------------------------------------
39
-
40
- _SYNTAX: dict[str, tuple[str, str]] = {
41
- # (display_name, syntax_hint)
42
- "ASK": ("ASK", '"<question>" SUB <match_string>'),
43
- "AUTOCOMMIT": ("AUTOCOMMIT", "ON|OFF"),
44
- "BEGIN BATCH": ("BEGIN BATCH / END BATCH / ROLLBACK BATCH", ""),
45
- "BEGIN SCRIPT": ("BEGIN SCRIPT / END SCRIPT", ""),
46
- "BEGIN SQL": ("BEGIN SQL / END SQL", ""),
47
- "CANCEL_HALT": ("CANCEL_HALT", "ON|OFF"),
48
- "CD": ("CD", "<directory>"),
49
- "CONNECT": ("CONNECT", "<alias> [AS <alias_name>]"),
50
- "COPY": ("COPY", "<source_file> TO <dest_file>"),
51
- "DEBUG": ("DEBUG", "ON|OFF"),
52
- "SUB": ("DEFINE SUB", "<variable> [AS] <value>"),
53
- "EXPORT QUERY": ("EXPORT QUERY", "<queryname> [AS <alias>] ..."),
54
- "EXPORT": ("EXPORT", "<queryname> TO <format> <filename> ..."),
55
- "HALT": ("HALT [ON]", "ERROR|CANCEL"),
56
- "IF": ("IF <condition>", "/ ELSE / ENDIF"),
57
- "IMPORT_FILE": ("IMPORT FILE", "<filename> [OPTIONS ...]"),
58
- "IMPORT": ("IMPORT TABLE", "<tablename> FROM FILE <filename> [OPTIONS ...]"),
59
- "LOOP": ("LOOP <n> TIMES | WHILE | UNTIL", "/ END LOOP"),
60
- "CONFIG": ("CONFIG", "<option> <value>"),
61
- "ON CANCEL_HALT": ("ON CANCEL_HALT", "..."),
62
- "ON ERROR_HALT": ("ON ERROR_HALT", "..."),
63
- "PAUSE": ("PAUSE", "[<text>]"),
64
- "PROMPT ACTION": ("PROMPT ACTION", "..."),
65
- "PROMPT ENTRY_FORM": ("PROMPT ENTRY_FORM", "..."),
66
- "PROMPT OPENFILE": ("PROMPT OPENFILE", "..."),
67
- "PROMPT SAVEFILE": ("PROMPT SAVEFILE", "..."),
68
- "PROMPT DIRECTORY": ("PROMPT DIRECTORY", "..."),
69
- "PROMPT MAP": ("PROMPT MAP", "..."),
70
- "ROLLBACK BATCH": ("ROLLBACK", ""),
71
- "SERVE": ("SERVE", "<queryname> ..."),
72
- "SYSTEM_CMD": ("SYSTEM_CMD", "(<operating system command line>)"),
73
- "TIMER": ("TIMER", "ON|OFF"),
74
- "USE": ("USE", "<alias_name>"),
75
- "WAIT_UNTIL": ("WAIT_UNTIL", "<Boolean_expression> <HALT|CONTINUE> AFTER <n> SECONDS"),
76
- "WRITE": ("WRITE", '"<text>" [[TEE] TO <output>]'),
77
- "WRITE CREATE_TABLE": ("WRITE CREATE_TABLE FROM", "<filename> [TO <output>]"),
78
- "WRITE SCRIPT": ("WRITE SCRIPT", "<script_name> [[APPEND] TO <output_file>]"),
79
- "ZIP": ("ZIP", "<filename> [APPEND] TO ZIPFILE <zipfilename>"),
80
- "SUB_TEMPFILE": ("SUB_TEMPFILE", "<variable>"),
81
- }
82
-
83
- # Keys from _SYNTAX that should be skipped when auto-generating from dispatch
84
- # table (they're variants covered by another entry).
85
- _SKIP_FROM_DISPATCH = {
86
- "END BATCH",
87
- "END SCRIPT",
88
- "END SQL",
89
- "ROLLBACK BATCH",
90
- "BEGIN SCRIPT",
91
- "BEGIN SQL",
92
- }
93
-
94
36
 
95
37
  def _init_config_text() -> str:
96
38
  """The default execsql.conf template, every option commented out and documented."""
@@ -106,56 +48,62 @@ def _init_config() -> None:
106
48
  sys.stdout.write(_init_config_text())
107
49
 
108
50
 
109
- def _metacommand_rows() -> list[tuple[str, str]]:
110
- """``(name, syntax)`` for every metacommand, in the order they are listed.
51
+ _CATEGORY_ORDER = ("control", "block", "action", "config", "prompt")
111
52
 
112
- Keyword list is derived from the dispatch table; syntax hints come from
113
- the ``_SYNTAX`` dict above. Keywords not in ``_SYNTAX`` get an empty
114
- syntax hint.
115
- """
116
- from execsql.metacommands import DISPATCH_TABLE
117
-
118
- # Collect unique keyword names from the dispatch table.
119
- seen: set[str] = set()
120
- keywords: list[str] = []
121
- for mc in DISPATCH_TABLE:
122
- if mc.description and mc.description not in seen and mc.description not in _SKIP_FROM_DISPATCH:
123
- seen.add(mc.description)
124
- keywords.append(mc.description)
125
- # Add parser-level keywords not in the dispatch table.
126
- for extra in ("BEGIN BATCH", "BEGIN SCRIPT", "BEGIN SQL"):
127
- if extra not in seen:
128
- seen.add(extra)
129
- keywords.append(extra)
130
-
131
- rows: list[tuple[str, str]] = []
132
- for kw in sorted(keywords):
133
- if kw in _SYNTAX:
134
- rows.append(_SYNTAX[kw])
135
- elif kw.startswith("CONFIG ") or kw.startswith("CONSOLE_") or "_" in kw:
136
- continue # skip config options / internal entries
137
- else:
138
- rows.append((kw, ""))
139
- return rows
53
+
54
+ def _metacommand_entries() -> list[Any]:
55
+ """Every metacommand's readable entry, by category, then keyword."""
56
+ from execsql.metacommands.reference import metacommands
57
+
58
+ order = {c: i for i, c in enumerate(_CATEGORY_ORDER)}
59
+ return sorted(metacommands(), key=lambda m: (order.get(m.category, len(order)), m.keyword))
60
+
61
+
62
+ def _metacommand_json(entry: Any) -> dict[str, Any]:
63
+ return {
64
+ "name": entry.keyword,
65
+ "category": entry.category,
66
+ "syntax": entry.forms[0],
67
+ "forms": list(entry.forms),
68
+ "summary": entry.summary,
69
+ "url": entry.url,
70
+ }
140
71
 
141
72
 
142
73
  def _print_metacommands() -> None:
143
- """Print the metacommands table using Rich."""
74
+ """Print every metacommand with its category and one-line summary."""
144
75
  table = Table(
145
76
  title="execsql Metacommands",
146
- caption="Embed in SQL comment lines following the [bold]!x![/bold] token.",
77
+ caption=(
78
+ "Write them in a SQL comment after [bold]-- !x![/bold]. "
79
+ "[bold]execsql list metacommands <KEYWORD>[/bold] shows the syntax of one."
80
+ ),
147
81
  show_header=True,
148
82
  header_style="bold cyan",
149
83
  border_style="dim",
150
84
  expand=False,
151
85
  )
152
86
  table.add_column("Metacommand", style="bold green", no_wrap=True)
153
- table.add_column("Syntax", style="white")
154
- for name, syntax in _metacommand_rows():
155
- table.add_row(name, syntax)
87
+ table.add_column("Category", style="dim", no_wrap=True)
88
+ table.add_column("Summary", style="white")
89
+ for entry in _metacommand_entries():
90
+ table.add_row(entry.keyword, entry.category, entry.summary)
156
91
  _console.print(table)
157
92
 
158
93
 
94
+ def _print_metacommand(entry: Any) -> None:
95
+ """Print one metacommand's syntax lines, summary and docs link."""
96
+ from rich.markup import escape
97
+
98
+ _console.print(f"[bold green]{escape(entry.keyword)}[/bold green] [dim]{entry.category}[/dim]")
99
+ _console.print(escape(entry.summary))
100
+ _console.print()
101
+ for form in entry.forms:
102
+ _console.print(f" -- !x! {escape(form)}", highlight=False)
103
+ _console.print()
104
+ _console.print(f"[dim]{entry.url}[/dim]", highlight=False)
105
+
106
+
159
107
  def _encoding_names() -> list[str]:
160
108
  """Every encoding name Python accepts, sorted."""
161
109
  return sorted(codec_dict.keys())
execsql/cli/lint.py CHANGED
@@ -48,6 +48,7 @@ Public surface:
48
48
 
49
49
  from __future__ import annotations
50
50
 
51
+ import functools
51
52
  import json
52
53
  import re
53
54
  from collections import Counter
@@ -82,6 +83,7 @@ __all__ = [
82
83
  "filter_issues",
83
84
  "lint",
84
85
  "lint_source",
86
+ "variables_defined_by",
85
87
  "parse_error",
86
88
  "print_concise",
87
89
  "print_statistics",
@@ -430,6 +432,18 @@ def _extract_var_definition(
430
432
  _read_ini_vars(ini_file, ini_section, script_dir, defined)
431
433
 
432
434
 
435
+ def variables_defined_by(command: str, script_dir: Path | None = None) -> set[str]:
436
+ """The variable names (upper case, without ``~``/``+``) a metacommand defines.
437
+
438
+ *command* is the text after ``-- !x!``. Covers the SUB family, the
439
+ prompts that store an answer, SUB_QUERYSTRING and SUB_INI (whose INI file
440
+ is read, relative to *script_dir*).
441
+ """
442
+ defined: set[str] = set()
443
+ _extract_var_definition(command, script_dir, defined)
444
+ return defined
445
+
446
+
433
447
  # Larger than any real SUB_INI file; lint reads no more than this.
434
448
  _MAX_INI_BYTES = 1024 * 1024
435
449
 
@@ -447,23 +461,40 @@ def _read_ini_vars(
447
461
  language server; a file that is unreadable or not valid INI defines
448
462
  nothing.
449
463
  """
450
- from configparser import ConfigParser
451
- from configparser import Error as ConfigError
452
-
453
464
  p = Path(ini_file)
454
465
  if not p.is_absolute() and script_dir is not None:
455
466
  p = script_dir / p
467
+ try:
468
+ if not p.is_file():
469
+ return
470
+ stat = p.stat()
471
+ if stat.st_size > _MAX_INI_BYTES:
472
+ return
473
+ resolved = str(p.resolve())
474
+ except OSError:
475
+ return
476
+ defined_vars.update(_ini_section_keys(resolved, stat.st_size, stat.st_mtime_ns, section))
477
+
478
+
479
+ @functools.lru_cache(maxsize=64)
480
+ def _ini_section_keys(path: str, size: int, mtime_ns: int, section: str) -> frozenset[str]:
481
+ """The upper-cased keys of *section* in the INI file at *path*.
482
+
483
+ Cached by the file's size and modification time: a script that names
484
+ the same file on many SUB_INI lines, and the language server re-linting
485
+ on every edit, read it once.
486
+ """
487
+ from configparser import ConfigParser
488
+ from configparser import Error as ConfigError
456
489
 
457
490
  cp = ConfigParser()
458
491
  try:
459
- if not p.is_file() or p.stat().st_size > _MAX_INI_BYTES:
460
- return
461
- cp.read_string(p.read_text(encoding="utf-8"), source=str(p))
492
+ cp.read_string(Path(path).read_text(encoding="utf-8"), source=path)
462
493
  except (OSError, UnicodeDecodeError, ConfigError):
463
- return
464
- if cp.has_section(section):
465
- for key, _value in cp.items(section):
466
- defined_vars.add(key.upper())
494
+ return frozenset()
495
+ if not cp.has_section(section):
496
+ return frozenset()
497
+ return frozenset(key.upper() for key, _value in cp.items(section))
467
498
 
468
499
 
469
500
  def _is_known_var(raw_name: str, defined_vars: set[str]) -> bool:
@@ -821,7 +852,12 @@ def _referencing_text(node: Node) -> list[str]:
821
852
  return texts
822
853
 
823
854
 
824
- def _check_unused_variables(script: Script, defined: set[str], issues: list[_Issue]) -> None:
855
+ def _check_unused_variables(
856
+ script: Script,
857
+ defined: set[str],
858
+ issues: list[_Issue],
859
+ script_dir: Path | None = None,
860
+ ) -> None:
825
861
  """Report variables a SUB defines that nothing ever reads.
826
862
 
827
863
  Almost always a typo in either the definition or the reference — the two
@@ -832,8 +868,11 @@ def _check_unused_variables(script: Script, defined: set[str], issues: list[_Iss
832
868
  referenced: set[str] = set()
833
869
  _collect_var_references(script.body, referenced)
834
870
  unused = sorted(defined - referenced)
871
+ lines: dict[str, int] = {}
872
+ if unused:
873
+ _definition_lines(script.body, script_dir, lines)
835
874
  for name in unused:
836
- line = _definition_line(script.body, name)
875
+ line = lines.get(name, 0)
837
876
  issues.append(
838
877
  _issue(
839
878
  "V002",
@@ -844,18 +883,15 @@ def _check_unused_variables(script: Script, defined: set[str], issues: list[_Iss
844
883
  )
845
884
 
846
885
 
847
- def _definition_line(nodes: list[Node], name: str) -> int:
848
- """Line of the SUB that defines *name*, or 0 when it cannot be located."""
886
+ def _definition_lines(nodes: list[Node], script_dir: Path | None, lines: dict[str, int]) -> None:
887
+ """Add to *lines* the line of the first metacommand that defines each variable, in script order."""
849
888
  for node in nodes:
850
889
  if isinstance(node, MetaCommandStatement):
851
890
  probe: set[str] = set()
852
- _extract_var_definition(node.command, None, probe)
853
- if name in probe:
854
- return node.span.start_line
855
- found = _definition_line(list(node.children()), name)
856
- if found:
857
- return found
858
- return 0
891
+ _extract_var_definition(node.command, script_dir, probe)
892
+ for name in probe:
893
+ lines.setdefault(name, node.span.start_line)
894
+ _definition_lines(list(node.children()), script_dir, lines)
859
895
 
860
896
 
861
897
  # ---------------------------------------------------------------------------
@@ -995,7 +1031,7 @@ def lint(
995
1031
  )
996
1032
 
997
1033
  # Pass 3: whole-script rules, which need every reference collected first
998
- _check_unused_variables(script, all_defined, issues)
1034
+ _check_unused_variables(script, all_defined, issues, script_dir)
999
1035
 
1000
1036
  return issues
1001
1037
 
execsql/db/access.py CHANGED
@@ -17,7 +17,7 @@ from typing import Any, cast
17
17
  from execsql.db.base import Database, _fetched
18
18
  from execsql.db.tiers import SupportTier
19
19
  from execsql.exceptions import ErrInfo
20
- from execsql.utils.errors import exception_desc, fatal_error
20
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
21
21
  from execsql.utils.auth import (
22
22
  begin_login,
23
23
  clear_stored_password,
@@ -68,7 +68,7 @@ class AccessDatabase(Database):
68
68
  try:
69
69
  import pyodbc # noqa: F401
70
70
  except Exception:
71
- fatal_error("The pyodbc module is required. See http://github.com/mkleehammer/pyodbc")
71
+ fatal_error(f"The pyodbc module is required to connect to Access. {install_hint('odbc')}")
72
72
  from execsql.types import dbt_access
73
73
 
74
74
  super().__init__(
@@ -327,8 +327,8 @@ class AccessDatabase(Database):
327
327
  rows = curs.fetchall()
328
328
  return [d[0] for d in curs.description], rows
329
329
 
330
- def select_rowsource(self, sql: str) -> tuple[list[str], Any]:
331
- """Return column names and an iterable that yields rows one at a time."""
330
+ def select_rowsource(self, sql: str, *, decode_bytes: bool = True) -> tuple[list[str], Any]: # noqa: ARG002
331
+ """Return column names and an iterable that yields rows one at a time (values as the driver returns them)."""
332
332
  # Return 1) a list of column names, and 2) an iterable that yields rows.
333
333
  self.dao_flush_check()
334
334
  curs = self.cursor()
execsql/db/base.py CHANGED
@@ -405,8 +405,13 @@ class Database(ABC):
405
405
  rows = curs.fetchall()
406
406
  return [d[0] for d in curs.description], rows
407
407
 
408
- def select_rowsource(self, sql: str) -> tuple[list[str], Generator]:
409
- """Execute *sql* and return ``(column_names, row_generator)`` for streaming large result sets."""
408
+ def select_rowsource(self, sql: str, *, decode_bytes: bool = True) -> tuple[list[str], Generator]:
409
+ """Execute *sql* and return ``(column_names, row_generator)`` for streaming large result sets.
410
+
411
+ A binary value (``bytes``) is decoded to text with the connection's
412
+ encoding, as the exporters expect, unless *decode_bytes* is false:
413
+ COPY passes false so binary columns reach the target unchanged.
414
+ """
410
415
  # Return 1) a list of column names, and 2) an iterable that yields rows.
411
416
  curs = self.cursor()
412
417
  try:
@@ -433,7 +438,7 @@ class Database(ABC):
433
438
  break
434
439
  else:
435
440
  for row in rows:
436
- if self.encoding:
441
+ if self.encoding and decode_bytes:
437
442
  yield [
438
443
  c.decode(self.encoding, "backslashreplace") if isinstance(c, bytes) else c
439
444
  for c in row
@@ -501,6 +506,15 @@ class Database(ABC):
501
506
  """The schema (MySQL: database) an unqualified table name is created in, or ``None`` if unknown."""
502
507
  return None
503
508
 
509
+ def table_exists_where_created(self, table_name: str, schema_name: str | None = None) -> bool:
510
+ """Whether *table_name* exists where a ``CREATE TABLE`` on this connection would put it.
511
+
512
+ The same as :meth:`table_exists`, except on a connection that sees
513
+ tables of other databases (DuckDB ``ATTACH``): only the current
514
+ database counts there.
515
+ """
516
+ return self.table_exists(table_name, schema_name)
517
+
504
518
  def _query_current_schema(self, sql: str) -> str | None:
505
519
  with self._cursor() as curs:
506
520
  curs.execute(sql)
execsql/db/dsn.py CHANGED
@@ -12,7 +12,7 @@ the CLI.
12
12
  from execsql.db.base import Database
13
13
  from execsql.db.tiers import SupportTier
14
14
  from execsql.exceptions import ErrInfo
15
- from execsql.utils.errors import exception_desc, fatal_error
15
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
16
16
  from execsql.utils.auth import (
17
17
  begin_login,
18
18
  clear_stored_password,
@@ -51,7 +51,7 @@ class DsnDatabase(Database):
51
51
  try:
52
52
  import pyodbc # noqa: F401
53
53
  except Exception:
54
- fatal_error("The pyodbc module is required. See http://github.com/mkleehammer/pyodbc")
54
+ fatal_error(f"The pyodbc module is required to connect through an ODBC DSN. {install_hint('odbc')}")
55
55
  from execsql.types import dbt_dsn
56
56
 
57
57
  super().__init__(
execsql/db/duckdb.py CHANGED
@@ -14,7 +14,7 @@ from typing import Any
14
14
 
15
15
  from execsql.db.base import Database, statement_start
16
16
  from execsql.exceptions import ErrInfo
17
- from execsql.utils.errors import exception_desc, fatal_error
17
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
18
18
  import execsql.state as _state
19
19
 
20
20
  __all__ = ["DuckDBDatabase"]
@@ -62,7 +62,7 @@ class DuckDBDatabase(Database):
62
62
  try:
63
63
  import duckdb # noqa: F401
64
64
  except Exception:
65
- fatal_error("The duckdb module is required.")
65
+ fatal_error(f"The duckdb module is required to use DuckDB. {install_hint('duckdb')}")
66
66
  from execsql.types import dbt_duckdb
67
67
 
68
68
  super().__init__(
@@ -167,6 +167,18 @@ class DuckDBDatabase(Database):
167
167
  self.rollback()
168
168
  raise
169
169
 
170
+ def table_exists_where_created(self, table_name: str, schema_name: str | None = None) -> bool:
171
+ """Whether *table_name* exists in the current database (not an ``ATTACH``ed one)."""
172
+ # information_schema.tables lists the tables of every attached database.
173
+ sql = "select table_name from information_schema.tables where table_name = ? and table_catalog = current_database()"
174
+ params: list = [table_name]
175
+ if schema_name:
176
+ sql += " and table_schema = ?"
177
+ params.append(schema_name)
178
+ with self._cursor() as curs:
179
+ curs.execute(sql, params)
180
+ return len(curs.fetchall()) > 0
181
+
170
182
  def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
171
183
  """Return True if the named view exists in the DuckDB database."""
172
184
  # DuckDB information_schema has no 'views' table; views are listed in 'tables'
execsql/db/firebird.py CHANGED
@@ -11,7 +11,7 @@ via the ``firebird-driver`` package. Corresponds to ``-t f`` on the CLI.
11
11
  from execsql.db.base import Database
12
12
  from execsql.db.tiers import SupportTier
13
13
  from execsql.exceptions import ErrInfo
14
- from execsql.utils.errors import exception_desc, fatal_error
14
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
15
15
  from execsql.utils.auth import (
16
16
  begin_login,
17
17
  clear_stored_password,
@@ -46,8 +46,7 @@ class FirebirdDatabase(Database):
46
46
  from firebird import driver as firebird_lib # noqa: F401
47
47
  except Exception:
48
48
  fatal_error(
49
- "The firebird-driver module is required to connect to Firebird. "
50
- "See https://pypi.org/project/firebird-driver/",
49
+ f"The firebird-driver module is required to connect to Firebird. {install_hint('firebird')}",
51
50
  )
52
51
  from execsql.types import dbt_firebird
53
52
 
execsql/db/mysql.py CHANGED
@@ -12,7 +12,7 @@ from typing import Any, cast
12
12
 
13
13
  from execsql.db.base import Database
14
14
  from execsql.exceptions import ErrInfo
15
- from execsql.utils.errors import exception_desc, fatal_error
15
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
16
16
  from execsql.utils.auth import (
17
17
  begin_login,
18
18
  clear_stored_password,
@@ -81,7 +81,7 @@ class MySQLDatabase(Database):
81
81
  import pymysql as mysql_lib # noqa: F401
82
82
  except Exception:
83
83
  fatal_error(
84
- "The pymysql module is required to connect to MySQL. See https://pypi.python.org/pypi/PyMySQL",
84
+ f"The pymysql module is required to connect to MySQL. {install_hint('mysql')}",
85
85
  )
86
86
  from execsql.types import dbt_mysql
87
87