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.
Files changed (92) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +19 -586
  3. execsql/cli/application.py +309 -0
  4. execsql/cli/commands/__init__.py +13 -0
  5. execsql/cli/commands/config.py +205 -0
  6. execsql/cli/commands/format.py +100 -0
  7. execsql/cli/commands/init.py +135 -0
  8. execsql/cli/commands/lint.py +225 -0
  9. execsql/cli/commands/listing.py +81 -0
  10. execsql/cli/commands/run.py +459 -0
  11. execsql/cli/dispatch.py +66 -0
  12. execsql/cli/dsn.py +11 -4
  13. execsql/cli/help.py +175 -24
  14. execsql/cli/lint.py +780 -100
  15. execsql/cli/options.py +108 -0
  16. execsql/cli/run.py +96 -32
  17. execsql/config.py +256 -173
  18. execsql/config_validation.py +282 -0
  19. execsql/data/execsql.conf.template +38 -5
  20. execsql/data/script_header.sql +23 -0
  21. execsql/db/access.py +29 -8
  22. execsql/db/base.py +40 -8
  23. execsql/db/dsn.py +13 -4
  24. execsql/db/duckdb.py +3 -1
  25. execsql/db/factory.py +13 -4
  26. execsql/db/firebird.py +21 -7
  27. execsql/db/mysql.py +37 -16
  28. execsql/db/oracle.py +49 -16
  29. execsql/db/postgres.py +30 -6
  30. execsql/db/sqlite.py +7 -9
  31. execsql/db/sqlserver.py +25 -8
  32. execsql/debug/repl.py +51 -193
  33. execsql/exceptions.py +9 -0
  34. execsql/exporters/delimited.py +87 -28
  35. execsql/exporters/xlsx.py +24 -9
  36. execsql/exporters/zip.py +1 -2
  37. execsql/format.py +115 -76
  38. execsql/gui/desktop.py +1 -3
  39. execsql/gui/tui.py +2 -2
  40. execsql/importers/base.py +35 -1
  41. execsql/importers/csv.py +3 -1
  42. execsql/interactive.py +232 -0
  43. execsql/manifest.py +200 -0
  44. execsql/metacommands/conditions.py +7 -8
  45. execsql/metacommands/control.py +2 -3
  46. execsql/metacommands/data.py +4 -1
  47. execsql/metacommands/debug.py +2 -4
  48. execsql/metacommands/effects.py +124 -0
  49. execsql/metacommands/io_fileops.py +62 -37
  50. execsql/metacommands/io_import.py +0 -1
  51. execsql/metacommands/prompt.py +2 -0
  52. execsql/metacommands/upsert.py +5 -2
  53. execsql/models.py +3 -2
  54. execsql/parser.py +14 -5
  55. execsql/plugins.py +24 -0
  56. execsql/script/__init__.py +0 -9
  57. execsql/script/ast.py +1 -1
  58. execsql/script/engine.py +46 -104
  59. execsql/script/executor.py +162 -39
  60. execsql/script/parser.py +167 -118
  61. execsql/state.py +34 -11
  62. execsql/state.pyi +11 -0
  63. execsql/types.py +22 -13
  64. execsql/utils/auth.py +169 -24
  65. execsql/utils/color.py +18 -0
  66. execsql/utils/datetime.py +55 -17
  67. execsql/utils/errors.py +82 -10
  68. execsql/utils/fileio.py +404 -117
  69. execsql/utils/gui.py +3 -0
  70. execsql/utils/mail.py +37 -12
  71. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
  72. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
  73. execsql2-2.25.0.dist-info/RECORD +140 -0
  74. execsql2-2.25.0.dist-info/entry_points.txt +2 -0
  75. execsql2-2.23.1.dist-info/RECORD +0 -124
  76. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  77. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  78. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  79. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  80. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  81. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  82. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  83. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  84. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  85. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  86. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  87. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  88. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  89. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  90. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  91. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  92. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
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/color.py ADDED
@@ -0,0 +1,18 @@
1
+ """The user's color preference, as the environment states it.
2
+
3
+ One policy for every place execsql writes ANSI itself (the debug REPL, CLI
4
+ help): ``NO_COLOR`` (https://no-color.org) or ``EXECSQL_NO_COLOR`` set to any
5
+ value turns color off. Whether the stream is a terminal is a separate
6
+ question each caller answers for its own stream.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ __all__ = ["color_disabled_by_env"]
14
+
15
+
16
+ def color_disabled_by_env() -> bool:
17
+ """Return True when ``NO_COLOR`` or ``EXECSQL_NO_COLOR`` is set."""
18
+ return os.environ.get("NO_COLOR") is not None or os.environ.get("EXECSQL_NO_COLOR") is not None
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
execsql/utils/errors.py CHANGED
@@ -8,8 +8,10 @@ throughout the codebase:
8
8
 
9
9
  - :func:`exception_info` — extracts type, value, and source location
10
10
  from the current exception.
11
- - :func:`exception_desc` — formats exception information as a
12
- human-readable string.
11
+ - :func:`exception_desc` — ``Type: message`` for the exception being
12
+ handled, as shown on screen.
13
+ - :func:`error_origin` / :func:`logged_error` — where in execsql an error
14
+ was raised, added to the message written to the log.
13
15
  - :func:`write_warning` — writes a non-fatal warning message.
14
16
  - :func:`exit_now` — terminates execution after optional halt hooks.
15
17
  - :func:`fatal_error` — logs a fatal error and calls :func:`exit_now`.
@@ -27,7 +29,9 @@ from execsql.exceptions import ErrInfo
27
29
 
28
30
  __all__ = [
29
31
  "exception_info",
32
+ "error_origin",
30
33
  "exception_desc",
34
+ "logged_error",
31
35
  "exit_now",
32
36
  "fatal_error",
33
37
  "stamp_errinfo",
@@ -67,15 +71,42 @@ def exception_info() -> tuple:
67
71
 
68
72
 
69
73
  def exception_desc() -> str:
70
- exc_type, exc_strval, exc_filename, exc_lineno, exc_linetext = exception_info()
71
- return f"{exc_type}: {exc_strval} in {exc_filename} on line {exc_lineno} of execsql."
74
+ """``Type: message`` for the exception being handled, as shown to the person running the script.
75
+
76
+ Where in execsql it was raised is for the log: see :func:`error_origin`.
77
+ """
78
+ exc_type, exc_strval, _filename, _lineno, _linetext = exception_info()
79
+ return f"{exc_type}: {exc_strval}"
80
+
81
+
82
+ def error_origin(exc: BaseException) -> str | None:
83
+ """Where in the execsql package the error behind *exc* was raised, as ``execsql/db/base.py:254``.
84
+
85
+ Follows *exc* to the exception it was raised from, and returns the last
86
+ frame of that traceback inside the package, or ``None`` when there is none.
87
+ """
88
+ package = Path(__file__).resolve().parents[1]
89
+ cause = exc.__cause__ or exc.__context__ or exc
90
+ origin = None
91
+ for frame in traceback.extract_tb(cause.__traceback__):
92
+ path = Path(frame.filename).resolve()
93
+ if path.is_relative_to(package):
94
+ origin = f"{path.relative_to(package.parent).as_posix()}:{frame.lineno}"
95
+ return origin
96
+
97
+
98
+ def logged_error(errinfo: ErrInfo) -> str:
99
+ """The error message for the log: what the screen shows, plus :func:`error_origin` when known."""
100
+ origin = error_origin(errinfo)
101
+ message = errinfo.errmsg()
102
+ return f"{message}\n Raised at: {origin}" if origin else message
72
103
 
73
104
 
74
105
  def stamp_errinfo(errinfo: ErrInfo) -> ErrInfo:
75
106
  """Attach script location from ``_state.last_command`` to an :class:`~execsql.exceptions.ErrInfo`.
