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.
Files changed (72) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +2 -0
  3. execsql/cli/commands/lint.py +2 -10
  4. execsql/cli/commands/run.py +35 -27
  5. execsql/cli/dsn.py +11 -4
  6. execsql/cli/lint.py +271 -28
  7. execsql/cli/options.py +1 -1
  8. execsql/cli/run.py +56 -19
  9. execsql/config.py +5 -19
  10. execsql/data/execsql.conf.template +14 -4
  11. execsql/db/access.py +29 -8
  12. execsql/db/base.py +40 -8
  13. execsql/db/dsn.py +13 -4
  14. execsql/db/duckdb.py +3 -1
  15. execsql/db/factory.py +11 -2
  16. execsql/db/firebird.py +21 -7
  17. execsql/db/mysql.py +37 -16
  18. execsql/db/oracle.py +49 -16
  19. execsql/db/postgres.py +30 -6
  20. execsql/db/sqlite.py +7 -9
  21. execsql/db/sqlserver.py +25 -8
  22. execsql/exporters/delimited.py +87 -28
  23. execsql/exporters/xlsx.py +24 -9
  24. execsql/exporters/zip.py +1 -2
  25. execsql/format.py +29 -2
  26. execsql/gui/desktop.py +1 -3
  27. execsql/gui/tui.py +2 -2
  28. execsql/importers/base.py +35 -1
  29. execsql/importers/csv.py +3 -1
  30. execsql/manifest.py +33 -22
  31. execsql/metacommands/conditions.py +6 -7
  32. execsql/metacommands/data.py +4 -1
  33. execsql/metacommands/debug.py +2 -4
  34. execsql/metacommands/io_fileops.py +62 -37
  35. execsql/metacommands/io_import.py +0 -1
  36. execsql/metacommands/prompt.py +2 -0
  37. execsql/metacommands/upsert.py +5 -2
  38. execsql/models.py +3 -2
  39. execsql/parser.py +14 -5
  40. execsql/plugins.py +24 -0
  41. execsql/script/engine.py +40 -11
  42. execsql/script/executor.py +9 -5
  43. execsql/script/parser.py +167 -118
  44. execsql/state.py +23 -9
  45. execsql/state.pyi +8 -0
  46. execsql/types.py +22 -13
  47. execsql/utils/auth.py +169 -24
  48. execsql/utils/datetime.py +55 -17
  49. execsql/utils/errors.py +71 -9
  50. execsql/utils/fileio.py +404 -117
  51. execsql/utils/gui.py +3 -0
  52. execsql/utils/mail.py +37 -12
  53. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/METADATA +18 -12
  54. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/RECORD +72 -72
  55. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +0 -0
  56. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  57. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  58. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  59. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  60. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  61. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  62. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  63. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  64. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  65. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  66. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  67. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  68. {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  69. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  70. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/entry_points.txt +0 -0
  71. {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  72. {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`` for genuine
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`` subprocess (which is ``atexit``-managed
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, subprocess queues, local class definitions). Those are
598
- assigned directly in ``_run()`` before and after this call.
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
- from execsql.plugins import discover_metacommand_plugins
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
- discover_metacommand_plugins(dispatch_table)
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
- dbt_access = DbType("Access")
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. After a successful interactive prompt the
16
- password is offered for storage in the keyring for future use (console
17
- mode only; GUI mode stores silently).
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 the stored credential turns out to be stale (e.g. after a password
20
- change), callers should use :func:`password_from_keyring` to detect
21
- this, call :func:`clear_stored_password` to remove the bad entry, and
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>/<server_or_file>/<database>``.
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__ = ["clear_stored_password", "get_password", "is_plaintext_keyring", "password_from_keyring"]
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
- # Tracks whether the most recent get_password() call returned a keyring-stored value.
36
- _last_from_keyring: bool = False
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(dbms_name: str, database_name: str, server_name: str | None) -> str:
85
- """Build a keyring service name from connection parameters."""
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 the last :func:`get_password` call used a keyring-stored value."""
127
- return _last_from_keyring
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. Returns True on success."""
137
- service = _keyring_service(dbms_name, database_name, server_name)
138
- return _keyring_delete(service, user_name)
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
- _last_from_keyring = False
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
- _last_from_keyring = True
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
- # --- Offer to store in keyring after interactive prompt ---
388
+ # --- Stored once the connection succeeds (remember_password) ---
244
389
  if use_keyring and passwd:
245
- _keyring_set(service, user_name, passwd)
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
- def parse_datetime(datestr: Any) -> datetime.datetime | None:
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 any format recognised by ``dateutil.parser.parse()``, including
48
- ISO 8601, US date formats (month-first), European formats, and natural
49
- language month names. Returns ``None`` if the input cannot be parsed.
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"``) are rejected
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
- try:
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
- try:
93
- dt = cast(datetime.datetime, _dateutil_parser.parse(data))
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