execsql2 2.23.1__py3-none-any.whl → 2.25.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/api.py +169 -160
- execsql/cli/__init__.py +19 -586
- execsql/cli/application.py +309 -0
- execsql/cli/commands/__init__.py +13 -0
- execsql/cli/commands/config.py +205 -0
- execsql/cli/commands/format.py +100 -0
- execsql/cli/commands/init.py +135 -0
- execsql/cli/commands/lint.py +225 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +459 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/dsn.py +11 -4
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +780 -100
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +96 -32
- execsql/config.py +256 -173
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +38 -5
- execsql/data/script_header.sql +23 -0
- execsql/db/access.py +29 -8
- execsql/db/base.py +40 -8
- execsql/db/dsn.py +13 -4
- execsql/db/duckdb.py +3 -1
- execsql/db/factory.py +13 -4
- execsql/db/firebird.py +21 -7
- execsql/db/mysql.py +37 -16
- execsql/db/oracle.py +49 -16
- execsql/db/postgres.py +30 -6
- execsql/db/sqlite.py +7 -9
- execsql/db/sqlserver.py +25 -8
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +115 -76
- execsql/gui/desktop.py +1 -3
- execsql/gui/tui.py +2 -2
- execsql/importers/base.py +35 -1
- execsql/importers/csv.py +3 -1
- execsql/interactive.py +232 -0
- execsql/manifest.py +200 -0
- execsql/metacommands/conditions.py +7 -8
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/effects.py +124 -0
- execsql/metacommands/io_fileops.py +62 -37
- execsql/metacommands/io_import.py +0 -1
- execsql/metacommands/prompt.py +2 -0
- execsql/metacommands/upsert.py +5 -2
- execsql/models.py +3 -2
- execsql/parser.py +14 -5
- execsql/plugins.py +24 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +46 -104
- execsql/script/executor.py +162 -39
- execsql/script/parser.py +167 -118
- execsql/state.py +34 -11
- execsql/state.pyi +11 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/color.py +18 -0
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +82 -10
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
- execsql2-2.25.0.dist-info/RECORD +140 -0
- execsql2-2.25.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.1.dist-info/RECORD +0 -124
- execsql2-2.23.1.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Check execsql.conf files and report every problem with its file and line.
|
|
2
|
+
|
|
3
|
+
A run reads config files with :class:`~execsql.config.ConfigData`, which stops
|
|
4
|
+
at the first invalid value and silently ignores anything it does not
|
|
5
|
+
recognize — a misspelled key or section simply does nothing. This module
|
|
6
|
+
backs ``execsql config --validate``: it reads the same files in the same order
|
|
7
|
+
and reports all of it.
|
|
8
|
+
|
|
9
|
+
Values are checked by :meth:`ConfigData._apply_file`, the method a run uses,
|
|
10
|
+
applied to one option at a time on a fresh instance. Validation therefore
|
|
11
|
+
cannot accept a value a run rejects, or reject one a run accepts.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import configparser
|
|
17
|
+
import difflib
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import warnings
|
|
22
|
+
from collections import deque
|
|
23
|
+
from collections.abc import Iterable
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from execsql.config import ConfigData, describe_parse_error
|
|
29
|
+
from execsql.exceptions import ConfigError
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from execsql.config import _VariablePool
|
|
33
|
+
|
|
34
|
+
__all__ = ["ConfigProblem", "known_options", "validate_config"]
|
|
35
|
+
|
|
36
|
+
#: Sections whose keys are free-form: variable names, and numbered include files.
|
|
37
|
+
_FREE_SECTIONS = frozenset({"variables", "include_required", "include_optional", configparser.DEFAULTSECT})
|
|
38
|
+
|
|
39
|
+
#: Keys that chain to another config file, and the platform each applies on.
|
|
40
|
+
_CHAIN_KEYS = {
|
|
41
|
+
"config_file": None,
|
|
42
|
+
"linux_config_file": "linux",
|
|
43
|
+
"macos_config_file": "darwin",
|
|
44
|
+
"win_config_file": "win32",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ConfigProblem:
|
|
50
|
+
"""One problem in one config file.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
file: The config file.
|
|
54
|
+
line: 1-based line number, or ``0`` when the problem has no single line.
|
|
55
|
+
severity: ``"error"`` — a run fails on it — or ``"warning"`` — a run
|
|
56
|
+
silently ignores it.
|
|
57
|
+
message: What is wrong, and the likely fix when there is one.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
file: str
|
|
61
|
+
line: int
|
|
62
|
+
severity: str
|
|
63
|
+
message: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def known_options() -> dict[str, set[str]]:
|
|
67
|
+
"""Every section execsql reads, and the keys it reads in each.
|
|
68
|
+
|
|
69
|
+
Free-form sections (``[variables]`` and the include sections) map to an
|
|
70
|
+
empty set: any key is accepted there.
|
|
71
|
+
"""
|
|
72
|
+
probe = ConfigData.__new__(ConfigData)
|
|
73
|
+
probe._set_defaults()
|
|
74
|
+
probe._read_known_options(configparser.ConfigParser()) # fills ConfigData._option_keys
|
|
75
|
+
known: dict[str, set[str]] = {}
|
|
76
|
+
for section, key, _attr in ConfigData._option_keys:
|
|
77
|
+
known.setdefault(section, set()).add(key)
|
|
78
|
+
known.setdefault(ConfigData._CONFIG_SECTION, set()).update(_CHAIN_KEYS)
|
|
79
|
+
for section in _FREE_SECTIONS - {configparser.DEFAULTSECT}:
|
|
80
|
+
known[section] = set()
|
|
81
|
+
return known
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _line_numbers(text: str) -> tuple[dict[str, int], dict[tuple[str, str], int]]:
|
|
85
|
+
"""Where each section header and each key is, by 1-based line number.
|
|
86
|
+
|
|
87
|
+
Keys are lower-cased, as :class:`configparser.ConfigParser` does.
|
|
88
|
+
Continuation lines (indented) and comments are skipped.
|
|
89
|
+
"""
|
|
90
|
+
sections: dict[str, int] = {}
|
|
91
|
+
keys: dict[tuple[str, str], int] = {}
|
|
92
|
+
current: str | None = None
|
|
93
|
+
for number, raw in enumerate(text.splitlines(), 1):
|
|
94
|
+
line = raw.strip()
|
|
95
|
+
if not line or line[0] in "#;":
|
|
96
|
+
continue
|
|
97
|
+
header = re.fullmatch(r"\[(.+)\]", line)
|
|
98
|
+
if header:
|
|
99
|
+
current = header.group(1)
|
|
100
|
+
sections.setdefault(current, number)
|
|
101
|
+
continue
|
|
102
|
+
if raw[0].isspace() or current is None:
|
|
103
|
+
continue
|
|
104
|
+
option = re.match(r"([^=:]+?)\s*[=:]", line)
|
|
105
|
+
if option:
|
|
106
|
+
keys[(current, option.group(1).strip().lower())] = number
|
|
107
|
+
return sections, keys
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _suggest(word: str, candidates: Iterable[str]) -> str | None:
|
|
111
|
+
match = difflib.get_close_matches(word, sorted(candidates), n=1, cutoff=0.7)
|
|
112
|
+
return match[0] if match else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _check_value(
|
|
116
|
+
section: str,
|
|
117
|
+
key: str,
|
|
118
|
+
value: str,
|
|
119
|
+
variable_pool: _VariablePool,
|
|
120
|
+
current_script: str,
|
|
121
|
+
) -> str | None:
|
|
122
|
+
"""Apply one option the way a run would; return why it fails, or ``None``."""
|
|
123
|
+
single = configparser.ConfigParser()
|
|
124
|
+
single.read_dict({section: {key: value.replace("%", "%%")}})
|
|
125
|
+
probe = ConfigData.__new__(ConfigData)
|
|
126
|
+
probe._set_defaults()
|
|
127
|
+
try:
|
|
128
|
+
with warnings.catch_warnings():
|
|
129
|
+
warnings.simplefilter("ignore") # enc_password's deprecation notice
|
|
130
|
+
probe._apply_file(single, variable_pool, current_script)
|
|
131
|
+
except ConfigError as exc:
|
|
132
|
+
return str(exc)
|
|
133
|
+
except ValueError as exc:
|
|
134
|
+
if section in ("include_required", "include_optional"):
|
|
135
|
+
return f"keys in [{section}] must be numbers, not {key!r}"
|
|
136
|
+
return str(exc)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
return f"{type(exc).__name__}: {exc}"
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_rule_codes(value: str) -> str | None:
|
|
143
|
+
"""Rule codes in ``[lint] select`` / ``ignore``, checked as ``execsql lint`` checks them."""
|
|
144
|
+
from execsql.cli.lint import resolve_selectors
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
resolve_selectors([value])
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
return str(exc)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_secret(key: str) -> bool:
|
|
154
|
+
"""Whether *key* holds a password (``password``, ``enc_password``, ``passwd``)."""
|
|
155
|
+
return "passw" in key.lower()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_file(
|
|
159
|
+
path: str,
|
|
160
|
+
known: dict[str, set[str]],
|
|
161
|
+
variable_pool: _VariablePool,
|
|
162
|
+
current_script: str,
|
|
163
|
+
) -> tuple[list[ConfigProblem], list[str]]:
|
|
164
|
+
"""Every problem in one file, and the config files it chains to (in queue order)."""
|
|
165
|
+
problems: list[ConfigProblem] = []
|
|
166
|
+
|
|
167
|
+
def problem(line: int, severity: str, message: str) -> None:
|
|
168
|
+
problems.append(ConfigProblem(path, line, severity, message))
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
with open(path) as f: # the encoding ConfigParser.read uses
|
|
172
|
+
text = f.read()
|
|
173
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
174
|
+
problem(0, "error", f"cannot read: {exc}")
|
|
175
|
+
return problems, []
|
|
176
|
+
|
|
177
|
+
cp = configparser.ConfigParser()
|
|
178
|
+
try:
|
|
179
|
+
cp.read_string(text, source=path)
|
|
180
|
+
except configparser.ParsingError as exc:
|
|
181
|
+
if isinstance(exc, configparser.MissingSectionHeaderError):
|
|
182
|
+
line, text = describe_parse_error(exc)
|
|
183
|
+
problem(line, "error", text)
|
|
184
|
+
else: # every unparsable line, not only the first
|
|
185
|
+
for err_line, err_text in exc.errors:
|
|
186
|
+
problem(err_line, "error", f"cannot parse line: {err_text.strip()}")
|
|
187
|
+
return problems, []
|
|
188
|
+
except configparser.Error as exc:
|
|
189
|
+
line, text = describe_parse_error(exc)
|
|
190
|
+
problem(line, "error", text)
|
|
191
|
+
return problems, []
|
|
192
|
+
|
|
193
|
+
section_lines, key_lines = _line_numbers(text)
|
|
194
|
+
|
|
195
|
+
for section in cp.sections():
|
|
196
|
+
if section not in known:
|
|
197
|
+
hint = _suggest(section, known)
|
|
198
|
+
problem(
|
|
199
|
+
section_lines.get(section, 0),
|
|
200
|
+
"warning",
|
|
201
|
+
f"unknown section [{section}]" + (f"; did you mean [{hint}]?" if hint else "; a run ignores it"),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
chained: list[str] = []
|
|
205
|
+
for (section, key), line in key_lines.items():
|
|
206
|
+
if section not in known and section != configparser.DEFAULTSECT:
|
|
207
|
+
continue # reported once, as an unknown section
|
|
208
|
+
if section not in _FREE_SECTIONS and key not in known[section]:
|
|
209
|
+
elsewhere = sorted(s for s, keys in known.items() if key in keys)
|
|
210
|
+
if elsewhere:
|
|
211
|
+
message = f"{key} belongs in [{elsewhere[0]}], not [{section}]"
|
|
212
|
+
else:
|
|
213
|
+
hint = _suggest(key, known[section])
|
|
214
|
+
message = f"unknown key {key!r} in [{section}]" + (
|
|
215
|
+
f"; did you mean {hint!r}?" if hint else "; a run ignores it"
|
|
216
|
+
)
|
|
217
|
+
problem(line, "warning", message)
|
|
218
|
+
continue
|
|
219
|
+
if section == configparser.DEFAULTSECT:
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
value = cp.get(section, key)
|
|
223
|
+
except configparser.Error as exc:
|
|
224
|
+
problem(line, "error", describe_parse_error(exc)[1])
|
|
225
|
+
continue
|
|
226
|
+
if key in _CHAIN_KEYS:
|
|
227
|
+
platform = _CHAIN_KEYS[key]
|
|
228
|
+
if platform and not sys.platform.startswith(platform):
|
|
229
|
+
continue # another platform's file; a run here never reads it
|
|
230
|
+
expand_home = os.name == "posix" if key == "config_file" else True
|
|
231
|
+
target = ConfigData._chain_target(value, variable_pool, expand_home=expand_home)
|
|
232
|
+
if Path(target).is_file():
|
|
233
|
+
chained.append(target)
|
|
234
|
+
else:
|
|
235
|
+
problem(line, "warning", f"{key} = {value}: no such file; a run skips it")
|
|
236
|
+
continue
|
|
237
|
+
failure = _check_value(section, key, value, variable_pool, current_script)
|
|
238
|
+
if failure is None and section == ConfigData._LINT_SECTION:
|
|
239
|
+
failure = _check_rule_codes(value)
|
|
240
|
+
if failure:
|
|
241
|
+
if _is_secret(key):
|
|
242
|
+
# Never echo a password, even an obfuscated one, into logs.
|
|
243
|
+
failure = failure.replace(value, "***") if value else failure
|
|
244
|
+
value = "***"
|
|
245
|
+
problem(line, "error", f"{key} = {value}: {failure}")
|
|
246
|
+
|
|
247
|
+
return problems, chained
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def validate_config(
|
|
251
|
+
script_path: str,
|
|
252
|
+
variable_pool: _VariablePool,
|
|
253
|
+
config_file: str | None = None,
|
|
254
|
+
) -> tuple[list[str], list[ConfigProblem]]:
|
|
255
|
+
"""Check every config file a run from *script_path* would read.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
script_path: Directory of the script a run would execute; its
|
|
259
|
+
``execsql.conf`` is included, as for a run.
|
|
260
|
+
variable_pool: Substitution variables for expanding chained file paths
|
|
261
|
+
and checking ``[variables]`` names.
|
|
262
|
+
config_file: The file ``--config`` names, read last.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The files checked, in the order a run reads them, and every problem
|
|
266
|
+
found, in file order and then line order.
|
|
267
|
+
"""
|
|
268
|
+
known = known_options()
|
|
269
|
+
current_script = str(Path(sys.argv[0]).resolve())
|
|
270
|
+
queue: deque[str] = deque(ConfigData._search_paths(script_path, config_file))
|
|
271
|
+
checked: list[str] = []
|
|
272
|
+
problems: list[ConfigProblem] = []
|
|
273
|
+
while queue and len(checked) < ConfigData._MAX_CONFIG_CHAIN:
|
|
274
|
+
path = queue.popleft()
|
|
275
|
+
if path in checked or not Path(path).is_file():
|
|
276
|
+
continue
|
|
277
|
+
checked.append(path)
|
|
278
|
+
found, chained = _check_file(path, known, variable_pool, current_script)
|
|
279
|
+
problems.extend(sorted(found, key=lambda p: p.line))
|
|
280
|
+
for target in chained:
|
|
281
|
+
queue.appendleft(target)
|
|
282
|
+
return checked, problems
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# 4. Working directory: current directory
|
|
13
13
|
#
|
|
14
14
|
# Later files override earlier ones. CLI arguments override everything.
|
|
15
|
-
# Generate this template with: execsql --init
|
|
15
|
+
# Generate this template with: execsql config --init > execsql.conf
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
[connect]
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
#password_prompt=Yes
|
|
47
47
|
|
|
48
48
|
# Whether to use the OS credential store (keyring) for password storage.
|
|
49
|
-
# Requires:
|
|
49
|
+
# Requires the auth extra: uv tool install "execsql2[auth]"
|
|
50
50
|
# Values: Yes or No. Default: Yes.
|
|
51
51
|
#use_keyring=Yes
|
|
52
52
|
|
|
@@ -71,7 +71,8 @@
|
|
|
71
71
|
[input]
|
|
72
72
|
# Settings for handling imported data.
|
|
73
73
|
|
|
74
|
-
#
|
|
74
|
+
# MS Access: create columns for decimal data as NUMERIC(precision,scale)
|
|
75
|
+
# (Yes) or as DOUBLE (No) when IMPORT or COPY creates a table.
|
|
75
76
|
# Values: Yes or No. Default: No.
|
|
76
77
|
#access_use_numeric=No
|
|
77
78
|
|
|
@@ -261,6 +262,15 @@
|
|
|
261
262
|
# Values: Yes or No. Default: No.
|
|
262
263
|
#use_tls=No
|
|
263
264
|
|
|
265
|
+
# Whether to verify the SMTP server's certificate when use_ssl or use_tls is on.
|
|
266
|
+
# Turn off only for a test relay; prefer ca_file for an internal certificate.
|
|
267
|
+
# Values: Yes or No. Default: Yes.
|
|
268
|
+
#verify_certificate=Yes
|
|
269
|
+
|
|
270
|
+
# CA certificate file (PEM) to trust, for a server with an internal or
|
|
271
|
+
# self-signed certificate. Default: the system's trusted CAs.
|
|
272
|
+
#ca_file=
|
|
273
|
+
|
|
264
274
|
# Email body format.
|
|
265
275
|
# Values: plain or html. Default: plain.
|
|
266
276
|
#email_format=plain
|
|
@@ -297,8 +307,8 @@
|
|
|
297
307
|
# Values: Yes or No. Default: Yes.
|
|
298
308
|
#allow_rm_file=Yes
|
|
299
309
|
|
|
300
|
-
# Whether to allow the SERVE metacommand (which
|
|
301
|
-
#
|
|
310
|
+
# Whether to allow the SERVE metacommand (which copies a file to stdout
|
|
311
|
+
# with HTTP headers, for CGI use). Set to No to disable.
|
|
302
312
|
# Also controllable via --no-serve CLI flag.
|
|
303
313
|
# Values: Yes or No. Default: Yes.
|
|
304
314
|
#allow_serve=Yes
|
|
@@ -349,6 +359,29 @@
|
|
|
349
359
|
#win_config_file=
|
|
350
360
|
|
|
351
361
|
|
|
362
|
+
[format]
|
|
363
|
+
# Layout for `execsql format`. A flag on the command line wins over these.
|
|
364
|
+
# A run ignores this section.
|
|
365
|
+
|
|
366
|
+
# Spaces per indent level, for metacommand blocks and SQL.
|
|
367
|
+
#indent=4
|
|
368
|
+
# Put commas at the start of lines instead of the end.
|
|
369
|
+
#leading_comma=No
|
|
370
|
+
# Reformat SQL with sqlglot (needs the [formatter] extra). No = metacommands only.
|
|
371
|
+
#sql=Yes
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
[lint]
|
|
375
|
+
# Rules for `execsql lint`: codes or prefixes, comma-separated (e.g. V001,F).
|
|
376
|
+
# --select or --ignore on the command line replaces the value here.
|
|
377
|
+
# A run ignores this section.
|
|
378
|
+
|
|
379
|
+
#select=
|
|
380
|
+
#ignore=
|
|
381
|
+
# Yes = exit 1 on warnings too, not only on errors (--strict / --no-strict).
|
|
382
|
+
#strict=No
|
|
383
|
+
|
|
384
|
+
|
|
352
385
|
[variables]
|
|
353
386
|
# Substitution variables defined at startup. No pre-defined keys.
|
|
354
387
|
# Example:
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- {name}
|
|
2
|
+
--
|
|
3
|
+
-- PURPOSE
|
|
4
|
+
--
|
|
5
|
+
--
|
|
6
|
+
-- NOTES
|
|
7
|
+
-- 1.
|
|
8
|
+
--
|
|
9
|
+
-- PROJECT
|
|
10
|
+
--
|
|
11
|
+
--
|
|
12
|
+
-- COPYRIGHT
|
|
13
|
+
-- Copyright (c) {year},
|
|
14
|
+
--
|
|
15
|
+
-- AUTHORS
|
|
16
|
+
--
|
|
17
|
+
--
|
|
18
|
+
-- HISTORY
|
|
19
|
+
-- Date Remarks
|
|
20
|
+
-- ---------- -----------------------------------------------------------------
|
|
21
|
+
-- {date} Created.
|
|
22
|
+
--
|
|
23
|
+
-- ==============================================================================
|
execsql/db/access.py
CHANGED
|
@@ -18,7 +18,14 @@ from execsql.db.base import Database, _fetched
|
|
|
18
18
|
from execsql.db.tiers import SupportTier
|
|
19
19
|
from execsql.exceptions import ErrInfo
|
|
20
20
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
21
|
-
from execsql.utils.auth import
|
|
21
|
+
from execsql.utils.auth import (
|
|
22
|
+
begin_login,
|
|
23
|
+
clear_stored_password,
|
|
24
|
+
get_password,
|
|
25
|
+
is_login_failure,
|
|
26
|
+
password_from_keyring,
|
|
27
|
+
remember_password,
|
|
28
|
+
)
|
|
22
29
|
import execsql.state as _state
|
|
23
30
|
|
|
24
31
|
__all__ = ["AccessDatabase"]
|
|
@@ -76,7 +83,6 @@ class AccessDatabase(Database):
|
|
|
76
83
|
self.password = password
|
|
77
84
|
# Encoding is only applicable to Jet < 4.0: non-accdb databases.
|
|
78
85
|
self.encoding = encoding or "windows-1252"
|
|
79
|
-
self.encode_commands = True
|
|
80
86
|
self.dao_conn: Any = None
|
|
81
87
|
self.conn = None # ODBC connection
|
|
82
88
|
self.paramstr = "?"
|
|
@@ -104,9 +110,12 @@ class AccessDatabase(Database):
|
|
|
104
110
|
if self.conn is not None:
|
|
105
111
|
self.conn.close()
|
|
106
112
|
self.conn = None
|
|
113
|
+
begin_login()
|
|
107
114
|
if self.need_passwd and self.user and self.password is None:
|
|
108
115
|
self.password = get_password("MS-Access", self.db_name, self.user)
|
|
109
116
|
|
|
117
|
+
errors: list[BaseException] = []
|
|
118
|
+
|
|
110
119
|
def _try_odbc_drivers():
|
|
111
120
|
db_name = str(Path(self.db_name).resolve())
|
|
112
121
|
for cs, jet4flag in self.connection_strings:
|
|
@@ -120,7 +129,8 @@ class AccessDatabase(Database):
|
|
|
120
129
|
# the connect attempt at 30 s to match the other
|
|
121
130
|
# adapters.
|
|
122
131
|
self.conn = pyodbc.connect(connstr, timeout=30)
|
|
123
|
-
except Exception:
|
|
132
|
+
except Exception as e:
|
|
133
|
+
errors.append(e)
|
|
124
134
|
if _state.exec_log is not None:
|
|
125
135
|
_state.exec_log.log_status_info(
|
|
126
136
|
f"Could not connect via ODBC using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
|
|
@@ -134,7 +144,8 @@ class AccessDatabase(Database):
|
|
|
134
144
|
return True
|
|
135
145
|
return False
|
|
136
146
|
|
|
137
|
-
|
|
147
|
+
# Only a rejected password means the stored one is stale.
|
|
148
|
+
if not _try_odbc_drivers() and password_from_keyring() and any(is_login_failure(e) for e in errors):
|
|
138
149
|
clear_stored_password("MS-Access", self.db_name, self.user)
|
|
139
150
|
self.password = get_password(
|
|
140
151
|
"MS-Access",
|
|
@@ -144,6 +155,8 @@ class AccessDatabase(Database):
|
|
|
144
155
|
other_msg="(stored credential failed — enter current password)",
|
|
145
156
|
)
|
|
146
157
|
_try_odbc_drivers()
|
|
158
|
+
if self.conn and self.user:
|
|
159
|
+
remember_password("MS-Access", self.db_name, self.user)
|
|
147
160
|
|
|
148
161
|
if not self.conn:
|
|
149
162
|
raise ErrInfo(
|
|
@@ -158,10 +171,13 @@ class AccessDatabase(Database):
|
|
|
158
171
|
if self.dao_conn is not None:
|
|
159
172
|
self.dao_conn.Close()
|
|
160
173
|
self.dao_conn = None
|
|
174
|
+
begin_login()
|
|
161
175
|
if self.need_passwd and self.user and self.password is None:
|
|
162
176
|
self.password = get_password("MS-Access", self.db_name, self.user)
|
|
163
177
|
dao_engines = ("DAO.DBEngine.120", "DAO.DBEngine.36")
|
|
164
178
|
|
|
179
|
+
dao_errors: list[BaseException] = []
|
|
180
|
+
|
|
165
181
|
def _try_dao_engines():
|
|
166
182
|
for engine in dao_engines:
|
|
167
183
|
try:
|
|
@@ -175,7 +191,8 @@ class AccessDatabase(Database):
|
|
|
175
191
|
)
|
|
176
192
|
else:
|
|
177
193
|
self.dao_conn = daoEngine.OpenDatabase(self.db_name)
|
|
178
|
-
except Exception:
|
|
194
|
+
except Exception as e:
|
|
195
|
+
dao_errors.append(e)
|
|
179
196
|
if _state.exec_log is not None:
|
|
180
197
|
_state.exec_log.log_status_info(f"Could not connect via DAO using: {engine}")
|
|
181
198
|
else:
|
|
@@ -184,7 +201,7 @@ class AccessDatabase(Database):
|
|
|
184
201
|
return True
|
|
185
202
|
return False
|
|
186
203
|
|
|
187
|
-
if not _try_dao_engines() and password_from_keyring():
|
|
204
|
+
if not _try_dao_engines() and password_from_keyring() and any(is_login_failure(e) for e in dao_errors):
|
|
188
205
|
clear_stored_password("MS-Access", self.db_name, self.user)
|
|
189
206
|
self.password = get_password(
|
|
190
207
|
"MS-Access",
|
|
@@ -194,6 +211,8 @@ class AccessDatabase(Database):
|
|
|
194
211
|
other_msg="(stored credential failed — enter current password)",
|
|
195
212
|
)
|
|
196
213
|
_try_dao_engines()
|
|
214
|
+
if self.dao_conn and self.user:
|
|
215
|
+
remember_password("MS-Access", self.db_name, self.user)
|
|
197
216
|
|
|
198
217
|
if not self.dao_conn:
|
|
199
218
|
raise ErrInfo(
|
|
@@ -339,6 +358,9 @@ class AccessDatabase(Database):
|
|
|
339
358
|
|
|
340
359
|
return headers, iter(dict_row, None)
|
|
341
360
|
|
|
361
|
+
# table_exists() without a schema looks only where CREATE TABLE puts a table: one namespace.
|
|
362
|
+
unqualified_lookup_is_scoped = True
|
|
363
|
+
|
|
342
364
|
def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
|
|
343
365
|
"""Return True if the named table exists in the Access database.
|
|
344
366
|
|
|
@@ -418,9 +440,8 @@ class AccessDatabase(Database):
|
|
|
418
440
|
return False
|
|
419
441
|
|
|
420
442
|
def drop_table(self, tablename: str) -> None:
|
|
421
|
-
"""Drop
|
|
443
|
+
"""Drop *tablename*, quoted by the caller."""
|
|
422
444
|
self.dao_flush_check()
|
|
423
|
-
tablename = self.type.quoted(tablename)
|
|
424
445
|
self.execute(f"drop table {tablename};")
|
|
425
446
|
|
|
426
447
|
def as_datetime(self, val: Any) -> datetime.datetime | datetime.date | datetime.time | None:
|
execsql/db/base.py
CHANGED
|
@@ -98,7 +98,6 @@ class Database(ABC):
|
|
|
98
98
|
password: str | None
|
|
99
99
|
port: int | None
|
|
100
100
|
encoding: Any
|
|
101
|
-
encode_commands: bool
|
|
102
101
|
paramstr: str
|
|
103
102
|
conn: Any
|
|
104
103
|
autocommit: bool
|
|
@@ -143,7 +142,6 @@ class Database(ABC):
|
|
|
143
142
|
self.password: str | None = None
|
|
144
143
|
self.port = port
|
|
145
144
|
self.encoding = encoding
|
|
146
|
-
self.encode_commands = True
|
|
147
145
|
self.paramstr = "?"
|
|
148
146
|
self.conn = None
|
|
149
147
|
self.autocommit = True
|
|
@@ -494,6 +492,21 @@ class Database(ABC):
|
|
|
494
492
|
rows = curs.fetchall()
|
|
495
493
|
return len(rows) > 0
|
|
496
494
|
|
|
495
|
+
#: Whether :meth:`table_exists` without a schema already looks only where
|
|
496
|
+
#: an unqualified ``CREATE TABLE`` puts a table (one namespace, or the
|
|
497
|
+
#: search path). When not, the check is narrowed to :meth:`current_schema`.
|
|
498
|
+
unqualified_lookup_is_scoped = False
|
|
499
|
+
|
|
500
|
+
def current_schema(self) -> str | None:
|
|
501
|
+
"""The schema (MySQL: database) an unqualified table name is created in, or ``None`` if unknown."""
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
def _query_current_schema(self, sql: str) -> str | None:
|
|
505
|
+
with self._cursor() as curs:
|
|
506
|
+
curs.execute(sql)
|
|
507
|
+
row = curs.fetchone()
|
|
508
|
+
return str(row[0]) if row and row[0] else None
|
|
509
|
+
|
|
497
510
|
def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
|
|
498
511
|
"""Return ``True`` if *table_name* (optionally in *schema_name*) exists."""
|
|
499
512
|
with self._cursor() as curs:
|
|
@@ -665,6 +678,17 @@ class Database(ABC):
|
|
|
665
678
|
eof = False
|
|
666
679
|
total_rows = 0
|
|
667
680
|
self.before_statement(sql)
|
|
681
|
+
# The rows go in as several batches and are committed once, by the
|
|
682
|
+
# caller. Most drivers hold every batch in the transaction they open
|
|
683
|
+
# themselves; DuckDB's commits each statement unless one is open, so a
|
|
684
|
+
# failure would leave the batches before it behind.
|
|
685
|
+
self.begin_transaction()
|
|
686
|
+
if self.driver_autocommits:
|
|
687
|
+
self.warn_once(
|
|
688
|
+
"import",
|
|
689
|
+
f"{self.name()} commits each statement as it runs, so an IMPORT or COPY that fails part-way "
|
|
690
|
+
"leaves the rows already loaded.",
|
|
691
|
+
)
|
|
668
692
|
|
|
669
693
|
# Optional rich progress bar for long-running imports.
|
|
670
694
|
use_progress = getattr(_state.conf, "show_progress", False)
|
|
@@ -739,6 +763,8 @@ class Database(ABC):
|
|
|
739
763
|
)
|
|
740
764
|
if not _state.conf.empty_strings and line[i].strip() == "":
|
|
741
765
|
line[i] = None
|
|
766
|
+
# Trailing values left off the line are NULL.
|
|
767
|
+
line = list(line) + [None] * (len(ts_colnames) - len(line))
|
|
742
768
|
lt = [
|
|
743
769
|
type_objs[i].from_data(val) if val is not None else None for i, val in enumerate(line)
|
|
744
770
|
]
|
|
@@ -777,13 +803,19 @@ class Database(ABC):
|
|
|
777
803
|
break
|
|
778
804
|
return total_rows
|
|
779
805
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
806
|
+
try:
|
|
807
|
+
with self._cursor() as curs:
|
|
808
|
+
if use_progress and progress_ctx is not None:
|
|
809
|
+
with progress_ctx:
|
|
810
|
+
task_id = progress_ctx.add_task(sq_name, total=None)
|
|
811
|
+
_import_loop(curs)
|
|
812
|
+
else:
|
|
784
813
|
_import_loop(curs)
|
|
785
|
-
|
|
786
|
-
|
|
814
|
+
except BaseException:
|
|
815
|
+
# A bad value or short row stops the load as surely as a failed
|
|
816
|
+
# insert does; either way none of it is kept.
|
|
817
|
+
self.rollback()
|
|
818
|
+
raise
|
|
787
819
|
|
|
788
820
|
if _state.exec_log:
|
|
789
821
|
_state.exec_log.log_status_info(
|
execsql/db/dsn.py
CHANGED
|
@@ -13,7 +13,14 @@ from execsql.db.base import Database
|
|
|
13
13
|
from execsql.db.tiers import SupportTier
|
|
14
14
|
from execsql.exceptions import ErrInfo
|
|
15
15
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
16
|
-
from execsql.utils.auth import
|
|
16
|
+
from execsql.utils.auth import (
|
|
17
|
+
begin_login,
|
|
18
|
+
clear_stored_password,
|
|
19
|
+
get_password,
|
|
20
|
+
is_login_failure,
|
|
21
|
+
password_from_keyring,
|
|
22
|
+
remember_password,
|
|
23
|
+
)
|
|
17
24
|
import execsql.state as _state
|
|
18
25
|
|
|
19
26
|
__all__ = ["DsnDatabase"]
|
|
@@ -56,7 +63,6 @@ class DsnDatabase(Database):
|
|
|
56
63
|
self.type = dbt_dsn
|
|
57
64
|
self.password = password
|
|
58
65
|
self.encoding = encoding
|
|
59
|
-
self.encode_commands = True
|
|
60
66
|
self.paramstr = "?"
|
|
61
67
|
self.open_db()
|
|
62
68
|
self.password = None # Clear cleartext password after successful connection
|
|
@@ -72,6 +78,7 @@ class DsnDatabase(Database):
|
|
|
72
78
|
if self.conn is not None:
|
|
73
79
|
self.conn.close()
|
|
74
80
|
self.conn = None
|
|
81
|
+
begin_login()
|
|
75
82
|
if self.need_passwd and self.user and self.password is None:
|
|
76
83
|
self.password = get_password("DSN", self.db_name, self.user)
|
|
77
84
|
|
|
@@ -126,8 +133,9 @@ class DsnDatabase(Database):
|
|
|
126
133
|
|
|
127
134
|
try:
|
|
128
135
|
_try_connect()
|
|
129
|
-
except ErrInfo:
|
|
130
|
-
|
|
136
|
+
except ErrInfo as e:
|
|
137
|
+
# Only a rejected password means the stored one is stale.
|
|
138
|
+
if not (password_from_keyring() and is_login_failure(e)):
|
|
131
139
|
raise
|
|
132
140
|
clear_stored_password("DSN", self.db_name, self.user)
|
|
133
141
|
self.password = get_password(
|
|
@@ -139,6 +147,7 @@ class DsnDatabase(Database):
|
|
|
139
147
|
)
|
|
140
148
|
self.conn = None
|
|
141
149
|
_try_connect()
|
|
150
|
+
remember_password("DSN", self.db_name, self.user or "")
|
|
142
151
|
|
|
143
152
|
def exec_cmd(self, querycommand: str) -> None:
|
|
144
153
|
"""Execute a stored procedure by name."""
|
execsql/db/duckdb.py
CHANGED
|
@@ -74,9 +74,11 @@ class DuckDBDatabase(Database):
|
|
|
74
74
|
)
|
|
75
75
|
self.type = dbt_duckdb
|
|
76
76
|
self.catalog_name = Path(DuckDB_fn).stem
|
|
77
|
-
self.encode_commands = False
|
|
78
77
|
self.open_db()
|
|
79
78
|
|
|
79
|
+
def current_schema(self) -> str | None:
|
|
80
|
+
return self._query_current_schema("select current_schema()")
|
|
81
|
+
|
|
80
82
|
def __repr__(self) -> str:
|
|
81
83
|
return f"DuckDBDatabase({self.db_name!r})"
|
|
82
84
|
|