execsql2 2.22.9__py3-none-any.whl → 2.23.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.
- execsql/api.py +71 -0
- execsql/config.py +2 -0
- execsql/data/execsql.conf.template +5 -0
- execsql/db/access.py +34 -10
- execsql/db/base.py +120 -2
- execsql/db/dsn.py +14 -7
- execsql/db/duckdb.py +102 -10
- execsql/db/firebird.py +12 -7
- execsql/db/mysql.py +55 -10
- execsql/db/oracle.py +24 -11
- execsql/db/postgres.py +17 -9
- execsql/db/sqlite.py +23 -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
- {execsql2-2.22.9.dist-info → execsql2-2.23.1.dist-info}/METADATA +22 -13
- {execsql2-2.22.9.dist-info → execsql2-2.23.1.dist-info}/RECORD +47 -46
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.22.9.dist-info → execsql2-2.23.1.dist-info}/WHEEL +0 -0
- {execsql2-2.22.9.dist-info → execsql2-2.23.1.dist-info}/entry_points.txt +0 -0
- {execsql2-2.22.9.dist-info → execsql2-2.23.1.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.22.9.dist-info → execsql2-2.23.1.dist-info}/licenses/NOTICE +0 -0
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,9 +32,34 @@ _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
|
|
|
52
|
+
#: Statements MySQL commits at once, together with everything before them
|
|
53
|
+
#: ("Statements That Cause an Implicit Commit" in the MySQL manual).
|
|
54
|
+
#: CREATE and DROP TEMPORARY TABLE are the exception.
|
|
55
|
+
implicit_commit_rx = re.compile(
|
|
56
|
+
r"(?:CREATE|ALTER|DROP)\s+(?!TEMPORARY\b)(?:OR\s+REPLACE\s+)?(?:UNIQUE\s+|FULLTEXT\s+|SPATIAL\s+)?\w+"
|
|
57
|
+
r"|RENAME\s+\w+|TRUNCATE\b|GRANT\b|REVOKE\b|LOCK\s+TABLES?\b|UNLOCK\s+TABLES?\b"
|
|
58
|
+
r"|BEGIN\b|START\s+TRANSACTION\b|(?:ANALYZE|CHECK|OPTIMIZE|REPAIR)\s+TABLE\b|FLUSH\b"
|
|
59
|
+
r"|(?:UN)?INSTALL\s+PLUGIN\b|SET\s+PASSWORD\b|SET\s+(?:SESSION\s+|@@)?autocommit\b",
|
|
60
|
+
re.I,
|
|
61
|
+
)
|
|
62
|
+
|
|
37
63
|
def __init__(
|
|
38
64
|
self,
|
|
39
65
|
server_name: str,
|
|
@@ -41,7 +67,7 @@ class MySQLDatabase(Database):
|
|
|
41
67
|
user_name: str | None,
|
|
42
68
|
need_passwd: bool = False,
|
|
43
69
|
port: int | None = 3306,
|
|
44
|
-
encoding: str | None = "
|
|
70
|
+
encoding: str | None = "utf8mb4",
|
|
45
71
|
password: str | None = None,
|
|
46
72
|
) -> None:
|
|
47
73
|
try:
|
|
@@ -52,18 +78,23 @@ class MySQLDatabase(Database):
|
|
|
52
78
|
)
|
|
53
79
|
from execsql.types import dbt_mysql
|
|
54
80
|
|
|
81
|
+
super().__init__(
|
|
82
|
+
server_name=str(server_name) if server_name is not None else None,
|
|
83
|
+
db_name=str(db_name) if db_name is not None else None,
|
|
84
|
+
user_name=str(user_name) if user_name is not None else None,
|
|
85
|
+
need_passwd=need_passwd,
|
|
86
|
+
port=port if port else 3306,
|
|
87
|
+
)
|
|
55
88
|
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
89
|
self.password = password
|
|
61
|
-
|
|
62
|
-
|
|
90
|
+
# utf8mb4, not latin1: the connection charset only governs how text moves
|
|
91
|
+
# between client and server, and MySQL transcodes to and from each
|
|
92
|
+
# column's own charset. A latin1 connection cannot carry CJK, emoji,
|
|
93
|
+
# Greek, or Cyrillic at all — it raises UnicodeEncodeError before the
|
|
94
|
+
# server is reached — so it silently capped what execsql could move.
|
|
95
|
+
self.encoding = encoding or "utf8mb4"
|
|
63
96
|
self.encode_commands = True
|
|
64
97
|
self.paramstr = "%s"
|
|
65
|
-
self.conn = None
|
|
66
|
-
self.autocommit = True
|
|
67
98
|
self.open_db()
|
|
68
99
|
self.password = None # Clear cleartext password after successful connection
|
|
69
100
|
|
|
@@ -297,8 +328,22 @@ class MySQLDatabase(Database):
|
|
|
297
328
|
if csv_file_obj.quotechar:
|
|
298
329
|
safe_quote = csv_file_obj.quotechar.replace("'", "''")
|
|
299
330
|
import_sql = f"{import_sql} optionally enclosed by '{safe_quote}'"
|
|
331
|
+
# Tell LOAD DATA what ends a line. Its default is "\n", so a CRLF
|
|
332
|
+
# file left a carriage return on the last field of every row: the
|
|
333
|
+
# value was not empty, NULLIF below could not see it, and a date
|
|
334
|
+
# column silently became 0000-00-00. Every other backend reads the
|
|
335
|
+
# same file correctly.
|
|
336
|
+
if _crlf_terminated(csv_file_obj.csvfname):
|
|
337
|
+
import_sql = f"{import_sql} lines terminated by '\\r\\n'"
|
|
300
338
|
import_sql = f"{import_sql} ignore {1 + csv_file_obj.junk_header_lines} lines"
|
|
301
|
-
|
|
339
|
+
# Read every column into a user variable and NULLIF it back. LOAD
|
|
340
|
+
# DATA otherwise coerces an empty field to the column's zero value
|
|
341
|
+
# — an empty numeric became 0 and an empty date 0000-00-00 — so the
|
|
342
|
+
# same CSV imported to MySQL differed from every other backend,
|
|
343
|
+
# where an empty field is NULL.
|
|
344
|
+
vars_list = ",".join(f"@ecs_{i}" for i in range(len(import_cols)))
|
|
345
|
+
set_list = ",".join(f"{col}=nullif(@ecs_{i},'')" for i, col in enumerate(import_cols))
|
|
346
|
+
import_sql = f"{import_sql} ({vars_list}) set {set_list};"
|
|
302
347
|
if _state.exec_log is not None:
|
|
303
348
|
_state.exec_log.log_status_info(
|
|
304
349
|
f"IMPORTing {csv_file_obj.csvfname} using the DBMS' fast file reading routine",
|
execsql/db/oracle.py
CHANGED
|
@@ -8,9 +8,11 @@ via the ``oracledb`` driver (python-oracledb). Corresponds to ``-t o``
|
|
|
8
8
|
on the CLI.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import re
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
13
14
|
from execsql.db.base import Database
|
|
15
|
+
from execsql.db.tiers import SupportTier
|
|
14
16
|
from execsql.exceptions import ErrInfo
|
|
15
17
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
16
18
|
from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
|
|
@@ -22,6 +24,18 @@ __all__ = ["OracleDatabase"]
|
|
|
22
24
|
class OracleDatabase(Database):
|
|
23
25
|
"""Oracle adapter using the cx_Oracle (python-oracledb) driver."""
|
|
24
26
|
|
|
27
|
+
#: Oracle commits every DDL statement at once, together with everything
|
|
28
|
+
#: before it, including CREATE GLOBAL TEMPORARY TABLE.
|
|
29
|
+
implicit_commit_rx = re.compile(
|
|
30
|
+
r"(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:GLOBAL\s+TEMPORARY\s+|UNIQUE\s+|BITMAP\s+)?\w+"
|
|
31
|
+
r"|TRUNCATE\s+\w+|RENAME\b|GRANT\b|REVOKE\b|COMMENT\s+ON\b|(?:NO)?AUDIT\b|ANALYZE\b|PURGE\b|FLASHBACK\b",
|
|
32
|
+
re.I,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
#: No Oracle server runs in CI; this adapter has no tests.
|
|
36
|
+
support_tier = SupportTier.BEST_EFFORT
|
|
37
|
+
support_tier_name = "Oracle"
|
|
38
|
+
|
|
25
39
|
def __init__(
|
|
26
40
|
self,
|
|
27
41
|
server_name: str,
|
|
@@ -40,18 +54,18 @@ class OracleDatabase(Database):
|
|
|
40
54
|
)
|
|
41
55
|
from execsql.types import dbt_oracle
|
|
42
56
|
|
|
57
|
+
super().__init__(
|
|
58
|
+
server_name=server_name,
|
|
59
|
+
db_name=db_name,
|
|
60
|
+
user_name=user_name,
|
|
61
|
+
need_passwd=need_passwd,
|
|
62
|
+
port=port if port else 1521,
|
|
63
|
+
)
|
|
43
64
|
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
65
|
self.password = password
|
|
49
|
-
self.port = port if port else 1521
|
|
50
66
|
self.encoding = encoding or "UTF8"
|
|
51
67
|
self.encode_commands = False
|
|
52
68
|
self.paramstr = ":1"
|
|
53
|
-
self.conn = None
|
|
54
|
-
self.autocommit = True
|
|
55
69
|
self.open_db()
|
|
56
70
|
self.password = None # Clear cleartext password after successful connection
|
|
57
71
|
|
|
@@ -105,13 +119,12 @@ class OracleDatabase(Database):
|
|
|
105
119
|
msg = f"Failed to open Oracle database {self.db_name} on {self.server_name}"
|
|
106
120
|
raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg) from e
|
|
107
121
|
|
|
108
|
-
def execute(self, sql: Any, paramlist: list | None = None) -> None:
|
|
122
|
+
def execute(self, sql: Any, paramlist: list | None = None, *, fetch: bool = False) -> tuple[list[str], list] | None:
|
|
109
123
|
"""Execute a SQL command, stripping any trailing semicolon for Oracle."""
|
|
110
124
|
# Strip any semicolon off the end and pass to the parent method.
|
|
111
125
|
if sql[-1:] == ";":
|
|
112
|
-
super().execute(sql[:-1], paramlist)
|
|
113
|
-
|
|
114
|
-
super().execute(sql, paramlist)
|
|
126
|
+
return super().execute(sql[:-1], paramlist, fetch=fetch)
|
|
127
|
+
return super().execute(sql, paramlist, fetch=fetch)
|
|
115
128
|
|
|
116
129
|
def select_data(self, sql: str) -> tuple[list[str], list]:
|
|
117
130
|
"""Return column names and all rows from a SELECT statement."""
|
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
|
@@ -26,6 +26,10 @@ DEFAULT_CONNECT_TIMEOUT = 30 # seconds
|
|
|
26
26
|
class SQLiteDatabase(Database):
|
|
27
27
|
"""SQLite adapter using the Python standard-library sqlite3 module."""
|
|
28
28
|
|
|
29
|
+
#: SQLite refuses VACUUM, ATTACH and DETACH inside a transaction, and
|
|
30
|
+
#: ignores some PRAGMAs there (``foreign_keys``).
|
|
31
|
+
no_transaction_rx = re.compile(r"(?:VACUUM|ATTACH|DETACH|PRAGMA)\b", re.I)
|
|
32
|
+
|
|
29
33
|
def __init__(self, SQLite_fn: str, timeout: float = DEFAULT_CONNECT_TIMEOUT) -> None:
|
|
30
34
|
try:
|
|
31
35
|
import sqlite3 # noqa: F401
|
|
@@ -33,22 +37,33 @@ class SQLiteDatabase(Database):
|
|
|
33
37
|
fatal_error("The sqlite3 module is required.")
|
|
34
38
|
from execsql.types import dbt_sqlite
|
|
35
39
|
|
|
40
|
+
super().__init__(
|
|
41
|
+
server_name=None,
|
|
42
|
+
db_name=SQLite_fn,
|
|
43
|
+
user_name=None,
|
|
44
|
+
need_passwd=False,
|
|
45
|
+
encoding="UTF-8",
|
|
46
|
+
)
|
|
36
47
|
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
48
|
self.encode_commands = False
|
|
43
|
-
self.paramstr = "?"
|
|
44
49
|
self.timeout = timeout
|
|
45
|
-
self.conn = None
|
|
46
|
-
self.autocommit = True
|
|
47
50
|
self.open_db()
|
|
48
51
|
|
|
49
52
|
def __repr__(self) -> str:
|
|
50
53
|
return f"SQLiteDatabase({self.db_name!r})"
|
|
51
54
|
|
|
55
|
+
def begin_transaction(self) -> None:
|
|
56
|
+
"""Open a transaction if none is open.
|
|
57
|
+
|
|
58
|
+
Python's sqlite3 module opens one on its own only before INSERT,
|
|
59
|
+
UPDATE, DELETE and REPLACE. Without this, a CREATE TABLE or DROP run
|
|
60
|
+
under AUTOCOMMIT OFF or in a batch would be committed at once.
|
|
61
|
+
"""
|
|
62
|
+
if self.conn is None:
|
|
63
|
+
self.open_db()
|
|
64
|
+
if not self.conn.in_transaction:
|
|
65
|
+
self.conn.execute("BEGIN")
|
|
66
|
+
|
|
52
67
|
def open_db(self) -> None:
|
|
53
68
|
"""Open a connection to the SQLite database file."""
|
|
54
69
|
import sqlite3
|
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)):
|
execsql/exporters/html.py
CHANGED
|
@@ -51,7 +51,10 @@ def export_html(
|
|
|
51
51
|
for r in rows:
|
|
52
52
|
f.write("<tr>")
|
|
53
53
|
for v in r:
|
|
54
|
-
|
|
54
|
+
# `if v is not None`, not `if v`: 0, False, and an empty
|
|
55
|
+
# string are all falsy, and a zero measurement silently became
|
|
56
|
+
# an empty cell.
|
|
57
|
+
f.write(f"<td>{html_mod.escape(str(v)) if v is not None else ''}</td>")
|
|
55
58
|
f.write("</tr>\n")
|
|
56
59
|
f.write("</tbody>\n</table>\n")
|
|
57
60
|
|
|
@@ -191,7 +194,10 @@ def export_cgi_html(
|
|
|
191
194
|
for r in rows:
|
|
192
195
|
f.write("<tr>")
|
|
193
196
|
for v in r:
|
|
194
|
-
|
|
197
|
+
# `if v is not None`, not `if v`: 0, False, and an empty
|
|
198
|
+
# string are all falsy, and a zero measurement silently became
|
|
199
|
+
# an empty cell.
|
|
200
|
+
f.write(f"<td>{html_mod.escape(str(v)) if v is not None else ''}</td>")
|
|
195
201
|
f.write("</tr>\n")
|
|
196
202
|
f.write("</tbody>\n</table>\n")
|
|
197
203
|
|
execsql/exporters/latex.py
CHANGED
|
@@ -20,6 +20,39 @@ import execsql.state as _state
|
|
|
20
20
|
__all__ = ["export_latex", "write_query_to_latex"]
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
#: Every character LaTeX treats specially, with its text-mode replacement.
|
|
24
|
+
#: ``&`` matters most here: it is the column separator this exporter writes, so
|
|
25
|
+
#: an ampersand in a value silently added a column and the .tex file no longer
|
|
26
|
+
#: compiled. ``%`` is nearly as bad — it comments out the rest of the line.
|
|
27
|
+
#: str.translate applies these simultaneously, so the backslash replacement
|
|
28
|
+
#: cannot be re-escaped by a later rule.
|
|
29
|
+
_LATEX_ESCAPE = str.maketrans(
|
|
30
|
+
{
|
|
31
|
+
"\\": r"\textbackslash{}",
|
|
32
|
+
"&": r"\&",
|
|
33
|
+
"%": r"\%",
|
|
34
|
+
"$": r"\$",
|
|
35
|
+
"#": r"\#",
|
|
36
|
+
"_": r"\_",
|
|
37
|
+
"{": r"\{",
|
|
38
|
+
"}": r"\}",
|
|
39
|
+
"~": r"\textasciitilde{}",
|
|
40
|
+
"^": r"\textasciicircum{}",
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _latex_cell(value: Any) -> str:
|
|
46
|
+
"""Render a value as LaTeX table-cell text.
|
|
47
|
+
|
|
48
|
+
``None`` becomes an empty cell rather than the literal string "None",
|
|
49
|
+
matching every other exporter.
|
|
50
|
+
"""
|
|
51
|
+
if value is None:
|
|
52
|
+
return ""
|
|
53
|
+
return str(value).translate(_LATEX_ESCAPE)
|
|
54
|
+
|
|
55
|
+
|
|
23
56
|
def export_latex(
|
|
24
57
|
outfile: str,
|
|
25
58
|
hdrs: list[str],
|
|
@@ -39,10 +72,10 @@ def export_latex(
|
|
|
39
72
|
f.write(f" \\caption{{{desc}}}\n")
|
|
40
73
|
f.write(f" \\begin{{tabular}} {{{' l' * len(hdrs)} }}\n")
|
|
41
74
|
f.write(" \\hline\n")
|
|
42
|
-
f.write(" " + " & ".join([h
|
|
75
|
+
f.write(" " + " & ".join([_latex_cell(h) for h in hdrs]) + " \\\\\n")
|
|
43
76
|
f.write(" \\hline\n")
|
|
44
77
|
for r in rows:
|
|
45
|
-
f.write(" " + " & ".join([
|
|
78
|
+
f.write(" " + " & ".join([_latex_cell(c) for c in r]) + " \\\\\n")
|
|
46
79
|
f.write(" \\hline\n")
|
|
47
80
|
f.write(" \\end{tabular}\n")
|
|
48
81
|
f.write(" \\end{table}\n")
|
execsql/exporters/markdown.py
CHANGED
|
@@ -35,15 +35,21 @@ def _cell(value: Any) -> str:
|
|
|
35
35
|
|
|
36
36
|
Args:
|
|
37
37
|
value: The cell value from the result set. ``None`` is rendered as
|
|
38
|
-
an empty string. Pipe characters are escaped
|
|
39
|
-
|
|
38
|
+
an empty string. Pipe characters are escaped, and newlines
|
|
39
|
+
become ``<br>``, so neither breaks the table structure.
|
|
40
40
|
|
|
41
41
|
Returns:
|
|
42
42
|
A string safe to embed between pipe characters in a GFM table row.
|
|
43
43
|
"""
|
|
44
44
|
if value is None:
|
|
45
45
|
return ""
|
|
46
|
-
|
|
46
|
+
text = str(value).translate(_PIPE_ESCAPE)
|
|
47
|
+
if "\n" in text or "\r" in text:
|
|
48
|
+
# A GFM table row is one line: a literal newline ends the row mid-cell
|
|
49
|
+
# and leaves the rest of the values outside the table entirely. <br> is
|
|
50
|
+
# how GFM expresses a line break inside a cell.
|
|
51
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>")
|
|
52
|
+
return text
|
|
47
53
|
|
|
48
54
|
|
|
49
55
|
def write_query_to_markdown(
|
execsql/exporters/ods.py
CHANGED
|
@@ -10,6 +10,7 @@ Provides :func:`write_query_to_ods` (single-sheet export),
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import datetime
|
|
13
|
+
import decimal
|
|
13
14
|
import getpass
|
|
14
15
|
import os
|
|
15
16
|
from pathlib import Path
|
|
@@ -239,7 +240,12 @@ class OdsFile:
|
|
|
239
240
|
if isinstance(item, bool):
|
|
240
241
|
# Booleans must be evaluated before numbers.
|
|
241
242
|
tc = of.table.TableCell(valuetype="boolean", value=1 if item else 0, stylename=style_name)
|
|
242
|
-
elif isinstance(item, float | int):
|
|
243
|
+
elif isinstance(item, float | int | decimal.Decimal):
|
|
244
|
+
# Decimal belongs here: PostgreSQL, MySQL and DuckDB all return it
|
|
245
|
+
# for numeric/decimal columns. Without it the value fell to the
|
|
246
|
+
# untyped branch below and the cell was written with an
|
|
247
|
+
# office:value but no office:value-type, which is not valid ODF —
|
|
248
|
+
# a reader is free to treat the number as text or ignore it.
|
|
243
249
|
tc = of.table.TableCell(valuetype="float", value=item, stylename=style_name)
|
|
244
250
|
elif isinstance(item, datetime.datetime):
|
|
245
251
|
self.define_iso_datetime_style()
|
|
@@ -266,12 +272,24 @@ class OdsFile:
|
|
|
266
272
|
tc = of.table.TableCell(timevalue=timeval.strftime("PT%HH%MM%S.%fS"), stylename="iso_datetime")
|
|
267
273
|
tc.addElement(of.text.P(text=timeval.strftime("%H:%M:%S.%f")))
|
|
268
274
|
elif isinstance(item, str):
|
|
269
|
-
|
|
270
|
-
|
|
275
|
+
# An XML parser normalises a newline inside an attribute to a
|
|
276
|
+
# space, so office:string-value cannot carry one. ODF's answer
|
|
277
|
+
# is one <text:p> per line, which is what LibreOffice writes;
|
|
278
|
+
# the attribute is set only for single-line values.
|
|
279
|
+
if "\n" in item or "\r" in item:
|
|
280
|
+
tc = of.table.TableCell(valuetype="string", stylename=style_name)
|
|
281
|
+
else:
|
|
282
|
+
tc = of.table.TableCell(valuetype="string", stringvalue=item, stylename=style_name)
|
|
271
283
|
else:
|
|
272
284
|
tc = of.table.TableCell(value=item, stylename=style_name)
|
|
273
285
|
if item is not None:
|
|
274
|
-
|
|
286
|
+
if isinstance(item, str) and ("\n" in item or "\r" in item):
|
|
287
|
+
# Previously these were flattened to spaces, silently changing
|
|
288
|
+
# the exported value.
|
|
289
|
+
for line in item.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
|
290
|
+
tc.addElement(of.text.P(text=line))
|
|
291
|
+
else:
|
|
292
|
+
tc.addElement(of.text.P(text=item))
|
|
275
293
|
tr.addElement(tc)
|
|
276
294
|
|
|
277
295
|
def add_sheet(self, of_table: Any) -> None:
|
execsql/exporters/pretty.py
CHANGED
|
@@ -36,6 +36,19 @@ def prettyprint_rowset(
|
|
|
36
36
|
if s is None:
|
|
37
37
|
return and_val
|
|
38
38
|
if isinstance(s, str):
|
|
39
|
+
# A newline or tab inside a value destroys a fixed-width table: the
|
|
40
|
+
# row is split across physical lines and every column after it is
|
|
41
|
+
# misaligned. This is a display format, so they are shown as their
|
|
42
|
+
# two-character escapes — the row stays one line, the columns line
|
|
43
|
+
# up, and the reader can still see that a break is there.
|
|
44
|
+
if "\n" in s or "\r" in s or "\t" in s:
|
|
45
|
+
return (
|
|
46
|
+
s.replace("\\", "\\\\")
|
|
47
|
+
.replace("\r\n", "\\n")
|
|
48
|
+
.replace("\r", "\\r")
|
|
49
|
+
.replace("\n", "\\n")
|
|
50
|
+
.replace("\t", "\\t")
|
|
51
|
+
)
|
|
39
52
|
return s
|
|
40
53
|
if type(s) in (type(memoryview(b"")), bytes, bytearray):
|
|
41
54
|
return f"Binary data ({len(s)} bytes)"
|
execsql/exporters/templates.py
CHANGED
|
@@ -105,7 +105,24 @@ class JinjaTemplateReport:
|
|
|
105
105
|
inf = EncodedFile(template_file, conf.script_encoding)
|
|
106
106
|
fh = inf.open("r")
|
|
107
107
|
try:
|
|
108
|
-
|
|
108
|
+
# Autoescape by template extension. The sandbox guards against a
|
|
109
|
+
# hostile *template*; nothing guarded against hostile *data*, so a
|
|
110
|
+
# value from the database rendered into an .html report as live
|
|
111
|
+
# markup — a `<script>` in a text column became a script tag.
|
|
112
|
+
# Keying on the extension leaves every non-markup template (csv,
|
|
113
|
+
# tex, txt, sql) byte-identical; only html/htm/xml change, and a
|
|
114
|
+
# template that genuinely wants raw markup asks for it with the
|
|
115
|
+
# standard `{{ value|safe }}`.
|
|
116
|
+
autoescape = self._jinja2.select_autoescape(
|
|
117
|
+
enabled_extensions=("html", "htm", "xml"),
|
|
118
|
+
default_for_string=False,
|
|
119
|
+
default=False,
|
|
120
|
+
)
|
|
121
|
+
env = SandboxedEnvironment(autoescape=autoescape)
|
|
122
|
+
# from_string() has no filename to inspect, so resolve the policy
|
|
123
|
+
# against the template's own path and pass the decision through.
|
|
124
|
+
env.autoescape = autoescape(template_file)
|
|
125
|
+
self.template = env.from_string(fh.read())
|
|
109
126
|
finally:
|
|
110
127
|
fh.close()
|
|
111
128
|
|
execsql/exporters/values.py
CHANGED
|
@@ -8,6 +8,7 @@ set as a series of SQL ``INSERT INTO … VALUES (…)`` statements, suitable
|
|
|
8
8
|
for loading data into a database from a plain SQL file.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import decimal
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
13
14
|
import execsql.state as _state
|
|
@@ -19,6 +20,29 @@ from execsql.utils.fileio import filewriter_close
|
|
|
19
20
|
__all__ = ["export_values", "write_query_to_values"]
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
def _sql_literal(value: Any) -> str:
|
|
24
|
+
"""Render a value as a SQL literal for an INSERT ... VALUES list.
|
|
25
|
+
|
|
26
|
+
Only numbers are emitted bare. Everything else is quoted, with embedded
|
|
27
|
+
single quotes doubled — the SQL standard escape.
|
|
28
|
+
|
|
29
|
+
Quoting is the safe default rather than the exception. Previously anything
|
|
30
|
+
that was not a string went out through ``str()`` unquoted, so a date became
|
|
31
|
+
``2026-09-24``, which SQL reads as arithmetic: PostgreSQL inserted 1993 and
|
|
32
|
+
reported no error at all. A timestamp, carrying a space, was a syntax
|
|
33
|
+
error instead. Any type this function has not been taught about is safer
|
|
34
|
+
quoted than bare.
|
|
35
|
+
"""
|
|
36
|
+
if value is None:
|
|
37
|
+
return "NULL"
|
|
38
|
+
if isinstance(value, bool):
|
|
39
|
+
# Checked before int — bool is a subclass of it.
|
|
40
|
+
return "TRUE" if value else "FALSE"
|
|
41
|
+
if isinstance(value, int | float | decimal.Decimal):
|
|
42
|
+
return str(value)
|
|
43
|
+
return "'" + str(value).replace("'", "''") + "'"
|
|
44
|
+
|
|
45
|
+
|
|
22
46
|
def export_values(
|
|
23
47
|
outfile: str,
|
|
24
48
|
hdrs: list[str],
|
|
@@ -54,10 +78,7 @@ def export_values(
|
|
|
54
78
|
firstrow = False
|
|
55
79
|
else:
|
|
56
80
|
f.write(",\n")
|
|
57
|
-
quoted_row = [
|
|
58
|
-
f"'{v.replace(chr(39), chr(39) * 2)}'" if isinstance(v, str) else str(v) if v is not None else "NULL"
|
|
59
|
-
for v in r
|
|
60
|
-
]
|
|
81
|
+
quoted_row = [_sql_literal(v) for v in r]
|
|
61
82
|
f.write(f" ({', '.join(quoted_row)})")
|
|
62
83
|
f.write("\n ;\n")
|
|
63
84
|
finally:
|