76
107
 
77
108
  Reads the source file name, line number, command text, and command type from
78
- the most-recently-executed :class:`~execsql.script.engine.ScriptCmd` and
109
+ the most-recently-executed statement and
79
110
  populates any ``None`` fields on *errinfo*. This ensures that error messages
80
111
  include "Line N of script foo.sql" context even when the ErrInfo was originally
81
112
  created deep inside a handler that had no access to execution state.
@@ -124,13 +155,38 @@ def _run_deferred_script(spec: Any) -> None:
124
155
  )
125
156
 
126
157
 
158
+ #: How long a halt waits for an output file that another process has
159
+ #: locked, before its pending output is counted as lost.
160
+ HALT_FLUSH_SECONDS = 10.0
161
+
162
+
163
+ def _flush_output_at_halt() -> ErrInfo | None:
164
+ """Write out what the run's files are still waiting for; the loss, if any, as an error."""
165
+ from execsql.utils.fileio import filewriter_close_all_after_write
166
+
167
+ try:
168
+ filewriter_close_all_after_write(max_wait=HALT_FLUSH_SECONDS)
169
+ except ErrInfo as lost:
170
+ # The loss belongs to no particular statement.
171
+ lost.script_file = ""
172
+ return lost
173
+ except Exception:
174
+ pass # A failed flush must not hide the error that halted the run.
175
+ return None
176
+
177
+
127
178
  def exit_now(exit_status: int, errinfo: ErrInfo | None, logmsg: str | None = None) -> None:
