execsql2 2.22.8__py3-none-any.whl → 2.23.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 +71 -0
- execsql/config.py +2 -0
- execsql/data/execsql.conf.template +6 -0
- execsql/db/access.py +12 -4
- execsql/db/base.py +15 -1
- execsql/db/dsn.py +12 -7
- execsql/db/duckdb.py +7 -8
- execsql/db/firebird.py +12 -7
- execsql/db/mysql.py +44 -10
- execsql/db/oracle.py +12 -7
- execsql/db/postgres.py +17 -9
- execsql/db/sqlite.py +7 -8
- execsql/db/sqlserver.py +7 -7
- execsql/db/tiers.py +79 -0
- execsql/exporters/delimited.py +15 -1
- execsql/exporters/html.py +8 -2
- execsql/exporters/latex.py +35 -2
- execsql/exporters/markdown.py +9 -3
- execsql/exporters/ods.py +22 -4
- execsql/exporters/pretty.py +13 -0
- execsql/exporters/templates.py +18 -1
- execsql/exporters/values.py +25 -4
- execsql/exporters/xlsx.py +7 -1
- execsql/state.py +5 -0
- execsql/state.pyi +3 -1
- execsql/types.py +4 -1
- execsql/utils/fileio.py +47 -2
- execsql/utils/mail.py +9 -5
- {execsql2-2.22.8.dist-info → execsql2-2.23.0.dist-info}/METADATA +22 -13
- {execsql2-2.22.8.dist-info → execsql2-2.23.0.dist-info}/RECORD +48 -47
- {execsql2-2.22.8.dist-info → execsql2-2.23.0.dist-info}/WHEEL +1 -1
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.22.8.data → execsql2-2.23.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.22.8.dist-info → execsql2-2.23.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.22.8.dist-info → execsql2-2.23.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.22.8.dist-info → execsql2-2.23.0.dist-info}/licenses/NOTICE +0 -0
execsql/api.py
CHANGED
|
@@ -29,6 +29,7 @@ Usage::
|
|
|
29
29
|
|
|
30
30
|
from __future__ import annotations
|
|
31
31
|
|
|
32
|
+
import atexit
|
|
32
33
|
import dataclasses
|
|
33
34
|
import datetime
|
|
34
35
|
import io
|
|
@@ -511,6 +512,11 @@ def run(
|
|
|
511
512
|
errors: list[ScriptError] = []
|
|
512
513
|
t0 = time.perf_counter()
|
|
513
514
|
|
|
515
|
+
# WRITE ... TO <file> and TEE are handled by a FileWriter subprocess,
|
|
516
|
+
# and fileio drops every write when none is running. Start one here so
|
|
517
|
+
# the API behaves like the CLI.
|
|
518
|
+
_ensure_filewriter(conf)
|
|
519
|
+
|
|
514
520
|
try:
|
|
515
521
|
execute(tree, ctx=ctx)
|
|
516
522
|
except SystemExit:
|
|
@@ -538,6 +544,17 @@ def run(
|
|
|
538
544
|
|
|
539
545
|
elapsed = time.perf_counter() - t0
|
|
540
546
|
|
|
547
|
+
# Flush and close every file the script wrote, so the caller can read
|
|
548
|
+
# them the moment run() returns. The subprocess itself is left running
|
|
549
|
+
# for reuse by a later run() and is reaped by the atexit handler; a
|
|
550
|
+
# writer the caller started is theirs and is never shut down here.
|
|
551
|
+
from execsql.utils.fileio import filewriter_close_all_after_write
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
filewriter_close_all_after_write()
|
|
555
|
+
except Exception:
|
|
556
|
+
pass # Best-effort: a failed flush must not mask a script error.
|
|
557
|
+
|
|
541
558
|
# ------------------------------------------------------------------
|
|
542
559
|
# Collect results
|
|
543
560
|
# ------------------------------------------------------------------
|
|
@@ -571,6 +588,60 @@ def run(
|
|
|
571
588
|
# ---------------------------------------------------------------------------
|
|
572
589
|
# Helpers
|
|
573
590
|
# ---------------------------------------------------------------------------
|
|
591
|
+
def _ensure_filewriter(conf: Any) -> bool:
|
|
592
|
+
"""Start the FileWriter subprocess if one is not already running.
|
|
593
|
+
|
|
594
|
+
``WRITE ... TO <file>`` and ``TEE`` hand their output to a FileWriter
|
|
595
|
+
subprocess, and every entry point in :mod:`execsql.utils.fileio` guards on
|
|
596
|
+
the subprocess being alive — dropping the write when it is not, so that a
|
|
597
|
+
dead writer cannot deadlock the caller on a full pipe. The CLI starts one
|
|
598
|
+
during startup; :func:`run` did not, so every file write under the API was
|
|
599
|
+
silently discarded and the run still reported success.
|
|
600
|
+
|
|
601
|
+
A writer started here outlives the call and is reused by a later
|
|
602
|
+
:func:`run`, exactly as the CLI keeps one for the life of the process; it
|
|
603
|
+
is reaped by an ``atexit`` handler. A writer the caller started themselves
|
|
604
|
+
is left alone.
|
|
605
|
+
|
|
606
|
+
Returns:
|
|
607
|
+
``True`` if this call started the writer, ``False`` if one was already
|
|
608
|
+
running.
|
|
609
|
+
"""
|
|
610
|
+
import execsql.state as _state
|
|
611
|
+
import execsql.utils.fileio as _fileio
|
|
612
|
+
from execsql.utils.fileio import FileWriter, filewriter_end
|
|
613
|
+
|
|
614
|
+
if _fileio.filewriter is not None and _fileio.filewriter.is_alive():
|
|
615
|
+
return False
|
|
616
|
+
|
|
617
|
+
# Drain stale messages so a previously-dead subprocess cannot leak
|
|
618
|
+
# responses or unconsumed commands into the new one. On macOS (`spawn`)
|
|
619
|
+
# the OS pipe buffer is small enough that retained entries would deadlock
|
|
620
|
+
# the next put().
|
|
621
|
+
for q in (_fileio.fw_input, _fileio.fw_output):
|
|
622
|
+
try:
|
|
623
|
+
while True:
|
|
624
|
+
q.get_nowait()
|
|
625
|
+
except Exception:
|
|
626
|
+
pass
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
_fileio.filewriter = _state.filewriter = FileWriter(
|
|
630
|
+
_fileio.fw_input,
|
|
631
|
+
_fileio.fw_output,
|
|
632
|
+
file_encoding=conf.output_encoding,
|
|
633
|
+
open_timeout=getattr(conf, "outfile_open_timeout", 10),
|
|
634
|
+
)
|
|
635
|
+
_state.filewriter.start()
|
|
636
|
+
except Exception:
|
|
637
|
+
# Under the `spawn` start method (macOS, Windows) the subprocess
|
|
638
|
+
# re-imports the parent's __main__, which fails from a REPL, a
|
|
639
|
+
# notebook, or `python -c`. Leave the writer unset: fileio's guards
|
|
640
|
+
# then warn on the first dropped write rather than deadlocking.
|
|
641
|
+
_fileio.filewriter = _state.filewriter = None
|
|
642
|
+
return False
|
|
643
|
+
atexit.register(filewriter_end)
|
|
644
|
+
return True
|
|
574
645
|
|
|
575
646
|
|
|
576
647
|
def _capture_errors(ctx: RuntimeContext, errors: list[ScriptError]) -> None:
|
execsql/config.py
CHANGED
|
@@ -316,6 +316,7 @@ class ConfigData:
|
|
|
316
316
|
self._get_int(cp, self._OUTPUT_SECTION, "zip_buffer_mb", "zip_buffer_mb")
|
|
317
317
|
# --- [interface] ---
|
|
318
318
|
self._get_bool(cp, self._INTERFACE_SECTION, "write_warnings", "write_warnings")
|
|
319
|
+
self._get_bool(cp, self._INTERFACE_SECTION, "support_tier_notice", "support_tier_notice")
|
|
319
320
|
self._get_int(cp, self._INTERFACE_SECTION, "console_height", "gui_console_height", min_val=5)
|
|
320
321
|
self._get_int(cp, self._INTERFACE_SECTION, "console_width", "gui_console_width", min_val=20)
|
|
321
322
|
self._get_bool(cp, self._INTERFACE_SECTION, "console_wait_when_done", "gui_wait_on_exit")
|
|
@@ -409,6 +410,7 @@ class ConfigData:
|
|
|
409
410
|
self.scan_lines = 100
|
|
410
411
|
self.hdf5_text_len = 1000
|
|
411
412
|
self.write_warnings = False
|
|
413
|
+
self.support_tier_notice = True
|
|
412
414
|
self.write_prefix: str | None = None
|
|
413
415
|
self.write_suffix: str | None = None
|
|
414
416
|
self.gui_level: int = 0
|
|
@@ -231,6 +231,11 @@
|
|
|
231
231
|
# Values: Yes or No. Default: No.
|
|
232
232
|
#write_warnings=No
|
|
233
233
|
|
|
234
|
+
# Whether opening a best-effort database connection (MS Access, Firebird,
|
|
235
|
+
# Oracle, ODBC DSN) notes on stderr that the adapter is not verified in CI.
|
|
236
|
+
# Values: Yes or No. Default: Yes.
|
|
237
|
+
#support_tier_notice=Yes
|
|
238
|
+
|
|
234
239
|
# Text to prefix to WRITE output (with a space separator). "clear" removes a previous prefix.
|
|
235
240
|
#write_prefix=
|
|
236
241
|
|
|
@@ -242,6 +247,7 @@
|
|
|
242
247
|
# Settings for the EMAIL metacommand.
|
|
243
248
|
|
|
244
249
|
# SMTP server connection.
|
|
250
|
+
# If username is set, password (or enc_password, below) is required too.
|
|
245
251
|
#host=
|
|
246
252
|
#port=
|
|
247
253
|
#username=
|
execsql/db/access.py
CHANGED
|
@@ -15,6 +15,7 @@ from pathlib import Path
|
|
|
15
15
|
from typing import Any, cast
|
|
16
16
|
|
|
17
17
|
from execsql.db.base import Database
|
|
18
|
+
from execsql.db.tiers import SupportTier
|
|
18
19
|
from execsql.exceptions import ErrInfo
|
|
19
20
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
20
21
|
from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
|
|
@@ -26,6 +27,11 @@ __all__ = ["AccessDatabase"]
|
|
|
26
27
|
class AccessDatabase(Database):
|
|
27
28
|
"""MS Access adapter connecting to .mdb/.accdb files via DAO (win32com) with pyodbc fallback."""
|
|
28
29
|
|
|
30
|
+
#: The Access Database Engine install on the Windows CI runner is
|
|
31
|
+
#: best-effort, so a run can silently skip these tests.
|
|
32
|
+
support_tier = SupportTier.BEST_EFFORT
|
|
33
|
+
support_tier_name = "MS Access"
|
|
34
|
+
|
|
29
35
|
# Regex for the 'create temporary view' SQL extension
|
|
30
36
|
temp_rx = re.compile(
|
|
31
37
|
r"^\s*create(?:\s+or\s+replace)?(\s+temp(?:orary)?)?\s+(?:(view|query))\s+(\w+) as\s+",
|
|
@@ -58,13 +64,15 @@ class AccessDatabase(Database):
|
|
|
58
64
|
fatal_error("The pyodbc module is required. See http://github.com/mkleehammer/pyodbc")
|
|
59
65
|
from execsql.types import dbt_access
|
|
60
66
|
|
|
67
|
+
super().__init__(
|
|
68
|
+
server_name=None,
|
|
69
|
+
db_name=Access_fn,
|
|
70
|
+
user_name=user_name,
|
|
71
|
+
need_passwd=need_passwd,
|
|
72
|
+
)
|
|
61
73
|
self.type = dbt_access
|
|
62
|
-
self.server_name = None
|
|
63
|
-
self.db_name = Access_fn
|
|
64
74
|
# The following assignment is tentative and may be changed when the connection is made.
|
|
65
75
|
self.jet4 = len(Access_fn) > 6 and Access_fn.lower()[-6:] == ".accdb"
|
|
66
|
-
self.user = user_name
|
|
67
|
-
self.need_passwd = need_passwd
|
|
68
76
|
self.password = password
|
|
69
77
|
# Encoding is only applicable to Jet < 4.0: non-accdb databases.
|
|
70
78
|
self.encoding = encoding or "windows-1252"
|
execsql/db/base.py
CHANGED
|
@@ -19,11 +19,12 @@ import datetime
|
|
|
19
19
|
import re
|
|
20
20
|
from abc import ABC, abstractmethod
|
|
21
21
|
from decimal import Decimal
|
|
22
|
-
from typing import Any
|
|
22
|
+
from typing import Any, ClassVar
|
|
23
23
|
from collections.abc import Callable, Generator, Iterator
|
|
24
24
|
|
|
25
25
|
from execsql.exceptions import ErrInfo
|
|
26
26
|
from execsql.utils.errors import exception_desc
|
|
27
|
+
from execsql.db.tiers import SupportTier, announce_tier
|
|
27
28
|
import execsql.state as _state
|
|
28
29
|
|
|
29
30
|
__all__ = ["Database", "DatabasePool"]
|
|
@@ -80,6 +81,15 @@ class Database(ABC):
|
|
|
80
81
|
conn: Any
|
|
81
82
|
autocommit: bool
|
|
82
83
|
|
|
84
|
+
#: How thoroughly this adapter is verified. Subclasses that are not
|
|
85
|
+
#: exercised against a live server in CI override this with
|
|
86
|
+
#: ``SupportTier.BEST_EFFORT``; see :mod:`execsql.db.tiers`.
|
|
87
|
+
support_tier: ClassVar[SupportTier] = SupportTier.SUPPORTED
|
|
88
|
+
|
|
89
|
+
#: Name used in the best-effort notice. Defaults to the class name minus
|
|
90
|
+
#: the ``Database`` suffix (``FirebirdDatabase`` -> ``Firebird``).
|
|
91
|
+
support_tier_name: ClassVar[str | None] = None
|
|
92
|
+
|
|
83
93
|
_dt_cast: dict[type, Callable] | None = None
|
|
84
94
|
|
|
85
95
|
@property
|
|
@@ -778,6 +788,10 @@ class DatabasePool:
|
|
|
778
788
|
)
|
|
779
789
|
self.pool[db_alias].close()
|
|
780
790
|
self.pool[db_alias] = db_obj
|
|
791
|
+
announce_tier(
|
|
792
|
+
db_obj.support_tier,
|
|
793
|
+
db_obj.support_tier_name or type(db_obj).__name__.removesuffix("Database"),
|
|
794
|
+
)
|
|
781
795
|
# Refresh static system vars so $DB_NAME, $DB_USER, etc. reflect the new connection.
|
|
782
796
|
try:
|
|
783
797
|
from execsql.script.engine import set_static_system_vars
|
execsql/db/dsn.py
CHANGED
|
@@ -10,6 +10,7 @@ the CLI.
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
from execsql.db.base import Database
|
|
13
|
+
from execsql.db.tiers import SupportTier
|
|
13
14
|
from execsql.exceptions import ErrInfo
|
|
14
15
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
15
16
|
from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
|
|
@@ -21,6 +22,11 @@ __all__ = ["DsnDatabase"]
|
|
|
21
22
|
class DsnDatabase(Database):
|
|
22
23
|
"""Generic ODBC adapter that connects to any data source registered as an ODBC DSN via pyodbc."""
|
|
23
24
|
|
|
25
|
+
#: CI covers DSN string parsing only — no live DSN is ever opened,
|
|
26
|
+
#: and a DSN may front any DBMS at all.
|
|
27
|
+
support_tier = SupportTier.BEST_EFFORT
|
|
28
|
+
support_tier_name = "ODBC DSN"
|
|
29
|
+
|
|
24
30
|
# There's no telling what is actually connected to a DSN, so this uses
|
|
25
31
|
# generic Database methods almost exclusively. Only 'exec_cmd()' is
|
|
26
32
|
# overridden, and that uses the method for SQL Server because the DAO
|
|
@@ -41,18 +47,17 @@ class DsnDatabase(Database):
|
|
|
41
47
|
fatal_error("The pyodbc module is required. See http://github.com/mkleehammer/pyodbc")
|
|
42
48
|
from execsql.types import dbt_dsn
|
|
43
49
|
|
|
50
|
+
super().__init__(
|
|
51
|
+
server_name=None,
|
|
52
|
+
db_name=dsn_name,
|
|
53
|
+
user_name=user_name,
|
|
54
|
+
need_passwd=need_passwd,
|
|
55
|
+
)
|
|
44
56
|
self.type = dbt_dsn
|
|
45
|
-
self.server_name = None
|
|
46
|
-
self.db_name = dsn_name
|
|
47
|
-
self.user = user_name
|
|
48
|
-
self.need_passwd = need_passwd
|
|
49
57
|
self.password = password
|
|
50
|
-
self.port = None
|
|
51
58
|
self.encoding = encoding
|
|
52
59
|
self.encode_commands = True
|
|
53
60
|
self.paramstr = "?"
|
|
54
|
-
self.conn = None
|
|
55
|
-
self.autocommit = True
|
|
56
61
|
self.open_db()
|
|
57
62
|
self.password = None # Clear cleartext password after successful connection
|
|
58
63
|
|
execsql/db/duckdb.py
CHANGED
|
@@ -28,17 +28,16 @@ class DuckDBDatabase(Database):
|
|
|
28
28
|
fatal_error("The duckdb module is required.")
|
|
29
29
|
from execsql.types import dbt_duckdb
|
|
30
30
|
|
|
31
|
+
super().__init__(
|
|
32
|
+
server_name=None,
|
|
33
|
+
db_name=DuckDB_fn,
|
|
34
|
+
user_name=None,
|
|
35
|
+
need_passwd=False,
|
|
36
|
+
encoding="UTF-8",
|
|
37
|
+
)
|
|
31
38
|
self.type = dbt_duckdb
|
|
32
|
-
self.server_name = None
|
|
33
|
-
self.db_name = DuckDB_fn
|
|
34
39
|
self.catalog_name = Path(DuckDB_fn).stem
|
|
35
|
-
self.user = None
|
|
36
|
-
self.need_passwd = False
|
|
37
|
-
self.encoding = "UTF-8"
|
|
38
40
|
self.encode_commands = False
|
|
39
|
-
self.paramstr = "?"
|
|
40
|
-
self.conn = None
|
|
41
|
-
self.autocommit = True
|
|
42
41
|
self.open_db()
|
|
43
42
|
|
|
44
43
|
def __repr__(self) -> str:
|
execsql/db/firebird.py
CHANGED
|
@@ -9,6 +9,7 @@ via the ``firebird-driver`` package. Corresponds to ``-t f`` on the CLI.
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
from execsql.db.base import Database
|
|
12
|
+
from execsql.db.tiers import SupportTier
|
|
12
13
|
from execsql.exceptions import ErrInfo
|
|
13
14
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
14
15
|
from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
|
|
@@ -20,6 +21,10 @@ __all__ = ["FirebirdDatabase"]
|
|
|
20
21
|
class FirebirdDatabase(Database):
|
|
21
22
|
"""Firebird adapter using the firebird-driver package."""
|
|
22
23
|
|
|
24
|
+
#: No Firebird server runs in CI; this adapter has no tests.
|
|
25
|
+
support_tier = SupportTier.BEST_EFFORT
|
|
26
|
+
support_tier_name = "Firebird"
|
|
27
|
+
|
|
23
28
|
def __init__(
|
|
24
29
|
self,
|
|
25
30
|
server_name: str,
|
|
@@ -39,18 +44,18 @@ class FirebirdDatabase(Database):
|
|
|
39
44
|
)
|
|
40
45
|
from execsql.types import dbt_firebird
|
|
41
46
|
|
|
47
|
+
super().__init__(
|
|
48
|
+
server_name=str(server_name),
|
|
49
|
+
db_name=str(db_name),
|
|
50
|
+
user_name=str(user_name),
|
|
51
|
+
need_passwd=need_passwd,
|
|
52
|
+
port=port if port else 3050,
|
|
53
|
+
)
|
|
42
54
|
self.type = dbt_firebird
|
|
43
|
-
self.server_name = str(server_name)
|
|
44
|
-
self.db_name = str(db_name)
|
|
45
|
-
self.user = str(user_name)
|
|
46
|
-
self.need_passwd = need_passwd
|
|
47
55
|
self.password = password
|
|
48
|
-
self.port = port if port else 3050
|
|
49
56
|
self.encoding = encoding or "latin1"
|
|
50
57
|
self.encode_commands = True
|
|
51
58
|
self.paramstr = "?"
|
|
52
|
-
self.conn = None
|
|
53
|
-
self.autocommit = True
|
|
54
59
|
self.open_db()
|
|
55
60
|
self.password = None # Clear cleartext password after successful connection
|
|
56
61
|
|
execsql/db/mysql.py
CHANGED
|
@@ -22,6 +22,7 @@ __all__ = ["MySQLDatabase"]
|
|
|
22
22
|
_PYTHON_TO_MYSQL_CHARSET: dict[str, str] = {
|
|
23
23
|
"utf-8": "utf8mb4",
|
|
24
24
|
"utf8": "utf8mb4",
|
|
25
|
+
"utf8mb4": "utf8mb4",
|
|
25
26
|
"latin-1": "latin1",
|
|
26
27
|
"iso-8859-1": "latin1",
|
|
27
28
|
"iso8859-1": "latin1",
|
|
@@ -31,6 +32,20 @@ _PYTHON_TO_MYSQL_CHARSET: dict[str, str] = {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def _crlf_terminated(filename: str) -> bool:
|
|
36
|
+
"""True if the file's first line ends with CRLF.
|
|
37
|
+
|
|
38
|
+
Read as bytes so no decoding or newline translation can hide the answer.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
with open(filename, "rb") as f:
|
|
42
|
+
chunk = f.read(65536)
|
|
43
|
+
except OSError:
|
|
44
|
+
return False
|
|
45
|
+
i = chunk.find(b"\n")
|
|
46
|
+
return i > 0 and chunk[i - 1 : i] == b"\r"
|
|
47
|
+
|
|
48
|
+
|
|
34
49
|
class MySQLDatabase(Database):
|
|
35
50
|
"""MySQL and MariaDB adapter using the pymysql package."""
|
|
36
51
|
|
|
@@ -41,7 +56,7 @@ class MySQLDatabase(Database):
|
|
|
41
56
|
user_name: str | None,
|
|
42
57
|
need_passwd: bool = False,
|
|
43
58
|
port: int | None = 3306,
|
|
44
|
-
encoding: str | None = "
|
|
59
|
+
encoding: str | None = "utf8mb4",
|
|
45
60
|
password: str | None = None,
|
|
46
61
|
) -> None:
|
|
47
62
|
try:
|
|
@@ -52,18 +67,23 @@ class MySQLDatabase(Database):
|
|
|
52
67
|
)
|
|
53
68
|
from execsql.types import dbt_mysql
|
|
54
69
|
|
|
70
|
+
super().__init__(
|
|
71
|
+
server_name=str(server_name) if server_name is not None else None,
|
|
72
|
+
db_name=str(db_name) if db_name is not None else None,
|
|
73
|
+
user_name=str(user_name) if user_name is not None else None,
|
|
74
|
+
need_passwd=need_passwd,
|
|
75
|
+
port=port if port else 3306,
|
|
76
|
+
)
|
|
55
77
|
self.type = dbt_mysql
|
|
56
|
-
self.server_name = str(server_name) if server_name is not None else None
|
|
57
|
-
self.db_name = str(db_name) if db_name is not None else None
|
|
58
|
-
self.user = str(user_name) if user_name is not None else None
|
|
59
|
-
self.need_passwd = need_passwd
|
|
60
78
|
self.password = password
|
|
61
|
-
|
|
62
|
-
|
|
79
|
+
# utf8mb4, not latin1: the connection charset only governs how text moves
|
|
80
|
+
# between client and server, and MySQL transcodes to and from each
|
|
81
|
+
# column's own charset. A latin1 connection cannot carry CJK, emoji,
|
|
82
|
+
# Greek, or Cyrillic at all — it raises UnicodeEncodeError before the
|
|
83
|
+
# server is reached — so it silently capped what execsql could move.
|
|
84
|
+
self.encoding = encoding or "utf8mb4"
|
|
63
85
|
self.encode_commands = True
|
|
64
86
|
self.paramstr = "%s"
|
|
65
|
-
self.conn = None
|
|
66
|
-
self.autocommit = True
|
|
67
87
|
self.open_db()
|
|
68
88
|
self.password = None # Clear cleartext password after successful connection
|
|
69
89
|
|
|
@@ -297,8 +317,22 @@ class MySQLDatabase(Database):
|
|
|
297
317
|
if csv_file_obj.quotechar:
|
|
298
318
|
safe_quote = csv_file_obj.quotechar.replace("'", "''")
|
|
299
319
|
import_sql = f"{import_sql} optionally enclosed by '{safe_quote}'"
|
|
320
|
+
# Tell LOAD DATA what ends a line. Its default is "\n", so a CRLF
|
|
321
|
+
# file left a carriage return on the last field of every row: the
|
|
322
|
+
# value was not empty, NULLIF below could not see it, and a date
|
|
323
|
+
# column silently became 0000-00-00. Every other backend reads the
|
|
324
|
+
# same file correctly.
|
|
325
|
+
if _crlf_terminated(csv_file_obj.csvfname):
|
|
326
|
+
import_sql = f"{import_sql} lines terminated by '\\r\\n'"
|
|
300
327
|
import_sql = f"{import_sql} ignore {1 + csv_file_obj.junk_header_lines} lines"
|
|
301
|
-
|
|
328
|
+
# Read every column into a user variable and NULLIF it back. LOAD
|
|
329
|
+
# DATA otherwise coerces an empty field to the column's zero value
|
|
330
|
+
# — an empty numeric became 0 and an empty date 0000-00-00 — so the
|
|
331
|
+
# same CSV imported to MySQL differed from every other backend,
|
|
332
|
+
# where an empty field is NULL.
|
|
333
|
+
vars_list = ",".join(f"@ecs_{i}" for i in range(len(import_cols)))
|
|
334
|
+
set_list = ",".join(f"{col}=nullif(@ecs_{i},'')" for i, col in enumerate(import_cols))
|
|
335
|
+
import_sql = f"{import_sql} ({vars_list}) set {set_list};"
|
|
302
336
|
if _state.exec_log is not None:
|
|
303
337
|
_state.exec_log.log_status_info(
|
|
304
338
|
f"IMPORTing {csv_file_obj.csvfname} using the DBMS' fast file reading routine",
|
execsql/db/oracle.py
CHANGED
|
@@ -11,6 +11,7 @@ on the CLI.
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
from execsql.db.base import Database
|
|
14
|
+
from execsql.db.tiers import SupportTier
|
|
14
15
|
from execsql.exceptions import ErrInfo
|
|
15
16
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
16
17
|
from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
|
|
@@ -22,6 +23,10 @@ __all__ = ["OracleDatabase"]
|
|
|
22
23
|
class OracleDatabase(Database):
|
|
23
24
|
"""Oracle adapter using the cx_Oracle (python-oracledb) driver."""
|
|
24
25
|
|
|
26
|
+
#: No Oracle server runs in CI; this adapter has no tests.
|
|
27
|
+
support_tier = SupportTier.BEST_EFFORT
|
|
28
|
+
support_tier_name = "Oracle"
|
|
29
|
+
|
|
25
30
|
def __init__(
|
|
26
31
|
self,
|
|
27
32
|
server_name: str,
|
|
@@ -40,18 +45,18 @@ class OracleDatabase(Database):
|
|
|
40
45
|
)
|
|
41
46
|
from execsql.types import dbt_oracle
|
|
42
47
|
|
|
48
|
+
super().__init__(
|
|
49
|
+
server_name=server_name,
|
|
50
|
+
db_name=db_name,
|
|
51
|
+
user_name=user_name,
|
|
52
|
+
need_passwd=need_passwd,
|
|
53
|
+
port=port if port else 1521,
|
|
54
|
+
)
|
|
43
55
|
self.type = dbt_oracle
|
|
44
|
-
self.server_name = server_name
|
|
45
|
-
self.db_name = db_name
|
|
46
|
-
self.user = user_name
|
|
47
|
-
self.need_passwd = need_passwd
|
|
48
56
|
self.password = password
|
|
49
|
-
self.port = port if port else 1521
|
|
50
57
|
self.encoding = encoding or "UTF8"
|
|
51
58
|
self.encode_commands = False
|
|
52
59
|
self.paramstr = ":1"
|
|
53
|
-
self.conn = None
|
|
54
|
-
self.autocommit = True
|
|
55
60
|
self.open_db()
|
|
56
61
|
self.password = None # Clear cleartext password after successful connection
|
|
57
62
|
|
execsql/db/postgres.py
CHANGED
|
@@ -49,20 +49,20 @@ class PostgresDatabase(Database):
|
|
|
49
49
|
)
|
|
50
50
|
from execsql.types import dbt_postgres
|
|
51
51
|
|
|
52
|
+
super().__init__(
|
|
53
|
+
server_name=server_name,
|
|
54
|
+
db_name=db_name,
|
|
55
|
+
user_name=user_name,
|
|
56
|
+
need_passwd=need_passwd,
|
|
57
|
+
port=port if port else 5432,
|
|
58
|
+
encoding=encoding or "UTF8",
|
|
59
|
+
)
|
|
52
60
|
self.type = dbt_postgres
|
|
53
|
-
self.server_name = server_name
|
|
54
|
-
self.db_name = db_name
|
|
55
|
-
self.user = user_name
|
|
56
|
-
self.need_passwd = need_passwd
|
|
57
61
|
self.password = password
|
|
58
|
-
self.port = port if port else 5432
|
|
59
62
|
self.new_db = new_db
|
|
60
|
-
self.encoding = encoding or "UTF8"
|
|
61
63
|
self.encode_commands = False
|
|
62
64
|
self.paramstr = "%s"
|
|
63
65
|
self.connect_timeout = connect_timeout
|
|
64
|
-
self.conn = None
|
|
65
|
-
self.autocommit = True
|
|
66
66
|
self.open_db()
|
|
67
67
|
self.password = None # Clear cleartext password after successful connection
|
|
68
68
|
|
|
@@ -318,8 +318,16 @@ class PostgresDatabase(Database):
|
|
|
318
318
|
input_enc = csv_file_obj.encoding.lower()
|
|
319
319
|
if input_enc in enc_xlates:
|
|
320
320
|
input_enc = enc_xlates[input_enc]
|
|
321
|
+
# COPY's CSV format always has a quote character — `"` unless told
|
|
322
|
+
# otherwise — so it cannot read a file that has none: with QUOTE NONE,
|
|
323
|
+
# or when none is detected, it would strip the `"` from `Well "A" 12`.
|
|
324
|
+
# Nor is it told about a detected escape character, so `\"` inside a
|
|
325
|
+
# quoted field came out as `\`. Both kinds of file take the row-by-row
|
|
326
|
+
# path, which reads the format that was detected.
|
|
321
327
|
if (
|
|
322
|
-
|
|
328
|
+
csv_file_obj.quotechar is not None
|
|
329
|
+
and csv_file_obj.escapechar is None
|
|
330
|
+
and encodings_match(input_enc, self.encoding)
|
|
323
331
|
and data_table_cols == csv_file_cols
|
|
324
332
|
and _state.conf.empty_strings
|
|
325
333
|
and _state.conf.empty_rows
|
execsql/db/sqlite.py
CHANGED
|
@@ -33,17 +33,16 @@ class SQLiteDatabase(Database):
|
|
|
33
33
|
fatal_error("The sqlite3 module is required.")
|
|
34
34
|
from execsql.types import dbt_sqlite
|
|
35
35
|
|
|
36
|
+
super().__init__(
|
|
37
|
+
server_name=None,
|
|
38
|
+
db_name=SQLite_fn,
|
|
39
|
+
user_name=None,
|
|
40
|
+
need_passwd=False,
|
|
41
|
+
encoding="UTF-8",
|
|
42
|
+
)
|
|
36
43
|
self.type = dbt_sqlite
|
|
37
|
-
self.server_name = None
|
|
38
|
-
self.db_name = SQLite_fn
|
|
39
|
-
self.user = None
|
|
40
|
-
self.need_passwd = False
|
|
41
|
-
self.encoding = "UTF-8"
|
|
42
44
|
self.encode_commands = False
|
|
43
|
-
self.paramstr = "?"
|
|
44
45
|
self.timeout = timeout
|
|
45
|
-
self.conn = None
|
|
46
|
-
self.autocommit = True
|
|
47
46
|
self.open_db()
|
|
48
47
|
|
|
49
48
|
def __repr__(self) -> str:
|
execsql/db/sqlserver.py
CHANGED
|
@@ -37,18 +37,18 @@ class SqlServerDatabase(Database):
|
|
|
37
37
|
fatal_error("The pyodbc module is required. See http://github.com/mkleehammer/pyodbc")
|
|
38
38
|
from execsql.types import dbt_sqlserver
|
|
39
39
|
|
|
40
|
+
super().__init__(
|
|
41
|
+
server_name=server_name,
|
|
42
|
+
db_name=db_name,
|
|
43
|
+
user_name=user_name,
|
|
44
|
+
need_passwd=need_passwd,
|
|
45
|
+
port=port if port else 1433,
|
|
46
|
+
)
|
|
40
47
|
self.type = dbt_sqlserver
|
|
41
|
-
self.server_name = server_name
|
|
42
|
-
self.db_name = db_name
|
|
43
|
-
self.user = user_name
|
|
44
|
-
self.need_passwd = need_passwd
|
|
45
48
|
self.password = password
|
|
46
|
-
self.port = port if port else 1433
|
|
47
49
|
self.encoding = encoding or "latin1" # Default on installation of SQL Server
|
|
48
50
|
self.encode_commands = True
|
|
49
51
|
self.paramstr = "?"
|
|
50
|
-
self.conn = None
|
|
51
|
-
self.autocommit = True
|
|
52
52
|
self.open_db()
|
|
53
53
|
self.password = None # Clear cleartext password after successful connection
|
|
54
54
|
|
execsql/db/tiers.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Database support tiers.
|
|
5
|
+
|
|
6
|
+
execsql2 ships nine DBMS adapters, but only five of them are exercised
|
|
7
|
+
against a live server (or a real database file) on every CI run. The other
|
|
8
|
+
four are carried forward from the upstream monolith and are not verified
|
|
9
|
+
anywhere. Documenting all nine with equal confidence tells users nothing
|
|
10
|
+
about which ones have actually been run.
|
|
11
|
+
|
|
12
|
+
:class:`SupportTier` records that distinction, each adapter declares its own
|
|
13
|
+
tier via ``Database.support_tier``, and :func:`announce_tier` emits a single
|
|
14
|
+
informational line the first time a session opens a Tier 2 connection.
|
|
15
|
+
|
|
16
|
+
The notice is written to stderr (never stdout, so it cannot corrupt piped
|
|
17
|
+
query output) and can be switched off with ``support_tier_notice=No`` in the
|
|
18
|
+
``[interface]`` section of ``execsql.conf``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import enum
|
|
22
|
+
|
|
23
|
+
import execsql.state as _state
|
|
24
|
+
|
|
25
|
+
__all__ = ["SupportTier", "announce_tier"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SupportTier(enum.Enum):
|
|
29
|
+
"""How thoroughly a DBMS adapter is verified.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
SUPPORTED: Tested against a live server or real database file on
|
|
33
|
+
every CI run. Regressions block a release, and bugs are fixed.
|
|
34
|
+
BEST_EFFORT: Inherited from the upstream monolith and not verified
|
|
35
|
+
in CI. The code is present and may work; nothing proves it
|
|
36
|
+
still does. Issues and pull requests are welcome, but no
|
|
37
|
+
guarantee is made.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
SUPPORTED = "supported"
|
|
41
|
+
BEST_EFFORT = "best effort"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_NOTICE = (
|
|
45
|
+
"Note: {dbms} support is best-effort — it is not verified in CI and may break. "
|
|
46
|
+
"Set support_tier_notice=No in the [interface] section of execsql.conf to silence this."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def announce_tier(tier: SupportTier, dbms_name: str) -> None:
|
|
51
|
+
"""Emit the best-effort notice for ``dbms_name``, at most once per session.
|
|
52
|
+
|
|
53
|
+
Silent for :attr:`SupportTier.SUPPORTED`, when the user has set
|
|
54
|
+
``support_tier_notice=No``, and before output hooks are installed (which is
|
|
55
|
+
the case for adapters built by the test suite and by library callers that
|
|
56
|
+
have not started a run).
|
|
57
|
+
|
|
58
|
+
Which names have been announced is tracked on the runtime context, so
|
|
59
|
+
:func:`execsql.state.reset` clears it between runs and between tests, and a
|
|
60
|
+
script that opens twenty Firebird connections prints the notice once.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
tier: The adapter's declared support tier.
|
|
64
|
+
dbms_name: Human-readable DBMS name used in the message.
|
|
65
|
+
"""
|
|
66
|
+
if tier is not SupportTier.BEST_EFFORT:
|
|
67
|
+
return
|
|
68
|
+
conf = _state.conf
|
|
69
|
+
if conf is not None and not getattr(conf, "support_tier_notice", True):
|
|
70
|
+
return
|
|
71
|
+
shown = _state.tier_notices_shown
|
|
72
|
+
if dbms_name in shown:
|
|
73
|
+
return
|
|
74
|
+
shown.add(dbms_name)
|
|
75
|
+
message = _NOTICE.format(dbms=dbms_name)
|
|
76
|
+
if _state.exec_log is not None:
|
|
77
|
+
_state.exec_log.log_status_info(message)
|
|
78
|
+
if _state.output is not None:
|
|
79
|
+
_state.output.write_err(message)
|
execsql/exporters/delimited.py
CHANGED
|
@@ -386,6 +386,16 @@ class CsvFile(EncodedFile):
|
|
|
386
386
|
break
|
|
387
387
|
except:
|
|
388
388
|
raise
|
|
389
|
+
# A quoted field may contain a newline — RFC 4180 allows it, and
|
|
390
|
+
# execsql's own CSV writer produces it. Read on until the quotes
|
|
391
|
+
# balance, so diagnosis sees one logical record rather than two
|
|
392
|
+
# physical lines whose delimiter counts disagree; an odd count made
|
|
393
|
+
# the real delimiter look inconsistent and it was rejected.
|
|
394
|
+
while ln.count('"') % 2 == 1:
|
|
395
|
+
try:
|
|
396
|
+
ln += next(linestream)
|
|
397
|
+
except StopIteration:
|
|
398
|
+
break
|
|
389
399
|
while len(ln) > 0 and ln[-1] in ("\n", "\r"):
|
|
390
400
|
ln = ln[:-1]
|
|
391
401
|
if len(ln) > 0:
|
|
@@ -799,7 +809,11 @@ def write_delimited_file(
|
|
|
799
809
|
else:
|
|
800
810
|
fmode = "w" if not append else "a"
|
|
801
811
|
filewriter_close(outfile)
|
|
802
|
-
|
|
812
|
+
# newline="": a delimited field may legitimately contain a newline, and
|
|
813
|
+
# universal-newline translation would rewrite it to \r\n on Windows,
|
|
814
|
+
# changing the exported value. Row terminators stay "\n" on every
|
|
815
|
+
# platform, so the same query now produces the same bytes everywhere.
|
|
816
|
+
ofile = EncodedFile(outfile, file_encoding).open(mode=fmode, newline="")
|
|
803
817
|
fdesc = outfile
|
|
804
818
|
try:
|
|
805
819
|
if not (filefmt.lower() == "plain" or (append and zipfile is None)):
|