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.
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 +351 -48
  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.1.dist-info}/METADATA +18 -12
  54. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/RECORD +72 -72
  55. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/README.md +0 -0
  56. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  57. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  58. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  59. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_compare.sql +0 -0
  60. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
  61. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
  62. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
  63. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  64. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  65. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/script_template.sql +0 -0
  66. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
  67. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  68. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  69. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/WHEEL +0 -0
  70. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/entry_points.txt +0 -0
  71. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/LICENSE.txt +0 -0
  72. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/NOTICE +0 -0
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
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,8 +71,35 @@ 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:
@@ -124,18 +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
179
  # An error in input typed at a BREAKPOINT prompt ends
129
180
  # that input, not the run: the prompt reports it and reads the next one.
130
181
  # HALT and a canceled prompt carry no error and still end the run.
131
182
  if errinfo is not None and _state.prompt_input is not None:
132
183
  raise errinfo
133
- em = None
184
+ _state.halt_error = errinfo
134
185
  if errinfo is not None:
135
186
  stamp_errinfo(errinfo)
136
187
  if _state.subvars is not None:
137
188
  _state.subvars.add_substitution("$ERROR_MESSAGE", errinfo.errmsg())
138
- em = errinfo.write()
189
+ errinfo.write()
139
190
  if _state.err_halt_writespec is not None:
140
191
  try:
141
192
  _state.err_halt_writespec.write()
@@ -182,14 +233,23 @@ def exit_now(exit_status: int, errinfo: ErrInfo | None, logmsg: str | None = Non
182
233
  cancelexec = _state.cancel_halt_exec
183
234
  _state.cancel_halt_exec = None
184
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
185
243
  if exit_status > 0 and _state.exec_log:
186
244
  if logmsg:
187
245
  _state.exec_log.log_exit_error(logmsg)
188
- else:
189
- if em:
190
- _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())
191
250
  if _state.exec_log is not None:
192
251
  _state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
252
+ _state.exec_log.log_exit()
193
253
  _state.exec_log.close()
194
254
  from execsql.utils.fileio import filewriter_end
195
255
 
@@ -198,6 +258,8 @@ def exit_now(exit_status: int, errinfo: ErrInfo | None, logmsg: str | None = Non
198
258
  from execsql import manifest as _manifest
199
259
 
200
260
  if (manifest := _manifest.current()) is not None:
261
+ if lost_output is not None:
262
+ manifest.add_error(lost_output)
201
263
  manifest.finish(exit_status, errinfo)
202
264
  sys.exit(exit_status)
203
265