execsql2 2.22.9__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.
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 +12 -4
  5. execsql/db/base.py +15 -1
  6. execsql/db/dsn.py +12 -7
  7. execsql/db/duckdb.py +7 -8
  8. execsql/db/firebird.py +12 -7
  9. execsql/db/mysql.py +44 -10
  10. execsql/db/oracle.py +12 -7
  11. execsql/db/postgres.py +17 -9
  12. execsql/db/sqlite.py +7 -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.0.dist-info}/METADATA +22 -13
  29. {execsql2-2.22.9.dist-info → execsql2-2.23.0.dist-info}/RECORD +47 -46
  30. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/README.md +0 -0
  31. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  32. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  33. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  34. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  35. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  36. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  37. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  38. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  39. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  40. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/script_template.sql +0 -0
  41. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  42. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  43. {execsql2-2.22.9.data → execsql2-2.23.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  44. {execsql2-2.22.9.dist-info → execsql2-2.23.0.dist-info}/WHEEL +0 -0
  45. {execsql2-2.22.9.dist-info → execsql2-2.23.0.dist-info}/entry_points.txt +0 -0
  46. {execsql2-2.22.9.dist-info → execsql2-2.23.0.dist-info}/licenses/LICENSE.txt +0 -0
  47. {execsql2-2.22.9.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
 
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 = "latin1",
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
- self.port = port if port else 3306
62
- self.encoding = encoding or "latin1"
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
- import_sql = f"{import_sql} ({input_col_list});"
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
- 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
@@ -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)
@@ -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)):