execsql2 2.27.0__py3-none-any.whl → 2.29.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 (48) hide show
  1. execsql/cli/commands/format.py +2 -1
  2. execsql/cli/commands/lsp.py +33 -9
  3. execsql/cli/lint.py +45 -22
  4. execsql/db/access.py +4 -4
  5. execsql/db/base.py +23 -12
  6. execsql/db/dsn.py +2 -2
  7. execsql/db/duckdb.py +14 -2
  8. execsql/db/firebird.py +2 -3
  9. execsql/db/mysql.py +21 -5
  10. execsql/db/oracle.py +5 -5
  11. execsql/db/postgres.py +3 -4
  12. execsql/db/sqlserver.py +2 -2
  13. execsql/exporters/duckdb.py +2 -2
  14. execsql/exporters/ods.py +2 -2
  15. execsql/exporters/templates.py +2 -2
  16. execsql/exporters/xls.py +6 -5
  17. execsql/exporters/xlsx.py +2 -2
  18. execsql/exporters/yaml.py +3 -4
  19. execsql/format.py +71 -21
  20. execsql/importers/base.py +6 -10
  21. execsql/lsp/document.py +7 -3
  22. execsql/metacommands/debug.py +2 -2
  23. execsql/metacommands/io_fileops.py +6 -6
  24. execsql/metacommands/upsert.py +2 -5
  25. execsql/models.py +11 -0
  26. execsql/script/variables.py +42 -10
  27. execsql/types.py +8 -0
  28. execsql/utils/errors.py +16 -0
  29. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/METADATA +4 -2
  30. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/RECORD +48 -48
  31. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/README.md +0 -0
  32. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  33. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  34. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  35. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  36. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  37. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  38. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  39. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  40. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  41. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/script_template.sql +0 -0
  42. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  43. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  44. {execsql2-2.27.0.data → execsql2-2.29.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  45. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/WHEEL +0 -0
  46. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/entry_points.txt +0 -0
  47. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/licenses/LICENSE.txt +0 -0
  48. {execsql2-2.27.0.dist-info → execsql2-2.29.0.dist-info}/licenses/NOTICE +0 -0
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  import typer
8
+ from rich.markup import escape
8
9
 
9
10
  from execsql.cli.application import ExecsqlCommand, app
10
11
  from execsql.cli.help import _err_console
@@ -90,7 +91,7 @@ def format_cmd(
90
91
  try:
91
92
  _require_sqlglot()
92
93
  except ImportError as exc:
93
- _err_console.print(f"[bold red]Error:[/bold red] {exc}", highlight=False)
94
+ _err_console.print(f"[bold red]Error:[/bold red] {escape(str(exc))}", highlight=False)
94
95
  raise typer.Exit(code=1) from exc
95
96
  raise typer.Exit(
96
97
  code=run_formatter(
@@ -3,13 +3,31 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import typer
6
+ from rich.markup import escape
6
7
 
7
8
  from execsql.cli.application import ExecsqlCommand, app
8
9
  from execsql.cli.help import _err_console
10
+ from execsql.utils.errors import install_hint
9
11
 
10
12
  __all__ = ["lsp_cmd"]
11
13
 
12
14
 
15
+ def _lsp_libraries_installed() -> bool:
16
+ """Whether pygls and lsprotocol are installed (whether or not this execsql can use them)."""
17
+ from importlib.util import find_spec
18
+
19
+ return all(find_spec(name) is not None for name in ("pygls", "lsprotocol"))
20
+
21
+
22
+ def _pygls_version() -> str:
23
+ from importlib.metadata import PackageNotFoundError, version
24
+
25
+ try:
26
+ return version("pygls")
27
+ except PackageNotFoundError:
28
+ return "unknown"
29
+
30
+
13
31
  @app.command(
14
32
  cls=ExecsqlCommand,
15
33
  name="lsp",
@@ -17,24 +35,30 @@ __all__ = ["lsp_cmd"]
17
35
  "Run the execsql language server on stdin/stdout, for an editor to start. "
18
36
  "Shows execsql lint findings as you type, completes metacommands, "
19
37
  "conditional tests and variables, explains them on hover, and finds where variables, "
20
- "SCRIPTs and included files are defined, offers quick fixes, and formats documents. Needs the lsp extra "
21
- "(installed with any extras you already have, e.g. execsql2[postgres,lsp])."
38
+ "SCRIPTs and included files are defined, offers quick fixes, and formats documents. Requires the lsp "
39
+ "extra; --check reports whether it is installed."
22
40
  ),
23
41
  )
24
42
  def lsp_cmd(
25
43
  # Editors' language clients pass --stdio (VS Code always does); stdio is the only transport.
26
44
  stdio: bool = typer.Option(False, "--stdio", hidden=True),
45
+ check: bool = typer.Option(False, "--check", help="Report whether the language server can start, and exit."),
27
46
  ) -> None:
28
47
  """The ``lsp`` command; its user-facing help is the decorator's ``help``."""
29
48
  try:
30
49
  from execsql.lsp.server import create_server
31
50
  except ImportError as exc:
32
- _err_console.print(
33
- "[bold red]Error:[/bold red] execsql lsp needs the lsp extra. Install it with the "
34
- 'extras you already have, e.g. uv tool install "execsql2\\[postgres,lsp]" or '
35
- 'pipx install --force "execsql2\\[postgres,lsp]". '
36
- "See https://execsql2.readthedocs.io/en/latest/getting-started/installation/#adding-an-extra-later",
37
- highlight=False,
38
- )
51
+ if _lsp_libraries_installed():
52
+ # pygls is there but this execsql cannot use it (another major version, a broken install).
53
+ message = (
54
+ f"execsql lsp cannot use the installed pygls {_pygls_version()} ({exc}). "
55
+ f"It needs pygls 2.x, which the lsp extra installs. {install_hint('lsp')}"
56
+ )
57
+ else:
58
+ message = f"execsql lsp needs the lsp extra. {install_hint('lsp')}"
59
+ _err_console.print(f"[bold red]Error:[/bold red] {escape(message)}", highlight=False)
39
60
  raise typer.Exit(code=1) from exc
61
+ if check:
62
+ typer.echo(f"execsql lsp is ready (pygls {_pygls_version()}).")
63
+ return
40
64
  create_server().start_io()
execsql/cli/lint.py CHANGED
@@ -48,6 +48,7 @@ Public surface:
48
48
 
49
49
  from __future__ import annotations
50
50
 
51
+ import functools
51
52
  import json
52
53
  import re
53
54
  from collections import Counter
@@ -460,23 +461,40 @@ def _read_ini_vars(
460
461
  language server; a file that is unreadable or not valid INI defines
461
462
  nothing.
462
463
  """
463
- from configparser import ConfigParser
464
- from configparser import Error as ConfigError
465
-
466
464
  p = Path(ini_file)
467
465
  if not p.is_absolute() and script_dir is not None:
468
466
  p = script_dir / p
467
+ try:
468
+ if not p.is_file():
469
+ return
470
+ stat = p.stat()
471
+ if stat.st_size > _MAX_INI_BYTES:
472
+ return
473
+ resolved = str(p.resolve())
474
+ except OSError:
475
+ return
476
+ defined_vars.update(_ini_section_keys(resolved, stat.st_size, stat.st_mtime_ns, section))
477
+
478
+
479
+ @functools.lru_cache(maxsize=64)
480
+ def _ini_section_keys(path: str, size: int, mtime_ns: int, section: str) -> frozenset[str]:
481
+ """The upper-cased keys of *section* in the INI file at *path*.
482
+
483
+ Cached by the file's size and modification time: a script that names
484
+ the same file on many SUB_INI lines, and the language server re-linting
485
+ on every edit, read it once.
486
+ """
487
+ from configparser import ConfigParser
488
+ from configparser import Error as ConfigError
469
489
 
470
490
  cp = ConfigParser()
471
491
  try:
472
- if not p.is_file() or p.stat().st_size > _MAX_INI_BYTES:
473
- return
474
- cp.read_string(p.read_text(encoding="utf-8"), source=str(p))
492
+ cp.read_string(Path(path).read_text(encoding="utf-8"), source=path)
475
493
  except (OSError, UnicodeDecodeError, ConfigError):
476
- return
477
- if cp.has_section(section):
478
- for key, _value in cp.items(section):
479
- defined_vars.add(key.upper())
494
+ return frozenset()
495
+ if not cp.has_section(section):
496
+ return frozenset()
497
+ return frozenset(key.upper() for key, _value in cp.items(section))
480
498
 
481
499
 
482
500
  def _is_known_var(raw_name: str, defined_vars: set[str]) -> bool:
@@ -834,7 +852,12 @@ def _referencing_text(node: Node) -> list[str]:
834
852
  return texts
835
853
 
836
854
 
837
- def _check_unused_variables(script: Script, defined: set[str], issues: list[_Issue]) -> None:
855
+ def _check_unused_variables(
856
+ script: Script,
857
+ defined: set[str],
858
+ issues: list[_Issue],
859
+ script_dir: Path | None = None,
860
+ ) -> None:
838
861
  """Report variables a SUB defines that nothing ever reads.
839
862
 
840
863
  Almost always a typo in either the definition or the reference — the two
@@ -845,8 +868,11 @@ def _check_unused_variables(script: Script, defined: set[str], issues: list[_Iss
845
868
  referenced: set[str] = set()
846
869
  _collect_var_references(script.body, referenced)
847
870
  unused = sorted(defined - referenced)
871
+ lines: dict[str, int] = {}
872
+ if unused:
873
+ _definition_lines(script.body, script_dir, lines)
848
874
  for name in unused:
849
- line = _definition_line(script.body, name)
875
+ line = lines.get(name, 0)
850
876
  issues.append(
851
877
  _issue(
852
878
  "V002",
@@ -857,18 +883,15 @@ def _check_unused_variables(script: Script, defined: set[str], issues: list[_Iss
857
883
  )
858
884
 
859
885
 
860
- def _definition_line(nodes: list[Node], name: str) -> int:
861
- """Line of the SUB that defines *name*, or 0 when it cannot be located."""
886
+ def _definition_lines(nodes: list[Node], script_dir: Path | None, lines: dict[str, int]) -> None:
887
+ """Add to *lines* the line of the first metacommand that defines each variable, in script order."""
862
888
  for node in nodes:
863
889
  if isinstance(node, MetaCommandStatement):
864
890
  probe: set[str] = set()
865
- _extract_var_definition(node.command, None, probe)
866
- if name in probe:
867
- return node.span.start_line
868
- found = _definition_line(list(node.children()), name)
869
- if found:
870
- return found
871
- return 0
891
+ _extract_var_definition(node.command, script_dir, probe)
892
+ for name in probe:
893
+ lines.setdefault(name, node.span.start_line)
894
+ _definition_lines(list(node.children()), script_dir, lines)
872
895
 
873
896
 
874
897
  # ---------------------------------------------------------------------------
@@ -1008,7 +1031,7 @@ def lint(
1008
1031
  )
1009
1032
 
1010
1033
  # Pass 3: whole-script rules, which need every reference collected first
1011
- _check_unused_variables(script, all_defined, issues)
1034
+ _check_unused_variables(script, all_defined, issues, script_dir)
1012
1035
 
1013
1036
  return issues
1014
1037
 
execsql/db/access.py CHANGED
@@ -17,7 +17,7 @@ from typing import Any, cast
17
17
  from execsql.db.base import Database, _fetched
18
18
  from execsql.db.tiers import SupportTier
19
19
  from execsql.exceptions import ErrInfo
20
- from execsql.utils.errors import exception_desc, fatal_error
20
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
21
21
  from execsql.utils.auth import (
22
22
  begin_login,
23
23
  clear_stored_password,
@@ -68,7 +68,7 @@ class AccessDatabase(Database):
68
68
  try:
69
69
  import pyodbc # noqa: F401
70
70
  except Exception:
71
- fatal_error("The pyodbc module is required. See http://github.com/mkleehammer/pyodbc")
71
+ fatal_error(f"The pyodbc module is required to connect to Access. {install_hint('odbc')}")
72
72
  from execsql.types import dbt_access
73
73
 
74
74
  super().__init__(
@@ -327,8 +327,8 @@ class AccessDatabase(Database):
327
327
  rows = curs.fetchall()
328
328
  return [d[0] for d in curs.description], rows
329
329
 
330
- def select_rowsource(self, sql: str) -> tuple[list[str], Any]:
331
- """Return column names and an iterable that yields rows one at a time."""
330
+ def select_rowsource(self, sql: str, *, decode_bytes: bool = True) -> tuple[list[str], Any]: # noqa: ARG002
331
+ """Return column names and an iterable that yields rows one at a time (values as the driver returns them)."""
332
332
  # Return 1) a list of column names, and 2) an iterable that yields rows.
333
333
  self.dao_flush_check()
334
334
  curs = self.cursor()
execsql/db/base.py CHANGED
@@ -215,21 +215,18 @@ class Database(ABC):
215
215
  def quote_literal(self, value: Any) -> str:
216
216
  """Return *value* as a SQL string literal, safely escaped.
217
217
 
218
- Default ANSI/Postgres behaviour: wrap in single quotes, double
219
- embedded apostrophes, escape backslashes (so a value containing
220
- ``\\'`` cannot terminate the literal on MySQL default mode or
221
- PostgreSQL E-strings), and reject embedded NUL bytes (most
222
- wire protocols truncate or reject them).
223
-
224
- Override per-DBMS only when the wire protocol requires a different
225
- literal form.
218
+ Standard SQL: wrap in single quotes and double embedded apostrophes;
219
+ a backslash is an ordinary character. Embedded NUL bytes are
220
+ rejected (most wire protocols truncate or reject them). A DBMS
221
+ that reads a backslash in a literal as an escape (MySQL) overrides
222
+ this.
226
223
  """
227
224
  if value is None:
228
225
  return "NULL"
229
226
  s = str(value)
230
227
  if "\x00" in s:
231
228
  raise ValueError("SQL literal contains a NUL byte; refusing to quote.")
232
- return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"
229
+ return "'" + s.replace("'", "''") + "'"
233
230
 
234
231
  def paramsubs(self, paramcount: int) -> str:
235
232
  """Return a comma-separated string of *paramcount* parameter placeholders."""
@@ -405,8 +402,13 @@ class Database(ABC):
405
402
  rows = curs.fetchall()
406
403
  return [d[0] for d in curs.description], rows
407
404
 
408
- def select_rowsource(self, sql: str) -> tuple[list[str], Generator]:
409
- """Execute *sql* and return ``(column_names, row_generator)`` for streaming large result sets."""
405
+ def select_rowsource(self, sql: str, *, decode_bytes: bool = True) -> tuple[list[str], Generator]:
406
+ """Execute *sql* and return ``(column_names, row_generator)`` for streaming large result sets.
407
+
408
+ A binary value (``bytes``) is decoded to text with the connection's
409
+ encoding, as the exporters expect, unless *decode_bytes* is false:
410
+ COPY passes false so binary columns reach the target unchanged.
411
+ """
410
412
  # Return 1) a list of column names, and 2) an iterable that yields rows.
411
413
  curs = self.cursor()
412
414
  try:
@@ -433,7 +435,7 @@ class Database(ABC):
433
435
  break
434
436
  else:
435
437
  for row in rows:
436
- if self.encoding:
438
+ if self.encoding and decode_bytes:
437
439
  yield [
438
440
  c.decode(self.encoding, "backslashreplace") if isinstance(c, bytes) else c
439
441
  for c in row
@@ -501,6 +503,15 @@ class Database(ABC):
501
503
  """The schema (MySQL: database) an unqualified table name is created in, or ``None`` if unknown."""
502
504
  return None
503
505
 
506
+ def table_exists_where_created(self, table_name: str, schema_name: str | None = None) -> bool:
507
+ """Whether *table_name* exists where a ``CREATE TABLE`` on this connection would put it.
508
+
509
+ The same as :meth:`table_exists`, except on a connection that sees
510
+ tables of other databases (DuckDB ``ATTACH``): only the current
511
+ database counts there.
512
+ """
513
+ return self.table_exists(table_name, schema_name)
514
+
504
515
  def _query_current_schema(self, sql: str) -> str | None:
505
516
  with self._cursor() as curs:
506
517
  curs.execute(sql)
execsql/db/dsn.py CHANGED
@@ -12,7 +12,7 @@ the CLI.
12
12
  from execsql.db.base import Database
13
13
  from execsql.db.tiers import SupportTier
14
14
  from execsql.exceptions import ErrInfo
15
- from execsql.utils.errors import exception_desc, fatal_error
15
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
16
16
  from execsql.utils.auth import (
17
17
  begin_login,
18
18
  clear_stored_password,
@@ -51,7 +51,7 @@ class DsnDatabase(Database):
51
51
  try:
52
52
  import pyodbc # noqa: F401
53
53
  except Exception:
54
- fatal_error("The pyodbc module is required. See http://github.com/mkleehammer/pyodbc")
54
+ fatal_error(f"The pyodbc module is required to connect through an ODBC DSN. {install_hint('odbc')}")
55
55
  from execsql.types import dbt_dsn
56
56
 
57
57
  super().__init__(
execsql/db/duckdb.py CHANGED
@@ -14,7 +14,7 @@ from typing import Any
14
14
 
15
15
  from execsql.db.base import Database, statement_start
16
16
  from execsql.exceptions import ErrInfo
17
- from execsql.utils.errors import exception_desc, fatal_error
17
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
18
18
  import execsql.state as _state
19
19
 
20
20
  __all__ = ["DuckDBDatabase"]
@@ -62,7 +62,7 @@ class DuckDBDatabase(Database):
62
62
  try:
63
63
  import duckdb # noqa: F401
64
64
  except Exception:
65
- fatal_error("The duckdb module is required.")
65
+ fatal_error(f"The duckdb module is required to use DuckDB. {install_hint('duckdb')}")
66
66
  from execsql.types import dbt_duckdb
67
67
 
68
68
  super().__init__(
@@ -167,6 +167,18 @@ class DuckDBDatabase(Database):
167
167
  self.rollback()
168
168
  raise
169
169
 
170
+ def table_exists_where_created(self, table_name: str, schema_name: str | None = None) -> bool:
171
+ """Whether *table_name* exists in the current database (not an ``ATTACH``ed one)."""
172
+ # information_schema.tables lists the tables of every attached database.
173
+ sql = "select table_name from information_schema.tables where table_name = ? and table_catalog = current_database()"
174
+ params: list = [table_name]
175
+ if schema_name:
176
+ sql += " and table_schema = ?"
177
+ params.append(schema_name)
178
+ with self._cursor() as curs:
179
+ curs.execute(sql, params)
180
+ return len(curs.fetchall()) > 0
181
+
170
182
  def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
171
183
  """Return True if the named view exists in the DuckDB database."""
172
184
  # DuckDB information_schema has no 'views' table; views are listed in 'tables'
execsql/db/firebird.py CHANGED
@@ -11,7 +11,7 @@ via the ``firebird-driver`` package. Corresponds to ``-t f`` on the CLI.
11
11
  from execsql.db.base import Database
12
12
  from execsql.db.tiers import SupportTier
13
13
  from execsql.exceptions import ErrInfo
14
- from execsql.utils.errors import exception_desc, fatal_error
14
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
15
15
  from execsql.utils.auth import (
16
16
  begin_login,
17
17
  clear_stored_password,
@@ -46,8 +46,7 @@ class FirebirdDatabase(Database):
46
46
  from firebird import driver as firebird_lib # noqa: F401
47
47
  except Exception:
48
48
  fatal_error(
49
- "The firebird-driver module is required to connect to Firebird. "
50
- "See https://pypi.org/project/firebird-driver/",
49
+ f"The firebird-driver module is required to connect to Firebird. {install_hint('firebird')}",
51
50
  )
52
51
  from execsql.types import dbt_firebird
53
52
 
execsql/db/mysql.py CHANGED
@@ -12,7 +12,7 @@ from typing import Any, cast
12
12
 
13
13
  from execsql.db.base import Database
14
14
  from execsql.exceptions import ErrInfo
15
- from execsql.utils.errors import exception_desc, fatal_error
15
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
16
16
  from execsql.utils.auth import (
17
17
  begin_login,
18
18
  clear_stored_password,
@@ -67,6 +67,20 @@ class MySQLDatabase(Database):
67
67
  re.I,
68
68
  )
69
69
 
70
+ def quote_literal(self, value: Any) -> str:
71
+ """Return *value* as a string literal for MySQL/MariaDB.
72
+
73
+ Unless ``NO_BACKSLASH_ESCAPES`` is set, MySQL reads a backslash in a
74
+ literal as an escape, so ``\\'`` would end it: backslashes are doubled
75
+ as well as apostrophes.
76
+ """
77
+ if value is None:
78
+ return "NULL"
79
+ s = str(value)
80
+ if "\x00" in s:
81
+ raise ValueError("SQL literal contains a NUL byte; refusing to quote.")
82
+ return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"
83
+
70
84
  def __init__(
71
85
  self,
72
86
  server_name: str,
@@ -81,7 +95,7 @@ class MySQLDatabase(Database):
81
95
  import pymysql as mysql_lib # noqa: F401
82
96
  except Exception:
83
97
  fatal_error(
84
- "The pymysql module is required to connect to MySQL. See https://pypi.python.org/pypi/PyMySQL",
98
+ f"The pymysql module is required to connect to MySQL. {install_hint('mysql')}",
85
99
  )
86
100
  from execsql.types import dbt_mysql
87
101
 
@@ -307,10 +321,12 @@ class MySQLDatabase(Database):
307
321
  table_cols = self.table_columns(table_name, schema_name)
308
322
  data_table_cols = [d.lower() for d in table_cols]
309
323
  csv_file_cols = csv_file_obj.column_headers()
324
+ # MySQL column names are case-insensitive: compare without case, and
325
+ # send the file's own spelling.
310
326
  if _state.conf.import_common_cols_only:
311
- import_cols = [col for col in csv_file_cols if col in data_table_cols]
327
+ import_cols = [col for col in csv_file_cols if col.lower() in data_table_cols]
312
328
  else:
313
- unmatched_cols = list(set(csv_file_cols) - set(data_table_cols))
329
+ unmatched_cols = [col for col in csv_file_cols if col.lower() not in data_table_cols]
314
330
  if len(unmatched_cols) > 0:
315
331
  raise ErrInfo(
316
332
  type="error",
@@ -326,7 +342,7 @@ class MySQLDatabase(Database):
326
342
  # reads the format that was detected.
327
343
  if (
328
344
  csv_file_obj.escapechar is None
329
- and data_table_cols == csv_file_cols
345
+ and data_table_cols == [col.lower() for col in csv_file_cols]
330
346
  and _state.conf.empty_strings
331
347
  and _state.conf.empty_rows
332
348
  and not _state.conf.del_empty_cols
execsql/db/oracle.py CHANGED
@@ -14,7 +14,7 @@ from typing import Any
14
14
  from execsql.db.base import Database
15
15
  from execsql.db.tiers import SupportTier
16
16
  from execsql.exceptions import ErrInfo
17
- from execsql.utils.errors import exception_desc, fatal_error
17
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
18
18
  from execsql.utils.auth import (
19
19
  begin_login,
20
20
  clear_stored_password,
@@ -76,7 +76,7 @@ class OracleDatabase(Database):
76
76
  ) -> None:
77
77
  if _oracle_driver() is None:
78
78
  fatal_error(
79
- 'The oracledb module is required to connect to Oracle. Install it with: pip install "execsql2[oracle]"',
79
+ f"The oracledb module is required to connect to Oracle. {install_hint('oracle')}",
80
80
  )
81
81
  from execsql.types import dbt_oracle
82
82
 
@@ -164,12 +164,12 @@ class OracleDatabase(Database):
164
164
  else:
165
165
  return super().select_data(sql)
166
166
 
167
- def select_rowsource(self, sql: str) -> Any:
167
+ def select_rowsource(self, sql: str, *, decode_bytes: bool = True) -> Any:
168
168
  """Return column names and an iterable that yields rows one at a time."""
169
169
  if sql[-1:] == ";":
170
- return super().select_rowsource(sql[:-1])
170
+ return super().select_rowsource(sql[:-1], decode_bytes=decode_bytes)
171
171
  else:
172
- return super().select_rowsource(sql)
172
+ return super().select_rowsource(sql, decode_bytes=decode_bytes)
173
173
 
174
174
  def select_rowdict(self, sql: str) -> Any:
175
175
  """Return column names and an iterable that yields rows as dictionaries."""
execsql/db/postgres.py CHANGED
@@ -15,7 +15,7 @@ from typing import Any
15
15
 
16
16
  from execsql.db.base import Database
17
17
  from execsql.exceptions import ErrInfo
18
- from execsql.utils.errors import exception_desc, fatal_error
18
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
19
19
  from execsql.utils.auth import (
20
20
  begin_login,
21
21
  clear_stored_password,
@@ -51,8 +51,7 @@ class PostgresDatabase(Database):
51
51
  import psycopg # noqa: F401
52
52
  except Exception:
53
53
  fatal_error(
54
- "The psycopg module (psycopg3) is required to connect to PostgreSQL. "
55
- "See https://www.psycopg.org/psycopg3/",
54
+ f"The psycopg module (psycopg3) is required to connect to PostgreSQL. {install_hint('postgres')}",
56
55
  )
57
56
  from execsql.types import dbt_postgres
58
57
 
@@ -354,7 +353,7 @@ class PostgresDatabase(Database):
354
353
  from execsql.exporters.delimited import blank_lines_as_empty_rows
355
354
 
356
355
  # Use Postgres' COPY FROM method via psycopg3's cursor.copy() context manager.
357
- rf = csv_file_obj.open("rt")
356
+ rf = csv_file_obj.openclean("rt") # past the SKIP lines, as every other reader starts
358
357
  if skipheader:
359
358
  next(rf)
360
359
  # Copy_from() requires a delimiter, but if there is none, feed it an
execsql/db/sqlserver.py CHANGED
@@ -11,7 +11,7 @@ import re
11
11
 
12
12
  from execsql.db.base import Database
13
13
  from execsql.exceptions import ErrInfo
14
- from execsql.utils.errors import fatal_error
14
+ from execsql.utils.errors import fatal_error, install_hint
15
15
  from execsql.utils.auth import (
16
16
  begin_login,
17
17
  clear_stored_password,
@@ -41,7 +41,7 @@ class SqlServerDatabase(Database):
41
41
  try:
42
42
  import pyodbc # noqa: F401
43
43
  except Exception:
44
- fatal_error("The pyodbc module is required. See http://github.com/mkleehammer/pyodbc")
44
+ fatal_error(f"The pyodbc module is required to connect to SQL Server. {install_hint('mssql')}")
45
45
  from execsql.types import dbt_sqlserver
46
46
 
47
47
  super().__init__(
@@ -33,9 +33,9 @@ def export_duckdb(
33
33
  try:
34
34
  import duckdb
35
35
  except Exception:
36
- from execsql.utils.errors import fatal_error
36
+ from execsql.utils.errors import fatal_error, install_hint
37
37
 
38
- fatal_error("The duckdb module is required to export data in that format.")
38
+ fatal_error(f"The duckdb module is required to export data in that format. {install_hint('duckdb')}")
39
39
  return
40
40
 
41
41
  from execsql.models import DataTable
execsql/exporters/ods.py CHANGED
@@ -20,7 +20,7 @@ import execsql.state as _state
20
20
  from execsql.exceptions import OdsFileError
21
21
  from execsql.exceptions import ErrInfo
22
22
  from execsql.script import current_script_line
23
- from execsql.utils.errors import exception_desc, fatal_error
23
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
24
24
  from execsql.utils.fileio import filewriter_close
25
25
  from execsql.utils.strings import unquoted
26
26
 
@@ -57,7 +57,7 @@ class OdsFile:
57
57
  import odf.number # noqa: F401
58
58
  import odf.style # noqa: F401
59
59
  except ImportError:
60
- fatal_error("The odfpy library is needed to create OpenDocument spreadsheets.")
60
+ fatal_error(f"The odfpy library is needed to create OpenDocument spreadsheets. {install_hint('formats')}")
61
61
  self.filename: str | None = None
62
62
  self.wbk: Any = None
63
63
  self.cell_style_names: list[str] = []
@@ -14,7 +14,7 @@ from typing import Any
14
14
 
15
15
  import execsql.state as _state
16
16
  from execsql.exceptions import ErrInfo
17
- from execsql.utils.errors import fatal_error
17
+ from execsql.utils.errors import fatal_error, install_hint
18
18
  from execsql.utils.fileio import filewriter_close
19
19
 
20
20
  __all__ = ["StrTemplateReport", "JinjaTemplateReport", "report_query"]
@@ -91,7 +91,7 @@ class JinjaTemplateReport:
91
91
  self._jinja2 = jinja2
92
92
  except ImportError:
93
93
  fatal_error(
94
- "The jinja2 library is required to produce reports with the Jinja2 templating system. See http://jinja.pocoo.org/",
94
+ f"The jinja2 library is required to produce reports with the Jinja2 templating system. {install_hint('formats')}",
95
95
  )
96
96
  conf = _state.conf
97
97
  template_root = getattr(conf, "template_root", None)
execsql/exporters/xls.py CHANGED
@@ -22,7 +22,7 @@ from pathlib import Path
22
22
  from typing import Any, cast
23
23
 
24
24
  from execsql.exceptions import XlsFileError, XlsxFileError
25
- from execsql.utils.errors import fatal_error
25
+ from execsql.utils.errors import fatal_error, install_hint
26
26
 
27
27
  __all__ = ["XlsFile", "XlsxFile"]
28
28
 
@@ -51,7 +51,7 @@ class XlsFile:
51
51
 
52
52
  self._xlrd = xlrd
53
53
  except ImportError:
54
- fatal_error("The xlrd library is needed to read Excel (.xls) spreadsheets.")
54
+ fatal_error(f"The xlrd library is needed to read Excel (.xls) spreadsheets. {install_hint('formats')}")
55
55
  self.filename: str | None = None
56
56
  self.encoding: str | None = None
57
57
  self.wbk: Any = None
@@ -158,7 +158,7 @@ class XlsFile:
158
158
  break
159
159
  ncols += 1
160
160
  sheet_data = []
161
- for r in range(junk_header_rows, sheet.nrows - junk_header_rows):
161
+ for r in range(junk_header_rows, sheet.nrows):
162
162
  datarow = row_data(r, ncols)
163
163
  if datarow.count(None) == len(datarow):
164
164
  break
@@ -190,7 +190,7 @@ class XlsxFile:
190
190
 
191
191
  self._openpyxl = openpyxl
192
192
  except ImportError:
193
- fatal_error("The openpyxl library is needed to read Excel (.xlsx) spreadsheets.")
193
+ fatal_error(f"The openpyxl library is needed to read Excel (.xlsx) spreadsheets. {install_hint('formats')}")
194
194
  self.filename: str | None = None
195
195
  self.encoding: str | None = None
196
196
  self.wbk: Any = None
@@ -270,7 +270,8 @@ class XlsxFile:
270
270
  sheet_data = []
271
271
  rowsrc = sheet.iter_rows(min_row=junk_header_rows + 1, values_only=True)
272
272
  for r in rowsrc:
273
- if not any(r):
273
+ # A row of zeros or FALSE values is data; only a row of empty cells ends it.
274
+ if all(c is None or c == "" for c in r):
274
275
  break
275
276
  sheet_data.append(list(r))
276
277
  for r in range(len(sheet_data)):
execsql/exporters/xlsx.py CHANGED
@@ -20,7 +20,7 @@ from execsql.exceptions import ErrInfo
20
20
  from execsql.exporters.base import ExportRecord
21
21
  from execsql.exporters.pretty import prettyprint_query
22
22
  from execsql.script import current_script_line
23
- from execsql.utils.errors import exception_desc, fatal_error
23
+ from execsql.utils.errors import exception_desc, fatal_error, install_hint
24
24
  from execsql.utils.fileio import filewriter_close
25
25
  from execsql.utils.strings import unquoted
26
26
 
@@ -39,7 +39,7 @@ def _require_openpyxl() -> Any:
39
39
  return openpyxl
40
40
  except ImportError:
41
41
  fatal_error(
42
- "The openpyxl library is needed to write Excel (.xlsx) spreadsheets (install the formats extra, execsql2[formats]).",
42
+ f"The openpyxl library is needed to write Excel (.xlsx) spreadsheets. {install_hint('formats')}",
43
43
  )
44
44
 
45
45