execsql2 2.24.0__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 +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 +271 -28
- 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.0.dist-info}/METADATA +18 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/RECORD +72 -72
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
execsql/state.py
CHANGED
|
@@ -101,6 +101,8 @@ if TYPE_CHECKING:
|
|
|
101
101
|
SubVarSet,
|
|
102
102
|
)
|
|
103
103
|
from execsql.script.executor import ExecutingStatement
|
|
104
|
+
from execsql.exceptions import ErrInfo
|
|
105
|
+
from execsql.manifest import RunManifest
|
|
104
106
|
from execsql.utils.fileio import FileWriter, Logger, TempFileMgr
|
|
105
107
|
from execsql.utils.mail import MailSpec
|
|
106
108
|
from execsql.utils.timer import Timer
|
|
@@ -225,6 +227,9 @@ _CONTEXT_ATTRS: frozenset[str] = frozenset(
|
|
|
225
227
|
"output",
|
|
226
228
|
"filewriter",
|
|
227
229
|
"tier_notices_shown",
|
|
230
|
+
"manifest",
|
|
231
|
+
"halt_error",
|
|
232
|
+
"halt_output_error",
|
|
228
233
|
# Lazy singletons
|
|
229
234
|
"counters",
|
|
230
235
|
"timer",
|
|
@@ -280,6 +285,9 @@ class RuntimeContext:
|
|
|
280
285
|
"output",
|
|
281
286
|
"filewriter",
|
|
282
287
|
"tier_notices_shown",
|
|
288
|
+
"manifest",
|
|
289
|
+
"halt_error",
|
|
290
|
+
"halt_output_error",
|
|
283
291
|
# Lazy singletons
|
|
284
292
|
"counters",
|
|
285
293
|
"timer",
|
|
@@ -330,6 +338,12 @@ class RuntimeContext:
|
|
|
330
338
|
# DBMS names whose best-effort support notice has already been shown,
|
|
331
339
|
# so repeated connections to the same backend announce it only once.
|
|
332
340
|
self.tier_notices_shown: set[str] = set()
|
|
341
|
+
# ``--manifest`` recorder for this run, or None.
|
|
342
|
+
self.manifest: RunManifest | None = None
|
|
343
|
+
# The error that halted this run (set by ``exit_now``), or None.
|
|
344
|
+
self.halt_error: ErrInfo | None = None
|
|
345
|
+
# File output lost when the run halted (set by ``exit_now``), or None.
|
|
346
|
+
self.halt_output_error: ErrInfo | None = None
|
|
333
347
|
|
|
334
348
|
# Lazy singletons
|
|
335
349
|
self.counters: CounterVars | None = None
|
|
@@ -364,9 +378,7 @@ class RuntimeContext:
|
|
|
364
378
|
# nesting construct: top-level script, EXECUTE SCRIPT calls, INCLUDE'd
|
|
365
379
|
# files, IF/ELSEIF/ELSE branches, LOOP iterations, BATCH blocks. See
|
|
366
380
|
# :class:`ExecFrame` for frame structure. Read by the debug REPL's
|
|
367
|
-
# ``.stack`` command and ``DEBUG WRITE COMMANDLISTSTACK
|
|
368
|
-
# execution context — the legacy ``commandliststack`` only records
|
|
369
|
-
# SCRIPT call frames and is therefore insufficient for the debugger.
|
|
381
|
+
# ``.stack`` command and ``DEBUG WRITE COMMANDLISTSTACK``.
|
|
370
382
|
self.ast_exec_stack: list[ExecFrame] = []
|
|
371
383
|
|
|
372
384
|
# -----------------------------------------------------------------
|
|
@@ -552,7 +564,7 @@ def reset() -> None:
|
|
|
552
564
|
"""Reset all mutable state to initial values.
|
|
553
565
|
|
|
554
566
|
Intended for use in tests. Creates a fresh :class:`RuntimeContext`,
|
|
555
|
-
preserving only the ``filewriter``
|
|
567
|
+
preserving only the ``filewriter`` thread (which is ``atexit``-managed
|
|
556
568
|
and must not be discarded while alive).
|
|
557
569
|
"""
|
|
558
570
|
ctx = _get_ctx()
|
|
@@ -594,8 +606,9 @@ def initialize(
|
|
|
594
606
|
Note:
|
|
595
607
|
``subvars``, ``status``, ``output``, ``filewriter``, and ``exec_log``
|
|
596
608
|
are **not** set here because they require CLI-specific arguments
|
|
597
|
-
(script path,
|
|
598
|
-
assigned directly in ``_run()``
|
|
609
|
+
(script path, output settings, local class definitions). Those are
|
|
610
|
+
assigned directly in ``_run()`` (or :func:`execsql.api.run`) before
|
|
611
|
+
and after this call.
|
|
599
612
|
"""
|
|
600
613
|
import execsql.db.base as _db_base
|
|
601
614
|
import execsql.exporters.base as _exporters_base
|
|
@@ -614,10 +627,11 @@ def initialize(
|
|
|
614
627
|
ctx.conditionallist = conditional_table
|
|
615
628
|
|
|
616
629
|
# Discover and register metacommand plugins via entry points.
|
|
617
|
-
# Runs here (not at import time) to avoid I/O side effects during import
|
|
618
|
-
|
|
630
|
+
# Runs here (not at import time) to avoid I/O side effects during import,
|
|
631
|
+
# and only once per dispatch table: every run shares the table.
|
|
632
|
+
from execsql.plugins import register_metacommand_plugins_once
|
|
619
633
|
|
|
620
|
-
|
|
634
|
+
register_metacommand_plugins_once(dispatch_table)
|
|
621
635
|
|
|
622
636
|
|
|
623
637
|
# ---------------------------------------------------------------------------
|
execsql/state.pyi
CHANGED
|
@@ -11,6 +11,8 @@ from execsql.config import ConfigData, StatObj
|
|
|
11
11
|
from execsql.db.base import DatabasePool
|
|
12
12
|
from execsql.exporters.base import ExportMetadata, WriteSpec
|
|
13
13
|
from execsql.script import CounterVars, MetaCommandList, ScriptExecSpec, SubVarSet
|
|
14
|
+
from execsql.exceptions import ErrInfo
|
|
15
|
+
from execsql.manifest import RunManifest
|
|
14
16
|
from execsql.utils.fileio import FileWriter, Logger, TempFileMgr
|
|
15
17
|
from execsql.utils.mail import MailSpec
|
|
16
18
|
from execsql.utils.timer import Timer
|
|
@@ -54,6 +56,9 @@ status: StatObj
|
|
|
54
56
|
output: Any
|
|
55
57
|
tier_notices_shown: set[str]
|
|
56
58
|
filewriter: FileWriter | None
|
|
59
|
+
manifest: RunManifest | None
|
|
60
|
+
halt_error: ErrInfo | None
|
|
61
|
+
halt_output_error: ErrInfo | None
|
|
57
62
|
counters: CounterVars
|
|
58
63
|
timer: Timer
|
|
59
64
|
dbs: DatabasePool
|
|
@@ -89,6 +94,9 @@ class RuntimeContext:
|
|
|
89
94
|
output: Any
|
|
90
95
|
tier_notices_shown: set[str]
|
|
91
96
|
filewriter: FileWriter | None
|
|
97
|
+
manifest: RunManifest | None
|
|
98
|
+
halt_error: ErrInfo | None
|
|
99
|
+
halt_output_error: ErrInfo | None
|
|
92
100
|
counters: CounterVars | None
|
|
93
101
|
timer: Timer | None
|
|
94
102
|
dbs: DatabasePool | None
|
execsql/types.py
CHANGED
|
@@ -180,6 +180,10 @@ class DT_Timestamp(DataType):
|
|
|
180
180
|
dt = parse_datetime(data)
|
|
181
181
|
if not dt:
|
|
182
182
|
raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
|
|
183
|
+
# A written UTC offset makes it a timestamptz: stored in a timestamp
|
|
184
|
+
# column, the driver would shift it by the client's time zone.
|
|
185
|
+
if isinstance(data, str) and dt.tzinfo is not None:
|
|
186
|
+
raise DataTypeError(self.data_type_name, self._CONV_ERR % data)
|
|
183
187
|
return dt
|
|
184
188
|
|
|
185
189
|
|
|
@@ -675,18 +679,6 @@ class DbType:
|
|
|
675
679
|
# conv_mod_fn is a function that modifies the result of data_type().from_data(x).
|
|
676
680
|
self.dialect[data_type] = (dbms_name, length_required, casting_name, conv_mod_fn, precision, scale)
|
|
677
681
|
|
|
678
|
-
def datatype_name(self, data_type: type[DataType]) -> str:
|
|
679
|
-
"""Return the DBMS-specific SQL type name for the given DataType class."""
|
|
680
|
-
# A convenience function to simplify access to data type names.
|
|
681
|
-
try:
|
|
682
|
-
return self.dialect[data_type][0]
|
|
683
|
-
except Exception as e:
|
|
684
|
-
raise DbTypeError(
|
|
685
|
-
self.dbms_id,
|
|
686
|
-
data_type,
|
|
687
|
-
f"{self.dbms_id} DBMS type has no specification for data type {data_type.data_type_name}",
|
|
688
|
-
) from e
|
|
689
|
-
|
|
690
682
|
def quoted(self, dbms_object: str) -> str:
|
|
691
683
|
"""Quote a database identifier if it contains non-word characters."""
|
|
692
684
|
if re.search(r"\W", dbms_object):
|
|
@@ -799,7 +791,24 @@ dbt_sqlserver.name_datatype(DT_Varchar, "varchar", True)
|
|
|
799
791
|
dbt_sqlserver.name_datatype(DT_Text, "varchar(max)")
|
|
800
792
|
dbt_sqlserver.name_datatype(DT_Binary, "varbinary(max)")
|
|
801
793
|
|
|
802
|
-
|
|
794
|
+
|
|
795
|
+
class _AccessDbType(DbType):
|
|
796
|
+
"""Access dialect: decimal data becomes DOUBLE unless ``access_use_numeric`` is set.
|
|
797
|
+
|
|
798
|
+
Read from the run's config at use time, not applied to the shared
|
|
799
|
+
``dt_xlate``, so one run's setting does not carry over to the next
|
|
800
|
+
``execsql.run()`` in the same process.
|
|
801
|
+
"""
|
|
802
|
+
|
|
803
|
+
def spec_type(self, data_type: type[DataType]) -> type[DataType]:
|
|
804
|
+
import execsql.state as _state
|
|
805
|
+
|
|
806
|
+
if data_type is DT_Decimal and getattr(_state.conf, "access_use_numeric", False):
|
|
807
|
+
return DT_Decimal
|
|
808
|
+
return super().spec_type(data_type)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
dbt_access = _AccessDbType("Access")
|
|
803
812
|
dbt_access.name_datatype(DT_TimestampTZ, "VARCHAR", True)
|
|
804
813
|
dbt_access.name_datatype(DT_Timestamp, "VARCHAR", True)
|
|
805
814
|
dbt_access.name_datatype(DT_Date, "VARCHAR", True)
|
execsql/utils/auth.py
CHANGED
|
@@ -12,28 +12,76 @@ the same password is needed again within the same session.
|
|
|
12
12
|
When the ``keyring`` package is installed, :func:`get_password` checks
|
|
13
13
|
the OS credential store first (macOS Keychain, Windows Credential
|
|
14
14
|
Manager, or Linux SecretService). If a stored password is found it is
|
|
15
|
-
returned without prompting.
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
returned without prompting. A password typed at the prompt is stored
|
|
16
|
+
only once the connection it was typed for succeeds: the adapter calls
|
|
17
|
+
:func:`remember_password` after connecting.
|
|
18
18
|
|
|
19
|
-
If
|
|
20
|
-
change), callers
|
|
21
|
-
|
|
22
|
-
re-prompt with ``skip_keyring=True``.
|
|
19
|
+
If a stored password is rejected by the server (after a password
|
|
20
|
+
change), callers check :func:`password_from_keyring` and
|
|
21
|
+
:func:`is_login_failure`, call :func:`clear_stored_password`, and
|
|
22
|
+
re-prompt with ``skip_keyring=True``. Any other connection failure (a
|
|
23
|
+
timeout, a closed port, a missing database) leaves the stored password
|
|
24
|
+
alone and is raised.
|
|
23
25
|
|
|
24
26
|
Keyring service names follow the pattern
|
|
25
|
-
``execsql/<db_type>/<
|
|
27
|
+
``execsql/<db_type>/<server>:<port>/<database>`` for server databases
|
|
28
|
+
and ``execsql/<db_type>/local/<database>`` otherwise. An entry stored by
|
|
29
|
+
an earlier version, without the port, is read only for a connection on
|
|
30
|
+
the DBMS's default port, and is saved under the new name once it works.
|
|
26
31
|
"""
|
|
27
32
|
|
|
28
33
|
import getpass
|
|
34
|
+
import threading
|
|
29
35
|
from typing import Any, cast
|
|
30
36
|
|
|
31
37
|
import execsql.state as _state
|
|
38
|
+
from execsql.utils.fileio import register_secret
|
|
32
39
|
|
|
33
|
-
__all__ = [
|
|
40
|
+
__all__ = [
|
|
41
|
+
"begin_login",
|
|
42
|
+
"clear_stored_password",
|
|
43
|
+
"get_password",
|
|
44
|
+
"is_login_failure",
|
|
45
|
+
"is_plaintext_keyring",
|
|
46
|
+
"password_from_keyring",
|
|
47
|
+
"remember_password",
|
|
48
|
+
]
|
|
34
49
|
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
|
|
51
|
+
class _Login(threading.local):
|
|
52
|
+
"""What the connection attempt in progress on this thread looked up.
|
|
53
|
+
|
|
54
|
+
Reset by :func:`begin_login` at the start of every attempt, so one
|
|
55
|
+
connection's answer never stands for another's, and per thread, so
|
|
56
|
+
concurrent ``execsql.run()`` calls never see each other's.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
# Whether this attempt's get_password() returned a keyring-stored value.
|
|
61
|
+
self.from_keyring = False
|
|
62
|
+
# (service, user, password) to store once the connection succeeds: a
|
|
63
|
+
# password typed at the prompt, or one read under the pre-port name.
|
|
64
|
+
self.pending_store: tuple[str, str, str] | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_login = _Login()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def begin_login() -> None:
|
|
71
|
+
"""Start a connection attempt: forget what an earlier attempt looked up.
|
|
72
|
+
|
|
73
|
+
Every adapter calls this before deciding whether it needs
|
|
74
|
+
:func:`get_password`. A connection handed its password by the caller
|
|
75
|
+
then never deletes or stores a keyring entry on the strength of an
|
|
76
|
+
earlier lookup for another server.
|
|
77
|
+
"""
|
|
78
|
+
_login.from_keyring = False
|
|
79
|
+
_login.pending_store = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# The port each server DBMS uses when none is given. Part of the keyring
|
|
83
|
+
# service name, so two servers on one host do not share a stored password.
|
|
84
|
+
_DEFAULT_PORTS = {"PostgreSQL": 5432, "MySQL": 3306, "SQL Server": 1433, "Oracle": 1521, "Firebird": 3050}
|
|
37
85
|
|
|
38
86
|
# Tracks whether we've already warned about a plaintext keyring backend
|
|
39
87
|
# this process — keyring is a one-shot warning, not a per-call nag.
|
|
@@ -81,12 +129,36 @@ def _warn_if_plaintext_keyring() -> None:
|
|
|
81
129
|
pass
|
|
82
130
|
|
|
83
131
|
|
|
84
|
-
def _keyring_service(
|
|
85
|
-
|
|
132
|
+
def _keyring_service(
|
|
133
|
+
dbms_name: str,
|
|
134
|
+
database_name: str,
|
|
135
|
+
server_name: str | None,
|
|
136
|
+
port: int | None = None,
|
|
137
|
+
) -> str:
|
|
138
|
+
"""Build a keyring service name from connection parameters; a server database's name includes the port."""
|
|
86
139
|
server_part = server_name or "local"
|
|
140
|
+
if server_name and dbms_name in _DEFAULT_PORTS:
|
|
141
|
+
server_part = f"{server_name}:{port or _DEFAULT_PORTS[dbms_name]}"
|
|
87
142
|
return f"execsql/{dbms_name}/{server_part}/{database_name}"
|
|
88
143
|
|
|
89
144
|
|
|
145
|
+
def _legacy_keyring_service(
|
|
146
|
+
dbms_name: str,
|
|
147
|
+
database_name: str,
|
|
148
|
+
server_name: str | None,
|
|
149
|
+
port: int | None,
|
|
150
|
+
) -> str | None:
|
|
151
|
+
"""The service name used before the port was part of it, if this connection may read it.
|
|
152
|
+
|
|
153
|
+
Such an entry does not say which port it was stored for, so it is only
|
|
154
|
+
offered to a connection on the DBMS's default port.
|
|
155
|
+
"""
|
|
156
|
+
default = _DEFAULT_PORTS.get(dbms_name)
|
|
157
|
+
if not server_name or default is None or (port or default) != default:
|
|
158
|
+
return None
|
|
159
|
+
return f"execsql/{dbms_name}/{server_name}/{database_name}"
|
|
160
|
+
|
|
161
|
+
|
|
90
162
|
def _keyring_get(service: str, username: str) -> str | None:
|
|
91
163
|
"""Try to retrieve a password from the OS keyring. Returns None on failure."""
|
|
92
164
|
try:
|
|
@@ -123,8 +195,8 @@ def _keyring_delete(service: str, username: str) -> bool:
|
|
|
123
195
|
|
|
124
196
|
|
|
125
197
|
def password_from_keyring() -> bool:
|
|
126
|
-
"""Return True if
|
|
127
|
-
return
|
|
198
|
+
"""Return True if this connection attempt's :func:`get_password` call used a keyring-stored value."""
|
|
199
|
+
return _login.from_keyring
|
|
128
200
|
|
|
129
201
|
|
|
130
202
|
def clear_stored_password(
|
|
@@ -132,10 +204,69 @@ def clear_stored_password(
|
|
|
132
204
|
database_name: str,
|
|
133
205
|
user_name: str,
|
|
134
206
|
server_name: str | None = None,
|
|
207
|
+
port: int | None = None,
|
|
135
208
|
) -> bool:
|
|
136
|
-
"""Remove a stored password from the OS keyring.
|
|
137
|
-
|
|
138
|
-
|
|
209
|
+
"""Remove a stored password from the OS keyring, under its current and pre-port names. True if one was removed."""
|
|
210
|
+
removed = _keyring_delete(_keyring_service(dbms_name, database_name, server_name, port), user_name)
|
|
211
|
+
legacy = _legacy_keyring_service(dbms_name, database_name, server_name, port)
|
|
212
|
+
if legacy is not None:
|
|
213
|
+
removed = _keyring_delete(legacy, user_name) or removed
|
|
214
|
+
return removed
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def remember_password(
|
|
218
|
+
dbms_name: str,
|
|
219
|
+
database_name: str,
|
|
220
|
+
user_name: str,
|
|
221
|
+
server_name: str | None = None,
|
|
222
|
+
port: int | None = None,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Store the password :func:`get_password` typed (or read under the pre-port name) for this connection, now that it works.
|
|
225
|
+
|
|
226
|
+
A password typed for a connection that then failed is never stored,
|
|
227
|
+
even if another connection succeeds afterwards.
|
|
228
|
+
"""
|
|
229
|
+
pending, _login.pending_store = _login.pending_store, None
|
|
230
|
+
if pending is not None and pending[:2] == (
|
|
231
|
+
_keyring_service(dbms_name, database_name, server_name, port),
|
|
232
|
+
user_name,
|
|
233
|
+
):
|
|
234
|
+
_keyring_set(*pending)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Login failures, by driver: what each reports when the server rejects the
|
|
238
|
+
# user name or password. PostgreSQL gives no SQLSTATE for a failed
|
|
239
|
+
# connection, so its message is matched. Anything else (a timeout, a closed
|
|
240
|
+
# port, a missing database) is not a login failure.
|
|
241
|
+
_LOGIN_FAILURE_TEXT = (
|
|
242
|
+
"password authentication failed", # PostgreSQL
|
|
243
|
+
"no password supplied", # PostgreSQL
|
|
244
|
+
"ora-01017", # Oracle: invalid username/password
|
|
245
|
+
"your user name and password are not defined", # Firebird
|
|
246
|
+
"not a valid password", # Access (DAO)
|
|
247
|
+
)
|
|
248
|
+
_MYSQL_ACCESS_DENIED = 1045
|
|
249
|
+
_ODBC_INVALID_AUTHORIZATION = "28000"
|
|
250
|
+
_ORACLE_INVALID_LOGON = 1017
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def is_login_failure(exc: BaseException) -> bool:
|
|
254
|
+
"""True if *exc*, or an exception it was raised from, is the server rejecting the credentials."""
|
|
255
|
+
seen: set[int] = set()
|
|
256
|
+
current: BaseException | None = exc
|
|
257
|
+
while current is not None and id(current) not in seen:
|
|
258
|
+
seen.add(id(current))
|
|
259
|
+
first = current.args[0] if getattr(current, "args", None) else None
|
|
260
|
+
if first == _MYSQL_ACCESS_DENIED: # pymysql: (errno, message)
|
|
261
|
+
return True
|
|
262
|
+
if first == _ODBC_INVALID_AUTHORIZATION: # pyodbc: (SQLSTATE, message)
|
|
263
|
+
return True
|
|
264
|
+
if getattr(first, "code", None) == _ORACLE_INVALID_LOGON: # oracledb / cx_Oracle error object
|
|
265
|
+
return True
|
|
266
|
+
if any(text in str(current).lower() for text in _LOGIN_FAILURE_TEXT):
|
|
267
|
+
return True
|
|
268
|
+
current = current.__cause__ or current.__context__
|
|
269
|
+
return False
|
|
139
270
|
|
|
140
271
|
|
|
141
272
|
def get_password(
|
|
@@ -145,6 +276,7 @@ def get_password(
|
|
|
145
276
|
server_name: str | None = None,
|
|
146
277
|
other_msg: str | None = None,
|
|
147
278
|
skip_keyring: bool = False,
|
|
279
|
+
port: int | None = None,
|
|
148
280
|
) -> str:
|
|
149
281
|
"""Prompt the user for a database password, using the GUI if available.
|
|
150
282
|
|
|
@@ -156,22 +288,34 @@ def get_password(
|
|
|
156
288
|
skip_keyring:
|
|
157
289
|
If True, bypass the keyring lookup and always prompt interactively.
|
|
158
290
|
Useful when a previously stored password is known to be invalid.
|
|
291
|
+
port:
|
|
292
|
+
The server's port, part of the keyring entry's name.
|
|
293
|
+
|
|
294
|
+
A typed password is not stored here: :func:`remember_password` stores
|
|
295
|
+
it once the connection succeeds.
|
|
159
296
|
"""
|
|
160
|
-
global _last_from_keyring
|
|
161
297
|
# Deferred imports to avoid circular dependencies at import time.
|
|
162
298
|
from execsql.utils.errors import exit_now
|
|
163
299
|
|
|
164
|
-
|
|
300
|
+
_login.from_keyring = False
|
|
301
|
+
_login.pending_store = None
|
|
165
302
|
|
|
166
303
|
# --- Keyring lookup (before any prompting) ---
|
|
167
304
|
conf = _state.conf
|
|
168
305
|
use_keyring = conf is None or getattr(conf, "use_keyring", True)
|
|
169
|
-
service = _keyring_service(dbms_name, database_name, server_name)
|
|
306
|
+
service = _keyring_service(dbms_name, database_name, server_name, port)
|
|
170
307
|
if use_keyring and not skip_keyring:
|
|
171
308
|
stored = _keyring_get(service, user_name)
|
|
309
|
+
if stored is None:
|
|
310
|
+
legacy = _legacy_keyring_service(dbms_name, database_name, server_name, port)
|
|
311
|
+
if legacy is not None:
|
|
312
|
+
stored = _keyring_get(legacy, user_name)
|
|
313
|
+
if stored is not None:
|
|
314
|
+
_login.pending_store = (service, user_name, stored)
|
|
172
315
|
if stored is not None:
|
|
173
|
-
|
|
316
|
+
_login.from_keyring = True
|
|
174
317
|
_state.upass = stored
|
|
318
|
+
register_secret(stored)
|
|
175
319
|
return stored
|
|
176
320
|
|
|
177
321
|
script_name = ""
|
|
@@ -239,9 +383,10 @@ def get_password(
|
|
|
239
383
|
passwd = getpass.getpass(str(prompt_text))
|
|
240
384
|
|
|
241
385
|
_state.upass = passwd
|
|
386
|
+
register_secret(passwd)
|
|
242
387
|
|
|
243
|
-
# ---
|
|
388
|
+
# --- Stored once the connection succeeds (remember_password) ---
|
|
244
389
|
if use_keyring and passwd:
|
|
245
|
-
|
|
390
|
+
_login.pending_store = (service, user_name, passwd)
|
|
246
391
|
|
|
247
392
|
return passwd
|
execsql/utils/datetime.py
CHANGED
|
@@ -8,7 +8,9 @@ raw string data into Python :class:`datetime.datetime` objects. Used by
|
|
|
8
8
|
:class:`~execsql.types.DT_Timestamp`, :class:`~execsql.types.DT_TimestampTZ`,
|
|
9
9
|
and related data-type classes when scanning imported data for type inference.
|
|
10
10
|
|
|
11
|
-
Delegates to ``dateutil.parser.parse()`` for robust, format-agnostic parsing
|
|
11
|
+
Delegates to ``dateutil.parser.parse()`` for robust, format-agnostic parsing,
|
|
12
|
+
but only accepts a full date: the year written out and nothing filled in from
|
|
13
|
+
today's date. ``1-2``, ``5/6`` and ``March 5`` are not timestamps.
|
|
12
14
|
"""
|
|
13
15
|
|
|
14
16
|
import datetime
|
|
@@ -41,15 +43,56 @@ def _looks_time_only(s: str) -> bool:
|
|
|
41
43
|
return bool(_TIME_ONLY.match(s.strip()))
|
|
42
44
|
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
# A year must be written out: four digits, or a two-digit year in the
|
|
47
|
+
# month-first m/d/yy or m-d-yy form. Without this, dateutil reads "1 2 3" as
|
|
48
|
+
# 2003-01-02 and "3.5.7" as 2007-03-05.
|
|
49
|
+
_FULL_YEAR = re.compile(r"(?<!\d)\d{4}(?!\d)")
|
|
50
|
+
_SHORT_YEAR = re.compile(r"^\s*(?P<month>\d{1,2})(?P<sep>[/-])\d{1,2}(?P=sep)\d{2}(?!\d)")
|
|
51
|
+
|
|
52
|
+
# dateutil fills fields missing from the string from its ``default``. Two
|
|
53
|
+
# defaults that differ in year, month and day expose any such filled-in field:
|
|
54
|
+
# the two parses then disagree.
|
|
55
|
+
_DEFAULT_A = datetime.datetime(2001, 1, 1)
|
|
56
|
+
_DEFAULT_B = datetime.datetime(2002, 2, 2)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse(datestr: str, strict: bool) -> datetime.datetime | None:
|
|
60
|
+
"""Parse *datestr* with dateutil; when *strict*, only a fully written date."""
|
|
61
|
+
try:
|
|
62
|
+
if not strict:
|
|
63
|
+
return cast(datetime.datetime, _dateutil_parser.parse(datestr))
|
|
64
|
+
if not _FULL_YEAR.search(datestr):
|
|
65
|
+
short = _SHORT_YEAR.match(datestr)
|
|
66
|
+
# Month first only: dateutil would read 13-05-24 day-first,
|
|
67
|
+
# giving one column two conventions.
|
|
68
|
+
if short is None or int(short["month"]) > 12:
|
|
69
|
+
return None
|
|
70
|
+
a = cast(datetime.datetime, _dateutil_parser.parse(datestr, default=_DEFAULT_A))
|
|
71
|
+
b = cast(datetime.datetime, _dateutil_parser.parse(datestr, default=_DEFAULT_B))
|
|
72
|
+
except (ValueError, OverflowError, TypeError):
|
|
73
|
+
return None
|
|
74
|
+
if (a.year, a.month, a.day) != (b.year, b.month, b.day):
|
|
75
|
+
return None
|
|
76
|
+
return a
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_datetime(datestr: Any, *, strict: bool = True) -> datetime.datetime | None:
|
|
45
80
|
"""Parse a date/time string into a :class:`datetime.datetime`.
|
|
46
81
|
|
|
47
|
-
Accepts
|
|
48
|
-
ISO 8601, US date formats (month-first), European formats, and
|
|
49
|
-
|
|
82
|
+
Accepts formats recognised by ``dateutil.parser.parse()``, including
|
|
83
|
+
ISO 8601, US date formats (month-first), European formats, and month
|
|
84
|
+
names. Returns ``None`` if the input cannot be parsed.
|
|
85
|
+
|
|
86
|
+
With *strict* (the default, used for type inference and import), the
|
|
87
|
+
string must give a full date: a four-digit year (or month-first
|
|
88
|
+
``m/d/yy`` / ``m-d-yy``) and a month and day, none filled in from
|
|
89
|
+
today's date. ``"1-2"``, ``"5/6"``,
|
|
90
|
+
``"March 5"`` and ``"Monday"`` return ``None``. Pass ``strict=False``
|
|
91
|
+
for a date a user typed into a metacommand, where today's date is a
|
|
92
|
+
reasonable default.
|
|
50
93
|
|
|
51
|
-
Bare numeric strings (e.g. ``"1"``, ``"42"``, ``"2024"``)
|
|
52
|
-
to prevent type-inference false positives.
|
|
94
|
+
Bare numeric strings (e.g. ``"1"``, ``"42"``, ``"2024"``) and time-only
|
|
95
|
+
strings are always rejected to prevent type-inference false positives.
|
|
53
96
|
|
|
54
97
|
If *datestr* is already a :class:`datetime.datetime`, it is returned as-is.
|
|
55
98
|
Non-string inputs are stringified before parsing.
|
|
@@ -65,10 +108,7 @@ def parse_datetime(datestr: Any) -> datetime.datetime | None:
|
|
|
65
108
|
return None
|
|
66
109
|
if _looks_time_only(datestr):
|
|
67
110
|
return None
|
|
68
|
-
|
|
69
|
-
return cast(datetime.datetime, _dateutil_parser.parse(datestr))
|
|
70
|
-
except (ValueError, OverflowError, TypeError):
|
|
71
|
-
return None
|
|
111
|
+
return _parse(datestr, strict)
|
|
72
112
|
|
|
73
113
|
|
|
74
114
|
def parse_datetimetz(data: Any) -> datetime.datetime | None:
|
|
@@ -79,7 +119,8 @@ def parse_datetimetz(data: Any) -> datetime.datetime | None:
|
|
|
79
119
|
``-0700``) and named timezones (``UTC``, ``EST``, etc.).
|
|
80
120
|
|
|
81
121
|
If *data* is already a timezone-aware :class:`datetime.datetime`, it is
|
|
82
|
-
returned as-is. Naive datetimes return ``None``.
|
|
122
|
+
returned as-is. Naive datetimes return ``None``. Like
|
|
123
|
+
:func:`parse_datetime`, only a full date is accepted.
|
|
83
124
|
"""
|
|
84
125
|
if isinstance(data, datetime.datetime):
|
|
85
126
|
if data.tzinfo is None or data.tzinfo.utcoffset(data) is None:
|
|
@@ -89,10 +130,7 @@ def parse_datetimetz(data: Any) -> datetime.datetime | None:
|
|
|
89
130
|
return None
|
|
90
131
|
if _looks_numeric(data):
|
|
91
132
|
return None
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
except (ValueError, OverflowError, TypeError):
|
|
95
|
-
return None
|
|
96
|
-
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
|
133
|
+
dt = _parse(data, strict=True)
|
|
134
|
+
if dt is None or dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
|
97
135
|
return None
|
|
98
136
|
return dt
|