execsql2 2.24.0__py3-none-any.whl → 2.25.1__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 +2 -0
- execsql/cli/commands/lint.py +2 -10
- execsql/cli/commands/run.py +35 -27
- execsql/cli/dsn.py +11 -4
- execsql/cli/lint.py +351 -48
- execsql/cli/options.py +1 -1
- execsql/cli/run.py +56 -19
- execsql/config.py +5 -19
- execsql/data/execsql.conf.template +14 -4
- 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 +11 -2
- 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/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +29 -2
- 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/manifest.py +33 -22
- execsql/metacommands/conditions.py +6 -7
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- 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/engine.py +40 -11
- execsql/script/executor.py +9 -5
- execsql/script/parser.py +167 -118
- execsql/state.py +23 -9
- execsql/state.pyi +8 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +71 -9
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/METADATA +18 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/RECORD +72 -72
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/WHEEL +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/entry_points.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/NOTICE +0 -0
execsql/cli/run.py
CHANGED
|
@@ -514,7 +514,8 @@ def _setup_logging(
|
|
|
514
514
|
opts_dict = {
|
|
515
515
|
k: v
|
|
516
516
|
for k, v in {
|
|
517
|
-
|
|
517
|
+
# Count only: -a values are opaque and often secrets.
|
|
518
|
+
"sub_vars": f"{len(sub_vars)} value(s)" if sub_vars else None,
|
|
518
519
|
"boolean_int": boolean_int,
|
|
519
520
|
"make_dirs": make_dirs,
|
|
520
521
|
"database_encoding": database_encoding,
|
|
@@ -535,7 +536,7 @@ def _setup_logging(
|
|
|
535
536
|
}
|
|
536
537
|
logger = Logger(
|
|
537
538
|
script_name or "<inline>",
|
|
538
|
-
conf.db or "",
|
|
539
|
+
conf.db or conf.db_file or "",
|
|
539
540
|
conf.server,
|
|
540
541
|
opts_dict,
|
|
541
542
|
conf.user_logfile,
|
|
@@ -552,6 +553,9 @@ def _setup_logging(
|
|
|
552
553
|
)
|
|
553
554
|
|
|
554
555
|
subvars.add_substitution("$RUN_ID", logger.run_id)
|
|
556
|
+
# Passwords from the DSN or config files, known before the first connection.
|
|
557
|
+
logger.add_redaction_value(getattr(conf, "db_password", None))
|
|
558
|
+
logger.add_redaction_value(getattr(conf, "smtp_password", None))
|
|
555
559
|
|
|
556
560
|
if sub_vars:
|
|
557
561
|
for n, repl in enumerate(sub_vars):
|
|
@@ -740,27 +744,13 @@ def _run(
|
|
|
740
744
|
manifest = _manifest.start(manifest_path, script_name, variables)
|
|
741
745
|
manifest.config_files = list(conf.files_read)
|
|
742
746
|
|
|
743
|
-
import execsql.utils.fileio as _fileio
|
|
744
|
-
|
|
745
747
|
if _state.filewriter is None or not _state.filewriter.is_alive():
|
|
746
|
-
|
|
747
|
-
# can't leak responses or unconsumed commands into the new one. On
|
|
748
|
-
# macOS (`spawn`) the OS pipe buffer is small enough that retained
|
|
749
|
-
# entries from a crashed writer would deadlock the next put().
|
|
750
|
-
for q in (_fileio.fw_input, _fileio.fw_output):
|
|
751
|
-
try:
|
|
752
|
-
while True:
|
|
753
|
-
q.get_nowait()
|
|
754
|
-
except Exception:
|
|
755
|
-
pass
|
|
756
|
-
_fileio.filewriter = _state.filewriter = FileWriter(
|
|
757
|
-
_fileio.fw_input,
|
|
758
|
-
_fileio.fw_output,
|
|
748
|
+
_state.filewriter = FileWriter(
|
|
759
749
|
file_encoding=conf.output_encoding,
|
|
760
750
|
open_timeout=getattr(conf, "outfile_open_timeout", 10),
|
|
761
751
|
)
|
|
762
752
|
_state.filewriter.start()
|
|
763
|
-
atexit.register(filewriter_end)
|
|
753
|
+
atexit.register(filewriter_end, _state.filewriter)
|
|
764
754
|
|
|
765
755
|
# ------------------------------------------------------------------
|
|
766
756
|
# Logging
|
|
@@ -856,6 +846,28 @@ def _run(
|
|
|
856
846
|
# ---------------------------------------------------------------------------
|
|
857
847
|
|
|
858
848
|
|
|
849
|
+
def _end_run_on_sigterm() -> None:
|
|
850
|
+
"""Make SIGTERM end the process through ``SystemExit`` (exit status 143).
|
|
851
|
+
|
|
852
|
+
Python's default SIGTERM action stops the process without running
|
|
853
|
+
``atexit`` handlers, so the run's log (buffered until close) was lost,
|
|
854
|
+
the open transaction was not rolled back explicitly, and a running
|
|
855
|
+
``SYSTEM_CMD`` was left behind. ``timeout``, ``docker stop``, systemd
|
|
856
|
+
and CI cancellation all send SIGTERM. Installed by the console entry
|
|
857
|
+
point only: ``execsql.run()`` leaves the host program's signals alone.
|
|
858
|
+
"""
|
|
859
|
+
import signal
|
|
860
|
+
|
|
861
|
+
def _terminate(signum: int, _frame: Any) -> None:
|
|
862
|
+
import execsql.state as _state
|
|
863
|
+
|
|
864
|
+
if _state.exec_log is not None:
|
|
865
|
+
_state.exec_log.log_exit_terminated()
|
|
866
|
+
raise SystemExit(128 + signum)
|
|
867
|
+
|
|
868
|
+
signal.signal(signal.SIGTERM, _terminate)
|
|
869
|
+
|
|
870
|
+
|
|
859
871
|
def _execute_script_ast(
|
|
860
872
|
tree: Any,
|
|
861
873
|
conf: ConfigData,
|
|
@@ -883,7 +895,8 @@ def _execute_script_ast(
|
|
|
883
895
|
try:
|
|
884
896
|
execute(tree)
|
|
885
897
|
except SystemExit as exc:
|
|
886
|
-
|
|
898
|
+
terminated = _state.exec_log is not None and _state.exec_log.exit_type == "terminated"
|
|
899
|
+
if gui_console_isrunning() and conf.gui_wait_on_exit and not terminated:
|
|
887
900
|
gui_console_wait_user(
|
|
888
901
|
"Script complete; close the console window to exit execsql.",
|
|
889
902
|
)
|
|
@@ -895,6 +908,9 @@ def _execute_script_ast(
|
|
|
895
908
|
sys.exit(exc.code)
|
|
896
909
|
except ConfigError:
|
|
897
910
|
raise
|
|
911
|
+
except KeyboardInterrupt:
|
|
912
|
+
_state.exec_log.log_exit_interrupted()
|
|
913
|
+
raise
|
|
898
914
|
except ErrInfo as exc:
|
|
899
915
|
from execsql.utils.errors import exit_now
|
|
900
916
|
|
|
@@ -908,6 +924,7 @@ def _execute_script_ast(
|
|
|
908
924
|
exit_now(1, ErrInfo("exception", exception_msg=msg))
|
|
909
925
|
|
|
910
926
|
_state.dbs.do_rollback = False
|
|
927
|
+
_flush_output_files()
|
|
911
928
|
if gui_console_isrunning() and conf.gui_wait_on_exit:
|
|
912
929
|
gui_console_wait_user(
|
|
913
930
|
"Script complete; close the console window to exit execsql.",
|
|
@@ -970,10 +987,30 @@ def _execute_script_textual_console(tree: Any, conf: ConfigData) -> None:
|
|
|
970
987
|
exit_now(1, ErrInfo("exception", exception_msg=msg))
|
|
971
988
|
|
|
972
989
|
_state.dbs.do_rollback = False
|
|
990
|
+
_flush_output_files()
|
|
973
991
|
_state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
|
|
974
992
|
_state.exec_log.log_exit_end()
|
|
975
993
|
|
|
976
994
|
|
|
995
|
+
def _flush_output_files() -> None:
|
|
996
|
+
"""Flush file output at the normal end of a script; lost output fails the run.
|
|
997
|
+
|
|
998
|
+
A WRITE/EXPORT target that stays locked past ``outfile_open_timeout`` has
|
|
999
|
+
its output discarded. That must not end in a successful exit status.
|
|
1000
|
+
"""
|
|
1001
|
+
from execsql.utils.fileio import filewriter_close_all_after_write
|
|
1002
|
+
|
|
1003
|
+
try:
|
|
1004
|
+
filewriter_close_all_after_write()
|
|
1005
|
+
except ErrInfo as exc:
|
|
1006
|
+
from execsql.utils.errors import exit_now
|
|
1007
|
+
|
|
1008
|
+
# The loss belongs to no particular statement; keep exit_now from
|
|
1009
|
+
# attributing it to whatever line happened to run last.
|
|
1010
|
+
exc.script_file = ""
|
|
1011
|
+
exit_now(1, exc)
|
|
1012
|
+
|
|
1013
|
+
|
|
977
1014
|
# ---------------------------------------------------------------------------
|
|
978
1015
|
# Utility: build the database connection for the initial/default database
|
|
979
1016
|
# ---------------------------------------------------------------------------
|
execsql/config.py
CHANGED
|
@@ -372,6 +372,8 @@ class ConfigData:
|
|
|
372
372
|
self._get_str(cp, self._EMAIL_SECTION, "password", "smtp_password")
|
|
373
373
|
self._get_bool(cp, self._EMAIL_SECTION, "use_ssl", "smtp_ssl")
|
|
374
374
|
self._get_bool(cp, self._EMAIL_SECTION, "use_tls", "smtp_tls")
|
|
375
|
+
self._get_bool(cp, self._EMAIL_SECTION, "verify_certificate", "smtp_verify_certificate")
|
|
376
|
+
self._get_str(cp, self._EMAIL_SECTION, "ca_file", "smtp_ca_file")
|
|
375
377
|
self._get_str(cp, self._EMAIL_SECTION, "message_css", "email_css")
|
|
376
378
|
# --- [format] and [lint]: read by `execsql format` / `execsql lint`; a run ignores them ---
|
|
377
379
|
self._get_int(cp, self._FORMAT_SECTION, "indent", "format_indent")
|
|
@@ -523,6 +525,8 @@ class ConfigData:
|
|
|
523
525
|
self.smtp_password: str | None = None
|
|
524
526
|
self.smtp_ssl = False
|
|
525
527
|
self.smtp_tls = False
|
|
528
|
+
self.smtp_verify_certificate = True
|
|
529
|
+
self.smtp_ca_file: str | None = None
|
|
526
530
|
self.email_format = "plain"
|
|
527
531
|
self.email_css: str | None = None
|
|
528
532
|
self.include_req: list = []
|
|
@@ -727,13 +731,12 @@ class WriteHooks:
|
|
|
727
731
|
"""
|
|
728
732
|
|
|
729
733
|
def __repr__(self) -> str:
|
|
730
|
-
return f"WriteHooks({self.write_func!r}, {self.err_func!r}
|
|
734
|
+
return f"WriteHooks({self.write_func!r}, {self.err_func!r})"
|
|
731
735
|
|
|
732
736
|
def __init__(
|
|
733
737
|
self,
|
|
734
738
|
standard_output_func: Callable[[str], object] | None = None,
|
|
735
739
|
error_output_func: Callable[[str], object] | None = None,
|
|
736
|
-
status_output_func: Callable[[str], object] | None = None,
|
|
737
740
|
) -> None:
|
|
738
741
|
"""Store optional hook callables; ``None`` means use the default stream.
|
|
739
742
|
|
|
@@ -742,8 +745,6 @@ class WriteHooks:
|
|
|
742
745
|
``None`` to use ``sys.stdout``.
|
|
743
746
|
error_output_func: Callable to receive error-output text, or
|
|
744
747
|
``None`` to use ``sys.stderr``.
|
|
745
|
-
status_output_func: Callable to receive status-line text, or
|
|
746
|
-
``None`` to suppress.
|
|
747
748
|
"""
|
|
748
749
|
# Arguments should be functions that take a single string and
|
|
749
750
|
# write it to the desired destination. Both stdout and stderr can be hooked.
|
|
@@ -752,7 +753,6 @@ class WriteHooks:
|
|
|
752
753
|
# The purpose is to allow writing to be redirected to a GUI.
|
|
753
754
|
self.write_func = standard_output_func
|
|
754
755
|
self.err_func = error_output_func
|
|
755
|
-
self.status_func = status_output_func
|
|
756
756
|
self.tee_stderr = True
|
|
757
757
|
|
|
758
758
|
def reset(self) -> None:
|
|
@@ -770,15 +770,6 @@ class WriteHooks:
|
|
|
770
770
|
self.err_func = error_output_func
|
|
771
771
|
self.tee_stderr = tee
|
|
772
772
|
|
|
773
|
-
def redir(
|
|
774
|
-
self,
|
|
775
|
-
standard_output_func: Callable[[str], object],
|
|
776
|
-
error_output_func: Callable[[str], object],
|
|
777
|
-
) -> None:
|
|
778
|
-
"""Redirect both stdout and stderr hooks in one call."""
|
|
779
|
-
self.redir_stdout(standard_output_func)
|
|
780
|
-
self.redir_stderr(error_output_func)
|
|
781
|
-
|
|
782
773
|
def write(self, strval: str) -> None:
|
|
783
774
|
"""Write a string to the standard-output hook, or to sys.stdout if unset."""
|
|
784
775
|
if self.write_func:
|
|
@@ -799,8 +790,3 @@ class WriteHooks:
|
|
|
799
790
|
else:
|
|
800
791
|
sys.stderr.write(strval)
|
|
801
792
|
sys.stderr.flush()
|
|
802
|
-
|
|
803
|
-
def write_status(self, strval: str) -> None:
|
|
804
|
-
"""Forward a status string to the status hook if one is registered."""
|
|
805
|
-
if self.status_func:
|
|
806
|
-
self.status_func(strval)
|
|
@@ -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
|
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
|
|
execsql/db/factory.py
CHANGED
|
@@ -64,7 +64,16 @@ def db_Postgres(
|
|
|
64
64
|
password: str | None = None,
|
|
65
65
|
) -> PostgresDatabase:
|
|
66
66
|
"""Open a new PostgreSQL connection via psycopg (psycopg3)."""
|
|
67
|
-
return PostgresDatabase(
|
|
67
|
+
return PostgresDatabase(
|
|
68
|
+
server_name,
|
|
69
|
+
database_name,
|
|
70
|
+
user,
|
|
71
|
+
pw_needed,
|
|
72
|
+
port,
|
|
73
|
+
new_db=new_db,
|
|
74
|
+
encoding=encoding,
|
|
75
|
+
password=password,
|
|
76
|
+
)
|
|
68
77
|
|
|
69
78
|
|
|
70
79
|
def db_SQLite(
|
|
@@ -143,7 +152,7 @@ def db_Oracle(
|
|
|
143
152
|
encoding: str | None = None,
|
|
144
153
|
password: str | None = None,
|
|
145
154
|
) -> OracleDatabase:
|
|
146
|
-
"""Open an Oracle database connection via
|
|
155
|
+
"""Open an Oracle database connection via python-oracledb."""
|
|
147
156
|
return OracleDatabase(server_name, database_name, user, pw_needed, port, encoding, password=password)
|
|
148
157
|
|
|
149
158
|
|
execsql/db/firebird.py
CHANGED
|
@@ -12,7 +12,14 @@ from execsql.db.base import Database
|
|
|
12
12
|
from execsql.db.tiers import SupportTier
|
|
13
13
|
from execsql.exceptions import ErrInfo
|
|
14
14
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
15
|
-
from execsql.utils.auth import
|
|
15
|
+
from execsql.utils.auth import (
|
|
16
|
+
begin_login,
|
|
17
|
+
clear_stored_password,
|
|
18
|
+
get_password,
|
|
19
|
+
is_login_failure,
|
|
20
|
+
password_from_keyring,
|
|
21
|
+
remember_password,
|
|
22
|
+
)
|
|
16
23
|
import execsql.state as _state
|
|
17
24
|
|
|
18
25
|
__all__ = ["FirebirdDatabase"]
|
|
@@ -54,7 +61,6 @@ class FirebirdDatabase(Database):
|
|
|
54
61
|
self.type = dbt_firebird
|
|
55
62
|
self.password = password
|
|
56
63
|
self.encoding = encoding or "latin1"
|
|
57
|
-
self.encode_commands = True
|
|
58
64
|
self.paramstr = "?"
|
|
59
65
|
self.open_db()
|
|
60
66
|
self.password = None # Clear cleartext password after successful connection
|
|
@@ -100,28 +106,34 @@ class FirebirdDatabase(Database):
|
|
|
100
106
|
|
|
101
107
|
if self.conn is None:
|
|
102
108
|
try:
|
|
109
|
+
begin_login()
|
|
103
110
|
if self.user and self.need_passwd and not self.password:
|
|
104
111
|
self.password = get_password(
|
|
105
112
|
"Firebird",
|
|
106
113
|
self.db_name,
|
|
107
114
|
self.user,
|
|
108
115
|
server_name=self.server_name,
|
|
116
|
+
port=self.port,
|
|
109
117
|
)
|
|
110
118
|
try:
|
|
111
119
|
self.conn = db_conn()
|
|
112
|
-
except Exception:
|
|
113
|
-
|
|
120
|
+
except Exception as e:
|
|
121
|
+
# Only a rejected password means the stored one is stale;
|
|
122
|
+
# a timeout or a missing database leaves it alone.
|
|
123
|
+
if not (password_from_keyring() and is_login_failure(e)):
|
|
114
124
|
raise
|
|
115
|
-
clear_stored_password("Firebird", self.db_name, self.user, self.server_name)
|
|
125
|
+
clear_stored_password("Firebird", self.db_name, self.user, self.server_name, port=self.port)
|
|
116
126
|
self.password = get_password(
|
|
117
127
|
"Firebird",
|
|
118
128
|
self.db_name,
|
|
119
129
|
self.user,
|
|
120
130
|
server_name=self.server_name,
|
|
131
|
+
port=self.port,
|
|
121
132
|
skip_keyring=True,
|
|
122
133
|
other_msg="(stored credential failed — enter current password)",
|
|
123
134
|
)
|
|
124
135
|
self.conn = db_conn()
|
|
136
|
+
remember_password("Firebird", self.db_name, self.user, self.server_name, port=self.port)
|
|
125
137
|
except SystemExit:
|
|
126
138
|
# If the user canceled the password prompt.
|
|
127
139
|
raise
|
|
@@ -143,6 +155,9 @@ class FirebirdDatabase(Database):
|
|
|
143
155
|
raise
|
|
144
156
|
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
|
145
157
|
|
|
158
|
+
# table_exists() without a schema looks only where CREATE TABLE puts a table: one namespace.
|
|
159
|
+
unqualified_lookup_is_scoped = True
|
|
160
|
+
|
|
146
161
|
def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
|
|
147
162
|
"""Return True if the named table exists in the Firebird database."""
|
|
148
163
|
sql = (
|
|
@@ -243,8 +258,7 @@ class FirebirdDatabase(Database):
|
|
|
243
258
|
return len(rows) > 0
|
|
244
259
|
|
|
245
260
|
def drop_table(self, tablename: str) -> None:
|
|
246
|
-
"""Drop
|
|
261
|
+
"""Drop *tablename*, schema-qualified and quoted by the caller."""
|
|
247
262
|
# Firebird will thrown an error if there are foreign keys into the table.
|
|
248
|
-
tablename = self.type.quoted(tablename)
|
|
249
263
|
self.execute(f"DROP TABLE {tablename};")
|
|
250
264
|
self.conn.commit()
|