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.
Files changed (47) hide show
  1. execsql/api.py +71 -0
  2. execsql/config.py +2 -0
  3. execsql/data/execsql.conf.template +5 -0
  4. execsql/db/access.py +34 -10
  5. execsql/db/base.py +120 -2
  6. execsql/db/dsn.py +14 -7
  7. execsql/db/duckdb.py +102 -10
  8. execsql/db/firebird.py +12 -7
  9. execsql/db/mysql.py +55 -10
  10. execsql/db/oracle.py +24 -11
  11. execsql/db/postgres.py +17 -9
  12. execsql/db/sqlite.py +23 -8
  13. execsql/db/sqlserver.py +7 -7
  14. execsql/db/tiers.py +79 -0
  15. execsql/exporters/delimited.py +15 -1
  16. execsql/exporters/html.py +8 -2
  17. execsql/exporters/latex.py +35 -2
  18. execsql/exporters/markdown.py +9 -3
  19. execsql/exporters/ods.py +22 -4
  20. execsql/exporters/pretty.py +13 -0
  21. execsql/exporters/templates.py +18 -1
  22. execsql/exporters/values.py +25 -4
  23. execsql/exporters/xlsx.py +7 -1
  24. execsql/state.py +5 -0
  25. execsql/state.pyi +3 -1
  26. execsql/types.py +4 -1
  27. execsql/utils/fileio.py +47 -2
  28. {execsql2-2.22.9.dist-info → execsql2-2.23.1.dist-info}/METADATA +22 -13
  29. {execsql2-2.22.9.dist-info → execsql2-2.23.1.dist-info}/RECORD +47 -46
  30. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/README.md +0 -0
  31. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  32. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  33. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  34. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/md_compare.sql +0 -0
  35. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
  36. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
  37. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
  38. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  39. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  40. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/script_template.sql +0 -0
  41. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
  42. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  43. {execsql2-2.22.9.data → execsql2-2.23.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  44. {execsql2-2.22.9.dist-info → execsql2-2.23.1.dist-info}/WHEEL +0 -0
  45. {execsql2-2.22.9.dist-info → execsql2-2.23.1.dist-info}/entry_points.txt +0 -0
  46. {execsql2-2.22.9.dist-info → execsql2-2.23.1.dist-info}/licenses/LICENSE.txt +0 -0
  47. {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 = "latin1",
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
- self.port = port if port else 3306
62
- self.encoding = encoding or "latin1"
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
- import_sql = f"{import_sql} ({input_col_list});"
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
- else:
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
- encodings_match(input_enc, self.encoding)
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)
@@ -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
- ofile = EncodedFile(outfile, file_encoding).open(mode=fmode)
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
- f.write(f"<td>{html_mod.escape(str(v)) if v else ''}</td>")
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
- f.write(f"<td>{html_mod.escape(str(v)) if v else ''}</td>")
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
 
@@ -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.replace("_", r"\_") for h in hdrs]) + " \\\\\n")
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([str(c).replace("_", r"\_") for c in r]) + " \\\\\n")
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")
@@ -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 so they do not
39
- break the table structure.
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
- return str(value).translate(_PIPE_ESCAPE)
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
- item = item.replace("\n", " ").replace("\r", " ")
270
- tc = of.table.TableCell(valuetype="string", stringvalue=item, stylename=style_name)
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
- tc.addElement(of.text.P(text=item))
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:
@@ -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)"
@@ -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
- self.template = SandboxedEnvironment().from_string(fh.read())
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
 
@@ -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: