execsql2 2.27.0__py3-none-any.whl → 2.29.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/format.py +2 -1
- execsql/cli/commands/lsp.py +33 -9
- execsql/cli/lint.py +45 -22
- execsql/db/access.py +4 -4
- execsql/db/base.py +23 -12
- execsql/db/dsn.py +2 -2
- execsql/db/duckdb.py +14 -2
- execsql/db/firebird.py +2 -3
- execsql/db/mysql.py +21 -5
- execsql/db/oracle.py +5 -5
- execsql/db/postgres.py +3 -4
- 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 +6 -5
- 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/document.py +7 -3
- execsql/metacommands/debug.py +2 -2
- execsql/metacommands/io_fileops.py +6 -6
- execsql/metacommands/upsert.py +2 -5
- execsql/models.py +11 -0
- execsql/script/variables.py +42 -10
- execsql/types.py +8 -0
- execsql/utils/errors.py +16 -0
- {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/METADATA +4 -2
- {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/RECORD +48 -48
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/WHEEL +0 -0
- {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/licenses/NOTICE +0 -0
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/lsp.py
CHANGED
|
@@ -3,13 +3,31 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import typer
|
|
6
|
+
from rich.markup import escape
|
|
6
7
|
|
|
7
8
|
from execsql.cli.application import ExecsqlCommand, app
|
|
8
9
|
from execsql.cli.help import _err_console
|
|
10
|
+
from execsql.utils.errors import install_hint
|
|
9
11
|
|
|
10
12
|
__all__ = ["lsp_cmd"]
|
|
11
13
|
|
|
12
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
|
+
|
|
13
31
|
@app.command(
|
|
14
32
|
cls=ExecsqlCommand,
|
|
15
33
|
name="lsp",
|
|
@@ -17,24 +35,30 @@ __all__ = ["lsp_cmd"]
|
|
|
17
35
|
"Run the execsql language server on stdin/stdout, for an editor to start. "
|
|
18
36
|
"Shows execsql lint findings as you type, completes metacommands, "
|
|
19
37
|
"conditional tests and variables, explains them on hover, and finds where variables, "
|
|
20
|
-
"SCRIPTs and included files are defined, offers quick fixes, and formats documents.
|
|
21
|
-
"
|
|
38
|
+
"SCRIPTs and included files are defined, offers quick fixes, and formats documents. Requires the lsp "
|
|
39
|
+
"extra; --check reports whether it is installed."
|
|
22
40
|
),
|
|
23
41
|
)
|
|
24
42
|
def lsp_cmd(
|
|
25
43
|
# Editors' language clients pass --stdio (VS Code always does); stdio is the only transport.
|
|
26
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."),
|
|
27
46
|
) -> None:
|
|
28
47
|
"""The ``lsp`` command; its user-facing help is the decorator's ``help``."""
|
|
29
48
|
try:
|
|
30
49
|
from execsql.lsp.server import create_server
|
|
31
50
|
except ImportError as exc:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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)
|
|
39
60
|
raise typer.Exit(code=1) from exc
|
|
61
|
+
if check:
|
|
62
|
+
typer.echo(f"execsql lsp is ready (pygls {_pygls_version()}).")
|
|
63
|
+
return
|
|
40
64
|
create_server().start_io()
|
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
|
|
@@ -460,23 +461,40 @@ def _read_ini_vars(
|
|
|
460
461
|
language server; a file that is unreadable or not valid INI defines
|
|
461
462
|
nothing.
|
|
462
463
|
"""
|
|
463
|
-
from configparser import ConfigParser
|
|
464
|
-
from configparser import Error as ConfigError
|
|
465
|
-
|
|
466
464
|
p = Path(ini_file)
|
|
467
465
|
if not p.is_absolute() and script_dir is not None:
|
|
468
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
|
|
469
489
|
|
|
470
490
|
cp = ConfigParser()
|
|
471
491
|
try:
|
|
472
|
-
|
|
473
|
-
return
|
|
474
|
-
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)
|
|
475
493
|
except (OSError, UnicodeDecodeError, ConfigError):
|
|
476
|
-
return
|
|
477
|
-
if cp.has_section(section):
|
|
478
|
-
|
|
479
|
-
|
|
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))
|
|
480
498
|
|
|
481
499
|
|
|
482
500
|
def _is_known_var(raw_name: str, defined_vars: set[str]) -> bool:
|
|
@@ -834,7 +852,12 @@ def _referencing_text(node: Node) -> list[str]:
|
|
|
834
852
|
return texts
|
|
835
853
|
|
|
836
854
|
|
|
837
|
-
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:
|
|
838
861
|
"""Report variables a SUB defines that nothing ever reads.
|
|
839
862
|
|
|
840
863
|
Almost always a typo in either the definition or the reference — the two
|
|
@@ -845,8 +868,11 @@ def _check_unused_variables(script: Script, defined: set[str], issues: list[_Iss
|
|
|
845
868
|
referenced: set[str] = set()
|
|
846
869
|
_collect_var_references(script.body, referenced)
|
|
847
870
|
unused = sorted(defined - referenced)
|
|
871
|
+
lines: dict[str, int] = {}
|
|
872
|
+
if unused:
|
|
873
|
+
_definition_lines(script.body, script_dir, lines)
|
|
848
874
|
for name in unused:
|
|
849
|
-
line =
|
|
875
|
+
line = lines.get(name, 0)
|
|
850
876
|
issues.append(
|
|
851
877
|
_issue(
|
|
852
878
|
"V002",
|
|
@@ -857,18 +883,15 @@ def _check_unused_variables(script: Script, defined: set[str], issues: list[_Iss
|
|
|
857
883
|
)
|
|
858
884
|
|
|
859
885
|
|
|
860
|
-
def
|
|
861
|
-
"""
|
|
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."""
|
|
862
888
|
for node in nodes:
|
|
863
889
|
if isinstance(node, MetaCommandStatement):
|
|
864
890
|
probe: set[str] = set()
|
|
865
|
-
_extract_var_definition(node.command,
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
if found:
|
|
870
|
-
return found
|
|
871
|
-
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)
|
|
872
895
|
|
|
873
896
|
|
|
874
897
|
# ---------------------------------------------------------------------------
|
|
@@ -1008,7 +1031,7 @@ def lint(
|
|
|
1008
1031
|
)
|
|
1009
1032
|
|
|
1010
1033
|
# Pass 3: whole-script rules, which need every reference collected first
|
|
1011
|
-
_check_unused_variables(script, all_defined, issues)
|
|
1034
|
+
_check_unused_variables(script, all_defined, issues, script_dir)
|
|
1012
1035
|
|
|
1013
1036
|
return issues
|
|
1014
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
|
@@ -215,21 +215,18 @@ class Database(ABC):
|
|
|
215
215
|
def quote_literal(self, value: Any) -> str:
|
|
216
216
|
"""Return *value* as a SQL string literal, safely escaped.
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
Override per-DBMS only when the wire protocol requires a different
|
|
225
|
-
literal form.
|
|
218
|
+
Standard SQL: wrap in single quotes and double embedded apostrophes;
|
|
219
|
+
a backslash is an ordinary character. Embedded NUL bytes are
|
|
220
|
+
rejected (most wire protocols truncate or reject them). A DBMS
|
|
221
|
+
that reads a backslash in a literal as an escape (MySQL) overrides
|
|
222
|
+
this.
|
|
226
223
|
"""
|
|
227
224
|
if value is None:
|
|
228
225
|
return "NULL"
|
|
229
226
|
s = str(value)
|
|
230
227
|
if "\x00" in s:
|
|
231
228
|
raise ValueError("SQL literal contains a NUL byte; refusing to quote.")
|
|
232
|
-
return "'" + s.replace("
|
|
229
|
+
return "'" + s.replace("'", "''") + "'"
|
|
233
230
|
|
|
234
231
|
def paramsubs(self, paramcount: int) -> str:
|
|
235
232
|
"""Return a comma-separated string of *paramcount* parameter placeholders."""
|
|
@@ -405,8 +402,13 @@ class Database(ABC):
|
|
|
405
402
|
rows = curs.fetchall()
|
|
406
403
|
return [d[0] for d in curs.description], rows
|
|
407
404
|
|
|
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.
|
|
405
|
+
def select_rowsource(self, sql: str, *, decode_bytes: bool = True) -> tuple[list[str], Generator]:
|
|
406
|
+
"""Execute *sql* and return ``(column_names, row_generator)`` for streaming large result sets.
|
|
407
|
+
|
|
408
|
+
A binary value (``bytes``) is decoded to text with the connection's
|
|
409
|
+
encoding, as the exporters expect, unless *decode_bytes* is false:
|
|
410
|
+
COPY passes false so binary columns reach the target unchanged.
|
|
411
|
+
"""
|
|
410
412
|
# Return 1) a list of column names, and 2) an iterable that yields rows.
|
|
411
413
|
curs = self.cursor()
|
|
412
414
|
try:
|
|
@@ -433,7 +435,7 @@ class Database(ABC):
|
|
|
433
435
|
break
|
|
434
436
|
else:
|
|
435
437
|
for row in rows:
|
|
436
|
-
if self.encoding:
|
|
438
|
+
if self.encoding and decode_bytes:
|
|
437
439
|
yield [
|
|
438
440
|
c.decode(self.encoding, "backslashreplace") if isinstance(c, bytes) else c
|
|
439
441
|
for c in row
|
|
@@ -501,6 +503,15 @@ class Database(ABC):
|
|
|
501
503
|
"""The schema (MySQL: database) an unqualified table name is created in, or ``None`` if unknown."""
|
|
502
504
|
return None
|
|
503
505
|
|
|
506
|
+
def table_exists_where_created(self, table_name: str, schema_name: str | None = None) -> bool:
|
|
507
|
+
"""Whether *table_name* exists where a ``CREATE TABLE`` on this connection would put it.
|
|
508
|
+
|
|
509
|
+
The same as :meth:`table_exists`, except on a connection that sees
|
|
510
|
+
tables of other databases (DuckDB ``ATTACH``): only the current
|
|
511
|
+
database counts there.
|
|
512
|
+
"""
|
|
513
|
+
return self.table_exists(table_name, schema_name)
|
|
514
|
+
|
|
504
515
|
def _query_current_schema(self, sql: str) -> str | None:
|
|
505
516
|
with self._cursor() as curs:
|
|
506
517
|
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,
|
|
@@ -67,6 +67,20 @@ class MySQLDatabase(Database):
|
|
|
67
67
|
re.I,
|
|
68
68
|
)
|
|
69
69
|
|
|
70
|
+
def quote_literal(self, value: Any) -> str:
|
|
71
|
+
"""Return *value* as a string literal for MySQL/MariaDB.
|
|
72
|
+
|
|
73
|
+
Unless ``NO_BACKSLASH_ESCAPES`` is set, MySQL reads a backslash in a
|
|
74
|
+
literal as an escape, so ``\\'`` would end it: backslashes are doubled
|
|
75
|
+
as well as apostrophes.
|
|
76
|
+
"""
|
|
77
|
+
if value is None:
|
|
78
|
+
return "NULL"
|
|
79
|
+
s = str(value)
|
|
80
|
+
if "\x00" in s:
|
|
81
|
+
raise ValueError("SQL literal contains a NUL byte; refusing to quote.")
|
|
82
|
+
return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"
|
|
83
|
+
|
|
70
84
|
def __init__(
|
|
71
85
|
self,
|
|
72
86
|
server_name: str,
|
|
@@ -81,7 +95,7 @@ class MySQLDatabase(Database):
|
|
|
81
95
|
import pymysql as mysql_lib # noqa: F401
|
|
82
96
|
except Exception:
|
|
83
97
|
fatal_error(
|
|
84
|
-
"The pymysql module is required to connect to MySQL.
|
|
98
|
+
f"The pymysql module is required to connect to MySQL. {install_hint('mysql')}",
|
|
85
99
|
)
|
|
86
100
|
from execsql.types import dbt_mysql
|
|
87
101
|
|
|
@@ -307,10 +321,12 @@ class MySQLDatabase(Database):
|
|
|
307
321
|
table_cols = self.table_columns(table_name, schema_name)
|
|
308
322
|
data_table_cols = [d.lower() for d in table_cols]
|
|
309
323
|
csv_file_cols = csv_file_obj.column_headers()
|
|
324
|
+
# MySQL column names are case-insensitive: compare without case, and
|
|
325
|
+
# send the file's own spelling.
|
|
310
326
|
if _state.conf.import_common_cols_only:
|
|
311
|
-
import_cols = [col for col in csv_file_cols if col in data_table_cols]
|
|
327
|
+
import_cols = [col for col in csv_file_cols if col.lower() in data_table_cols]
|
|
312
328
|
else:
|
|
313
|
-
unmatched_cols =
|
|
329
|
+
unmatched_cols = [col for col in csv_file_cols if col.lower() not in data_table_cols]
|
|
314
330
|
if len(unmatched_cols) > 0:
|
|
315
331
|
raise ErrInfo(
|
|
316
332
|
type="error",
|
|
@@ -326,7 +342,7 @@ class MySQLDatabase(Database):
|
|
|
326
342
|
# reads the format that was detected.
|
|
327
343
|
if (
|
|
328
344
|
csv_file_obj.escapechar is None
|
|
329
|
-
and data_table_cols == csv_file_cols
|
|
345
|
+
and data_table_cols == [col.lower() for col in csv_file_cols]
|
|
330
346
|
and _state.conf.empty_strings
|
|
331
347
|
and _state.conf.empty_rows
|
|
332
348
|
and not _state.conf.del_empty_cols
|
execsql/db/oracle.py
CHANGED
|
@@ -14,7 +14,7 @@ from typing import Any
|
|
|
14
14
|
from execsql.db.base import Database
|
|
15
15
|
from execsql.db.tiers import SupportTier
|
|
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
|
from execsql.utils.auth import (
|
|
19
19
|
begin_login,
|
|
20
20
|
clear_stored_password,
|
|
@@ -76,7 +76,7 @@ class OracleDatabase(Database):
|
|
|
76
76
|
) -> None:
|
|
77
77
|
if _oracle_driver() is None:
|
|
78
78
|
fatal_error(
|
|
79
|
-
|
|
79
|
+
f"The oracledb module is required to connect to Oracle. {install_hint('oracle')}",
|
|
80
80
|
)
|
|
81
81
|
from execsql.types import dbt_oracle
|
|
82
82
|
|
|
@@ -164,12 +164,12 @@ class OracleDatabase(Database):
|
|
|
164
164
|
else:
|
|
165
165
|
return super().select_data(sql)
|
|
166
166
|
|
|
167
|
-
def select_rowsource(self, sql: str) -> Any:
|
|
167
|
+
def select_rowsource(self, sql: str, *, decode_bytes: bool = True) -> Any:
|
|
168
168
|
"""Return column names and an iterable that yields rows one at a time."""
|
|
169
169
|
if sql[-1:] == ";":
|
|
170
|
-
return super().select_rowsource(sql[:-1])
|
|
170
|
+
return super().select_rowsource(sql[:-1], decode_bytes=decode_bytes)
|
|
171
171
|
else:
|
|
172
|
-
return super().select_rowsource(sql)
|
|
172
|
+
return super().select_rowsource(sql, decode_bytes=decode_bytes)
|
|
173
173
|
|
|
174
174
|
def select_rowdict(self, sql: str) -> Any:
|
|
175
175
|
"""Return column names and an iterable that yields rows as dictionaries."""
|
execsql/db/postgres.py
CHANGED
|
@@ -15,7 +15,7 @@ from typing import Any
|
|
|
15
15
|
|
|
16
16
|
from execsql.db.base import Database
|
|
17
17
|
from execsql.exceptions import ErrInfo
|
|
18
|
-
from execsql.utils.errors import exception_desc, fatal_error
|
|
18
|
+
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
19
19
|
from execsql.utils.auth import (
|
|
20
20
|
begin_login,
|
|
21
21
|
clear_stored_password,
|
|
@@ -51,8 +51,7 @@ class PostgresDatabase(Database):
|
|
|
51
51
|
import psycopg # noqa: F401
|
|
52
52
|
except Exception:
|
|
53
53
|
fatal_error(
|
|
54
|
-
"The psycopg module (psycopg3) is required to connect to PostgreSQL.
|
|
55
|
-
"See https://www.psycopg.org/psycopg3/",
|
|
54
|
+
f"The psycopg module (psycopg3) is required to connect to PostgreSQL. {install_hint('postgres')}",
|
|
56
55
|
)
|
|
57
56
|
from execsql.types import dbt_postgres
|
|
58
57
|
|
|
@@ -354,7 +353,7 @@ class PostgresDatabase(Database):
|
|
|
354
353
|
from execsql.exporters.delimited import blank_lines_as_empty_rows
|
|
355
354
|
|
|
356
355
|
# Use Postgres' COPY FROM method via psycopg3's cursor.copy() context manager.
|
|
357
|
-
rf = csv_file_obj.
|
|
356
|
+
rf = csv_file_obj.openclean("rt") # past the SKIP lines, as every other reader starts
|
|
358
357
|
if skipheader:
|
|
359
358
|
next(rf)
|
|
360
359
|
# Copy_from() requires a delimiter, but if there is none, feed it an
|
execsql/db/sqlserver.py
CHANGED
|
@@ -11,7 +11,7 @@ import re
|
|
|
11
11
|
|
|
12
12
|
from execsql.db.base import Database
|
|
13
13
|
from execsql.exceptions import ErrInfo
|
|
14
|
-
from execsql.utils.errors import fatal_error
|
|
14
|
+
from execsql.utils.errors import fatal_error, install_hint
|
|
15
15
|
from execsql.utils.auth import (
|
|
16
16
|
begin_login,
|
|
17
17
|
clear_stored_password,
|
|
@@ -41,7 +41,7 @@ class SqlServerDatabase(Database):
|
|
|
41
41
|
try:
|
|
42
42
|
import pyodbc # noqa: F401
|
|
43
43
|
except Exception:
|
|
44
|
-
fatal_error("The pyodbc module is required.
|
|
44
|
+
fatal_error(f"The pyodbc module is required to connect to SQL Server. {install_hint('mssql')}")
|
|
45
45
|
from execsql.types import dbt_sqlserver
|
|
46
46
|
|
|
47
47
|
super().__init__(
|
execsql/exporters/duckdb.py
CHANGED
|
@@ -33,9 +33,9 @@ def export_duckdb(
|
|
|
33
33
|
try:
|
|
34
34
|
import duckdb
|
|
35
35
|
except Exception:
|
|
36
|
-
from execsql.utils.errors import fatal_error
|
|
36
|
+
from execsql.utils.errors import fatal_error, install_hint
|
|
37
37
|
|
|
38
|
-
fatal_error("The duckdb module is required to export data in that format.")
|
|
38
|
+
fatal_error(f"The duckdb module is required to export data in that format. {install_hint('duckdb')}")
|
|
39
39
|
return
|
|
40
40
|
|
|
41
41
|
from execsql.models import DataTable
|
execsql/exporters/ods.py
CHANGED
|
@@ -20,7 +20,7 @@ import execsql.state as _state
|
|
|
20
20
|
from execsql.exceptions import OdsFileError
|
|
21
21
|
from execsql.exceptions import ErrInfo
|
|
22
22
|
from execsql.script import current_script_line
|
|
23
|
-
from execsql.utils.errors import exception_desc, fatal_error
|
|
23
|
+
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
24
24
|
from execsql.utils.fileio import filewriter_close
|
|
25
25
|
from execsql.utils.strings import unquoted
|
|
26
26
|
|
|
@@ -57,7 +57,7 @@ class OdsFile:
|
|
|
57
57
|
import odf.number # noqa: F401
|
|
58
58
|
import odf.style # noqa: F401
|
|
59
59
|
except ImportError:
|
|
60
|
-
fatal_error("The odfpy library is needed to create OpenDocument spreadsheets.")
|
|
60
|
+
fatal_error(f"The odfpy library is needed to create OpenDocument spreadsheets. {install_hint('formats')}")
|
|
61
61
|
self.filename: str | None = None
|
|
62
62
|
self.wbk: Any = None
|
|
63
63
|
self.cell_style_names: list[str] = []
|
execsql/exporters/templates.py
CHANGED
|
@@ -14,7 +14,7 @@ from typing import Any
|
|
|
14
14
|
|
|
15
15
|
import execsql.state as _state
|
|
16
16
|
from execsql.exceptions import ErrInfo
|
|
17
|
-
from execsql.utils.errors import fatal_error
|
|
17
|
+
from execsql.utils.errors import fatal_error, install_hint
|
|
18
18
|
from execsql.utils.fileio import filewriter_close
|
|
19
19
|
|
|
20
20
|
__all__ = ["StrTemplateReport", "JinjaTemplateReport", "report_query"]
|
|
@@ -91,7 +91,7 @@ class JinjaTemplateReport:
|
|
|
91
91
|
self._jinja2 = jinja2
|
|
92
92
|
except ImportError:
|
|
93
93
|
fatal_error(
|
|
94
|
-
"The jinja2 library is required to produce reports with the Jinja2 templating system.
|
|
94
|
+
f"The jinja2 library is required to produce reports with the Jinja2 templating system. {install_hint('formats')}",
|
|
95
95
|
)
|
|
96
96
|
conf = _state.conf
|
|
97
97
|
template_root = getattr(conf, "template_root", None)
|
execsql/exporters/xls.py
CHANGED
|
@@ -22,7 +22,7 @@ from pathlib import Path
|
|
|
22
22
|
from typing import Any, cast
|
|
23
23
|
|
|
24
24
|
from execsql.exceptions import XlsFileError, XlsxFileError
|
|
25
|
-
from execsql.utils.errors import fatal_error
|
|
25
|
+
from execsql.utils.errors import fatal_error, install_hint
|
|
26
26
|
|
|
27
27
|
__all__ = ["XlsFile", "XlsxFile"]
|
|
28
28
|
|
|
@@ -51,7 +51,7 @@ class XlsFile:
|
|
|
51
51
|
|
|
52
52
|
self._xlrd = xlrd
|
|
53
53
|
except ImportError:
|
|
54
|
-
fatal_error("The xlrd library is needed to read Excel (.xls) spreadsheets.")
|
|
54
|
+
fatal_error(f"The xlrd library is needed to read Excel (.xls) spreadsheets. {install_hint('formats')}")
|
|
55
55
|
self.filename: str | None = None
|
|
56
56
|
self.encoding: str | None = None
|
|
57
57
|
self.wbk: Any = None
|
|
@@ -158,7 +158,7 @@ class XlsFile:
|
|
|
158
158
|
break
|
|
159
159
|
ncols += 1
|
|
160
160
|
sheet_data = []
|
|
161
|
-
for r in range(junk_header_rows, sheet.nrows
|
|
161
|
+
for r in range(junk_header_rows, sheet.nrows):
|
|
162
162
|
datarow = row_data(r, ncols)
|
|
163
163
|
if datarow.count(None) == len(datarow):
|
|
164
164
|
break
|
|
@@ -190,7 +190,7 @@ class XlsxFile:
|
|
|
190
190
|
|
|
191
191
|
self._openpyxl = openpyxl
|
|
192
192
|
except ImportError:
|
|
193
|
-
fatal_error("The openpyxl library is needed to read Excel (.xlsx) spreadsheets.")
|
|
193
|
+
fatal_error(f"The openpyxl library is needed to read Excel (.xlsx) spreadsheets. {install_hint('formats')}")
|
|
194
194
|
self.filename: str | None = None
|
|
195
195
|
self.encoding: str | None = None
|
|
196
196
|
self.wbk: Any = None
|
|
@@ -270,7 +270,8 @@ class XlsxFile:
|
|
|
270
270
|
sheet_data = []
|
|
271
271
|
rowsrc = sheet.iter_rows(min_row=junk_header_rows + 1, values_only=True)
|
|
272
272
|
for r in rowsrc:
|
|
273
|
-
|
|
273
|
+
# A row of zeros or FALSE values is data; only a row of empty cells ends it.
|
|
274
|
+
if all(c is None or c == "" for c in r):
|
|
274
275
|
break
|
|
275
276
|
sheet_data.append(list(r))
|
|
276
277
|
for r in range(len(sheet_data)):
|
execsql/exporters/xlsx.py
CHANGED
|
@@ -20,7 +20,7 @@ from execsql.exceptions import ErrInfo
|
|
|
20
20
|
from execsql.exporters.base import ExportRecord
|
|
21
21
|
from execsql.exporters.pretty import prettyprint_query
|
|
22
22
|
from execsql.script import current_script_line
|
|
23
|
-
from execsql.utils.errors import exception_desc, fatal_error
|
|
23
|
+
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
24
24
|
from execsql.utils.fileio import filewriter_close
|
|
25
25
|
from execsql.utils.strings import unquoted
|
|
26
26
|
|
|
@@ -39,7 +39,7 @@ def _require_openpyxl() -> Any:
|
|
|
39
39
|
return openpyxl
|
|
40
40
|
except ImportError:
|
|
41
41
|
fatal_error(
|
|
42
|
-
"The openpyxl library is needed to write Excel (.xlsx) spreadsheets (
|
|
42
|
+
f"The openpyxl library is needed to write Excel (.xlsx) spreadsheets. {install_hint('formats')}",
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
|