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.
- execsql/api.py +169 -160
- execsql/cli/__init__.py +19 -586
- execsql/cli/application.py +309 -0
- execsql/cli/commands/__init__.py +13 -0
- execsql/cli/commands/config.py +205 -0
- execsql/cli/commands/format.py +100 -0
- execsql/cli/commands/init.py +135 -0
- execsql/cli/commands/lint.py +225 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +459 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/dsn.py +11 -4
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +780 -100
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +96 -32
- execsql/config.py +256 -173
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +38 -5
- execsql/data/script_header.sql +23 -0
- 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 +13 -4
- 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/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +115 -76
- 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/interactive.py +232 -0
- execsql/manifest.py +200 -0
- execsql/metacommands/conditions.py +7 -8
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/effects.py +124 -0
- 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/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +46 -104
- execsql/script/executor.py +162 -39
- execsql/script/parser.py +167 -118
- execsql/state.py +34 -11
- execsql/state.pyi +11 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/color.py +18 -0
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +82 -10
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
- execsql2-2.25.0.dist-info/RECORD +140 -0
- execsql2-2.25.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.1.dist-info/RECORD +0 -124
- execsql2-2.23.1.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {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
|
-
|
|
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/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
|
-
|
|
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
|
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` —
|
|
12
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|