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.
- execsql/cli/commands/__init__.py +1 -0
- execsql/cli/commands/format.py +2 -1
- execsql/cli/commands/listing.py +32 -5
- execsql/cli/commands/lsp.py +64 -0
- execsql/cli/dispatch.py +1 -1
- execsql/cli/help.py +44 -96
- execsql/cli/lint.py +58 -22
- execsql/db/access.py +4 -4
- execsql/db/base.py +17 -3
- execsql/db/dsn.py +2 -2
- execsql/db/duckdb.py +14 -2
- execsql/db/firebird.py +2 -3
- execsql/db/mysql.py +2 -2
- execsql/db/oracle.py +5 -5
- execsql/db/postgres.py +2 -3
- execsql/db/sqlserver.py +2 -2
- execsql/exporters/duckdb.py +2 -2
- execsql/exporters/ods.py +2 -2
- execsql/exporters/templates.py +2 -2
- execsql/exporters/xls.py +3 -3
- execsql/exporters/xlsx.py +2 -2
- execsql/exporters/yaml.py +3 -4
- execsql/format.py +71 -21
- execsql/importers/base.py +6 -10
- execsql/lsp/__init__.py +6 -0
- execsql/lsp/code_actions.py +217 -0
- execsql/lsp/completion.py +203 -0
- execsql/lsp/diagnostics.py +66 -0
- execsql/lsp/document.py +210 -0
- execsql/lsp/folding.py +59 -0
- execsql/lsp/formatting.py +37 -0
- execsql/lsp/hover.py +119 -0
- execsql/lsp/navigation.py +169 -0
- execsql/lsp/rename.py +91 -0
- execsql/lsp/server.py +358 -0
- execsql/metacommands/debug.py +2 -2
- execsql/metacommands/dispatch.py +7 -0
- execsql/metacommands/io_fileops.py +6 -6
- execsql/metacommands/reference.py +1404 -0
- execsql/metacommands/upsert.py +2 -5
- execsql/models.py +11 -0
- execsql/utils/errors.py +16 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/METADATA +34 -18
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/RECORD +62 -49
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/WHEEL +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/NOTICE +0 -0
execsql/cli/commands/__init__.py
CHANGED
execsql/cli/commands/format.py
CHANGED
|
@@ -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(
|
execsql/cli/commands/listing.py
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
110
|
-
"""``(name, syntax)`` for every metacommand, in the order they are listed.
|
|
51
|
+
_CATEGORY_ORDER = ("control", "block", "action", "config", "prompt")
|
|
111
52
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
74
|
+
"""Print every metacommand with its category and one-line summary."""
|
|
144
75
|
table = Table(
|
|
145
76
|
title="execsql Metacommands",
|
|
146
|
-
caption=
|
|
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("
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
848
|
-
"""
|
|
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,
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|