128
- em = None
179
+ # An error in input typed at a BREAKPOINT prompt ends
180
+ # that input, not the run: the prompt reports it and reads the next one.
181
+ # HALT and a canceled prompt carry no error and still end the run.
182
+ if errinfo is not None and _state.prompt_input is not None:
183
+ raise errinfo
184
+ _state.halt_error = errinfo
129
185
  if errinfo is not None:
130
186
  stamp_errinfo(errinfo)
131
187
  if _state.subvars is not None:
132
188
  _state.subvars.add_substitution("$ERROR_MESSAGE", errinfo.errmsg())
133
- em = errinfo.write()
189
+ errinfo.write()
134
190
  if _state.err_halt_writespec is not None:
135
191
  try:
136
192
  _state.err_halt_writespec.write()
@@ -177,18 +233,34 @@ def exit_now(exit_status: int, errinfo: ErrInfo | None, logmsg: str | None = Non
177
233
  cancelexec = _state.cancel_halt_exec
178
234
  _state.cancel_halt_exec = None
179
235
  _run_deferred_script(cancelexec)
236
+ # After the ON ERROR_HALT / ON CANCEL_HALT actions, which may write files
237
+ # themselves; before the writer is stopped, which discards what waits.
238
+ lost_output = _flush_output_at_halt()
239
+ if lost_output is not None:
240
+ _state.halt_output_error = lost_output
241
+ lost_output.write()
242
+ exit_status = exit_status or 1
180
243
  if exit_status > 0 and _state.exec_log:
181
244
  if logmsg:
182
245
  _state.exec_log.log_exit_error(logmsg)
183
- else:
184
- if em:
185
- _state.exec_log.log_exit_error(em)
246
+ elif errinfo is not None:
247
+ _state.exec_log.log_exit_error(logged_error(errinfo))
248
+ if lost_output is not None:
249
+ _state.exec_log.log_status_error(lost_output.errmsg())
186
250
  if _state.exec_log is not None:
187
251
  _state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
252
+ _state.exec_log.log_exit()
188
253
  _state.exec_log.close()
189
254
  from execsql.utils.fileio import filewriter_end
190
255
 
191
256
  filewriter_end()
257
+ # Last, so the ON ERROR_HALT / ON CANCEL_HALT actions above are in it.
258
+ from execsql import manifest as _manifest
259
+
260
+ if (manifest := _manifest.current()) is not None:
261
+ if lost_output is not None:
262
+ manifest.add_error(lost_output)
263
+ manifest.finish(exit_status, errinfo)
192
264
  sys.exit(exit_status)
193
265
 
194
266