execsql2 2.0.1__py3-none-any.whl → 2.1.2__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 (90) hide show
  1. execsql/cli.py +322 -108
  2. execsql/config.py +134 -114
  3. execsql/db/access.py +89 -65
  4. execsql/db/base.py +97 -68
  5. execsql/db/dsn.py +45 -29
  6. execsql/db/duckdb.py +4 -5
  7. execsql/db/factory.py +27 -27
  8. execsql/db/firebird.py +30 -18
  9. execsql/db/mysql.py +38 -14
  10. execsql/db/oracle.py +58 -33
  11. execsql/db/postgres.py +68 -28
  12. execsql/db/sqlite.py +36 -27
  13. execsql/db/sqlserver.py +45 -30
  14. execsql/exceptions.py +68 -64
  15. execsql/exporters/__init__.py +1 -1
  16. execsql/exporters/base.py +42 -17
  17. execsql/exporters/delimited.py +60 -59
  18. execsql/exporters/duckdb.py +8 -12
  19. execsql/exporters/feather.py +32 -24
  20. execsql/exporters/html.py +33 -30
  21. execsql/exporters/json.py +18 -17
  22. execsql/exporters/latex.py +11 -13
  23. execsql/exporters/ods.py +50 -46
  24. execsql/exporters/parquet.py +32 -0
  25. execsql/exporters/pretty.py +16 -15
  26. execsql/exporters/raw.py +9 -11
  27. execsql/exporters/sqlite.py +38 -38
  28. execsql/exporters/templates.py +15 -72
  29. execsql/exporters/values.py +13 -12
  30. execsql/exporters/xls.py +26 -26
  31. execsql/exporters/xml.py +12 -12
  32. execsql/exporters/zip.py +0 -3
  33. execsql/gui/__init__.py +2 -2
  34. execsql/gui/console.py +0 -1
  35. execsql/gui/desktop.py +6 -7
  36. execsql/gui/tui.py +8 -14
  37. execsql/importers/base.py +6 -9
  38. execsql/importers/csv.py +10 -17
  39. execsql/importers/feather.py +16 -22
  40. execsql/importers/ods.py +3 -4
  41. execsql/importers/xls.py +5 -6
  42. execsql/metacommands/__init__.py +8 -8
  43. execsql/metacommands/conditions.py +41 -33
  44. execsql/metacommands/connect.py +113 -99
  45. execsql/metacommands/control.py +38 -26
  46. execsql/metacommands/data.py +35 -33
  47. execsql/metacommands/debug.py +13 -9
  48. execsql/metacommands/io.py +288 -229
  49. execsql/metacommands/prompt.py +179 -157
  50. execsql/metacommands/script_ext.py +11 -9
  51. execsql/metacommands/system.py +44 -25
  52. execsql/models.py +9 -16
  53. execsql/parser.py +10 -10
  54. execsql/script.py +183 -157
  55. execsql/state.py +170 -208
  56. execsql/types.py +46 -81
  57. execsql/utils/auth.py +114 -14
  58. execsql/utils/crypto.py +31 -4
  59. execsql/utils/datetime.py +7 -7
  60. execsql/utils/errors.py +34 -29
  61. execsql/utils/fileio.py +90 -55
  62. execsql/utils/gui.py +22 -23
  63. execsql/utils/mail.py +15 -17
  64. execsql/utils/numeric.py +2 -3
  65. execsql/utils/regex.py +9 -12
  66. execsql/utils/strings.py +10 -12
  67. execsql/utils/timer.py +0 -2
  68. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/execsql.conf +1 -1
  69. execsql2-2.1.2.dist-info/METADATA +300 -0
  70. execsql2-2.1.2.dist-info/RECORD +96 -0
  71. execsql2-2.0.1.dist-info/METADATA +0 -406
  72. execsql2-2.0.1.dist-info/RECORD +0 -95
  73. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/READ_ME.rst +0 -0
  74. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  75. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  76. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
  77. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/md_compare.sql +0 -0
  78. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
  79. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
  80. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
  81. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  82. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  83. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/script_template.sql +0 -0
  84. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
  85. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  86. {execsql2-2.0.1.data → execsql2-2.1.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  87. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/WHEEL +0 -0
  88. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/entry_points.txt +0 -0
  89. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/licenses/LICENSE.txt +0 -0
  90. {execsql2-2.0.1.dist-info → execsql2-2.1.2.dist-info}/licenses/NOTICE +0 -0
execsql/db/sqlite.py CHANGED
@@ -9,10 +9,9 @@ files via the Python standard library ``sqlite3`` module. Corresponds to
9
9
  """
10
10
 
11
11
  import datetime
12
- import io
13
12
  import re
14
13
  from decimal import Decimal
15
- from typing import Any, List, Optional
14
+ from typing import Any
16
15
 
17
16
  from execsql.db.base import Database
18
17
  from execsql.exceptions import ErrInfo
@@ -20,8 +19,11 @@ from execsql.utils.errors import exception_desc, fatal_error
20
19
  import execsql.state as _state
21
20
 
22
21
 
22
+ DEFAULT_CONNECT_TIMEOUT = 30 # seconds
23
+
24
+
23
25
  class SQLiteDatabase(Database):
24
- def __init__(self, SQLite_fn: str) -> None:
26
+ def __init__(self, SQLite_fn: str, timeout: float = DEFAULT_CONNECT_TIMEOUT) -> None:
25
27
  try:
26
28
  import sqlite3 # noqa: F401
27
29
  except Exception:
@@ -36,6 +38,7 @@ class SQLiteDatabase(Database):
36
38
  self.encoding = "UTF-8"
37
39
  self.encode_commands = False
38
40
  self.paramstr = "?"
41
+ self.timeout = timeout
39
42
  self.conn = None
40
43
  self.autocommit = True
41
44
  self.open_db()
@@ -48,7 +51,7 @@ class SQLiteDatabase(Database):
48
51
 
49
52
  if self.conn is None:
50
53
  try:
51
- self.conn = sqlite3.connect(self.db_name)
54
+ self.conn = sqlite3.connect(self.db_name, timeout=self.timeout)
52
55
  except ErrInfo:
53
56
  raise
54
57
  except Exception:
@@ -72,11 +75,11 @@ class SQLiteDatabase(Database):
72
75
  self.rollback()
73
76
  raise
74
77
 
75
- def table_exists(self, table_name: str, schema_name: Optional[str] = None) -> bool:
78
+ def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
76
79
  curs = self.cursor()
77
- sql = f"select name from sqlite_master where type='table' and name='{table_name}';"
80
+ sql = "select name from sqlite_master where type='table' and name=?;"
78
81
  try:
79
- curs.execute(sql)
82
+ curs.execute(sql, (table_name,))
80
83
  except ErrInfo:
81
84
  raise
82
85
  except Exception:
@@ -94,19 +97,15 @@ class SQLiteDatabase(Database):
94
97
  self,
95
98
  table_name: str,
96
99
  column_name: str,
97
- schema_name: Optional[str] = None,
100
+ schema_name: str | None = None,
98
101
  ) -> bool:
99
- curs = self.cursor()
100
- sql = f"select {column_name} from {table_name} limit 1;"
101
- try:
102
- curs.execute(sql)
103
- except Exception:
104
- return False
105
- return True
102
+ cols = self.table_columns(table_name, schema_name)
103
+ return column_name in cols
106
104
 
107
- def table_columns(self, table_name: str, schema_name: Optional[str] = None) -> List[str]:
105
+ def table_columns(self, table_name: str, schema_name: str | None = None) -> list[str]:
108
106
  curs = self.cursor()
109
- sql = f"select * from {table_name} where 1=0;"
107
+ quoted_tbl = self.quote_identifier(table_name)
108
+ sql = f"select * from {quoted_tbl} where 1=0;"
110
109
  try:
111
110
  curs.execute(sql)
112
111
  except ErrInfo:
@@ -123,9 +122,9 @@ class SQLiteDatabase(Database):
123
122
 
124
123
  def view_exists(self, view_name: str) -> bool:
125
124
  curs = self.cursor()
126
- sql = f"select name from sqlite_master where type='view' and name='{view_name}';"
125
+ sql = "select name from sqlite_master where type='view' and name=?;"
127
126
  try:
128
- curs.execute(sql)
127
+ curs.execute(sql, (view_name,))
129
128
  except ErrInfo:
130
129
  raise
131
130
  except Exception:
@@ -148,10 +147,10 @@ class SQLiteDatabase(Database):
148
147
 
149
148
  def populate_table(
150
149
  self,
151
- schema_name: Optional[str],
150
+ schema_name: str | None,
152
151
  table_name: str,
153
152
  rowsource: Any,
154
- column_list: List[str],
153
+ column_list: list[str],
155
154
  tablespec_src: Any,
156
155
  ) -> None:
157
156
  # The rowsource argument must be a generator yielding a list of values for the columns of the table.
@@ -174,6 +173,7 @@ class SQLiteDatabase(Database):
174
173
  paramspec = ",".join(["?" for c in columns])
175
174
  sql = f"insert into {sq_name} ({colspec}) values ({paramspec});"
176
175
  curs = self.cursor()
176
+ total_rows = 0
177
177
  for datalineno, line in enumerate(rowsource):
178
178
  # Skip empty rows.
179
179
  if not (len(line) == 1 and line[0] is None):
@@ -190,16 +190,15 @@ class SQLiteDatabase(Database):
190
190
  line[i] = line[i].strip()
191
191
  if _state.conf.replace_newlines:
192
192
  line[i] = re.sub(r"[\s\t]*[\r\n]+[\s\t]*", " ", line[i])
193
- if not _state.conf.empty_strings:
194
- if line[i].strip() == "":
195
- line[i] = None
193
+ if not _state.conf.empty_strings and line[i].strip() == "":
194
+ line[i] = None
196
195
  # Convert datetime, time, and Decimal values to strings.
197
196
  for i in range(len(linedata)):
198
197
  if type(linedata[i]) in (datetime.datetime, datetime.time, Decimal):
199
198
  linedata[i] = str(linedata[i])
200
199
  add_line = True
201
200
  if not _state.conf.empty_rows:
202
- add_line = not all([c is None for c in linedata])
201
+ add_line = not all(c is None for c in linedata)
203
202
  if add_line:
204
203
  try:
205
204
  curs.execute(sql, linedata)
@@ -213,17 +212,27 @@ class SQLiteDatabase(Database):
213
212
  exception_msg=exception_desc(),
214
213
  other_msg=f"Can't load data into table {sq_name} from line {{{line}}}",
215
214
  )
215
+ total_rows += 1
216
+ interval = getattr(_state.conf, "import_progress_interval", 0)
217
+ if _state.exec_log and interval > 0 and total_rows % interval == 0:
218
+ _state.exec_log.log_status_info(
219
+ f"IMPORT into {sq_name}: {total_rows} rows imported so far.",
220
+ )
221
+ if _state.exec_log:
222
+ _state.exec_log.log_status_info(
223
+ f"IMPORT into {sq_name} complete: {total_rows} rows imported.",
224
+ )
216
225
 
217
226
  def import_entire_file(
218
227
  self,
219
- schema_name: Optional[str],
228
+ schema_name: str | None,
220
229
  table_name: str,
221
230
  column_name: str,
222
231
  file_name: str,
223
232
  ) -> None:
224
233
  import sqlite3
225
234
 
226
- with io.open(file_name, "rb") as f:
235
+ with open(file_name, "rb") as f:
227
236
  filedata = f.read()
228
237
  sq_name = self.schema_qualified_table_name(schema_name, table_name)
229
238
  sql = f"insert into {sq_name} ({column_name}) values ({self.paramsubs(1)});"
execsql/db/sqlserver.py CHANGED
@@ -7,13 +7,11 @@ Implements :class:`SqlServerDatabase`, which connects to Microsoft SQL
7
7
  Server via ``pyodbc``. Corresponds to ``-t s`` on the CLI.
8
8
  """
9
9
 
10
- import io
11
- from typing import Optional
12
10
 
13
11
  from execsql.db.base import Database
14
12
  from execsql.exceptions import ErrInfo
15
- from execsql.utils.errors import exception_desc, fatal_error
16
- from execsql.utils.auth import get_password
13
+ from execsql.utils.errors import fatal_error
14
+ from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
17
15
  import execsql.state as _state
18
16
 
19
17
 
@@ -22,11 +20,11 @@ class SqlServerDatabase(Database):
22
20
  self,
23
21
  server_name: str,
24
22
  db_name: str,
25
- user_name: Optional[str],
23
+ user_name: str | None,
26
24
  need_passwd: bool = False,
27
- port: Optional[int] = 1433,
28
- encoding: Optional[str] = "latin1",
29
- password: Optional[str] = None,
25
+ port: int | None = 1433,
26
+ encoding: str | None = "latin1",
27
+ password: str | None = None,
30
28
  ) -> None:
31
29
  try:
32
30
  import pyodbc # noqa: F401
@@ -76,30 +74,47 @@ class SqlServerDatabase(Database):
76
74
  "SQL Native Client",
77
75
  "SQL Server",
78
76
  )
79
- for drv in ssdrivers:
80
- if self.user:
81
- if self.password:
82
- connstr = (
83
- f"DRIVER={{{drv}}};SERVER={self.server_name};MARS_Connection=Yes; "
84
- f"DATABASE={self.db_name};Uid={self.user};Pwd={self.password}"
85
- )
77
+
78
+ def _try_drivers():
79
+ for drv in ssdrivers:
80
+ if self.user:
81
+ if self.password:
82
+ connstr = (
83
+ f"DRIVER={{{drv}}};SERVER={self.server_name};MARS_Connection=Yes; "
84
+ f"DATABASE={self.db_name};Uid={self.user};Pwd={self.password}"
85
+ )
86
+ else:
87
+ connstr = (
88
+ f"DRIVER={{{drv}}};SERVER={self.server_name};MARS_Connection=Yes; "
89
+ f"DATABASE={self.db_name};Uid={self.user}"
90
+ )
86
91
  else:
87
92
  connstr = (
88
93
  f"DRIVER={{{drv}}};SERVER={self.server_name};MARS_Connection=Yes; "
89
- f"DATABASE={self.db_name};Uid={self.user}"
94
+ f"DATABASE={self.db_name};Trusted_Connection=yes"
90
95
  )
91
- else:
92
- connstr = (
93
- f"DRIVER={{{drv}}};SERVER={self.server_name};MARS_Connection=Yes; "
94
- f"DATABASE={self.db_name};Trusted_Connection=yes"
95
- )
96
- try:
97
- self.conn = pyodbc.connect(connstr)
98
- except Exception:
99
- _state.exec_log.log_status_info(f"Could not connect using: {connstr}")
100
- else:
101
- _state.exec_log.log_status_info(f"Connected using: {connstr}")
102
- break
96
+ try:
97
+ self.conn = pyodbc.connect(connstr)
98
+ except Exception:
99
+ _state.exec_log.log_status_info(f"Could not connect using: {connstr}")
100
+ else:
101
+ _state.exec_log.log_status_info(f"Connected using: {connstr}")
102
+ return True
103
+ return False
104
+
105
+ if not _try_drivers() and password_from_keyring():
106
+ # Stored credential is stale — clear it and re-prompt.
107
+ clear_stored_password("SQL Server", self.db_name, self.user, self.server_name)
108
+ self.password = get_password(
109
+ "SQL Server",
110
+ self.db_name,
111
+ self.user,
112
+ server_name=self.server_name,
113
+ skip_keyring=True,
114
+ other_msg="(stored credential failed — enter current password)",
115
+ )
116
+ _try_drivers()
117
+
103
118
  if not self.conn:
104
119
  raise ErrInfo(
105
120
  type="error",
@@ -147,14 +162,14 @@ class SqlServerDatabase(Database):
147
162
 
148
163
  def import_entire_file(
149
164
  self,
150
- schema_name: Optional[str],
165
+ schema_name: str | None,
151
166
  table_name: str,
152
167
  column_name: str,
153
168
  file_name: str,
154
169
  ) -> None:
155
170
  import pyodbc
156
171
 
157
- with io.open(file_name, "rb") as f:
172
+ with open(file_name, "rb") as f:
158
173
  filedata = f.read()
159
174
  sq_name = self.schema_qualified_table_name(schema_name, table_name)
160
175
  sql = f"insert into {sq_name} ({column_name}) values ({self.paramsubs(1)});"
execsql/exceptions.py CHANGED
@@ -6,9 +6,11 @@ Custom exception hierarchy for execsql.
6
6
  All domain-specific exceptions are defined here so that callers can import
7
7
  from a single location. Notable exceptions:
8
8
 
9
+ - :class:`ExecSqlError` — common base for all single-message execsql exceptions.
9
10
  - :class:`ConfigError` — invalid or missing ``execsql.conf`` values.
10
- - :class:`ErrInfo` — both an exception and a structured error-data carrier
11
- (type, command text, exception message, script location).
11
+ - :class:`ErrInfo` — rich exception carrying type, command text, exception
12
+ message, and script location; used as both a raised exception and an error
13
+ data carrier passed to ``exit_now()``.
12
14
  - :class:`ExecSqlTimeoutError` — timeout during alarm-timer operations.
13
15
  - :class:`DataTypeError` / :class:`DbTypeError` — type-system failures.
14
16
  - :class:`ColumnError` / :class:`DataTableError` — data-model failures.
@@ -20,26 +22,60 @@ from a single location. Notable exceptions:
20
22
  """
21
23
 
22
24
 
23
- class ConfigError(Exception):
24
- def __init__(self, msg: str) -> None:
25
- self.value = msg
25
+ class ExecSqlError(Exception):
26
+ """Base class for simple single-message execsql exceptions.
27
+
28
+ Subclasses inherit a ``value`` attribute holding the original message and
29
+ a ``__repr__`` that uses the concrete class name, so no boilerplate is
30
+ needed in each subclass.
31
+
32
+ ``super().__init__(errmsg)`` is called so that ``str(exc)``, ``exc.args``,
33
+ and standard logging all produce meaningful output.
34
+ """
35
+
36
+ def __init__(self, errmsg: str) -> None:
37
+ super().__init__(errmsg)
38
+ self.value = errmsg
26
39
 
27
40
  def __repr__(self) -> str:
28
- return f"ConfigError({self.value!r})"
41
+ return f"{type(self).__name__}({self.value!r})"
42
+
29
43
 
44
+ class ConfigError(ExecSqlError):
45
+ """Raised for invalid or missing execsql configuration values."""
30
46
 
31
- class ExecSqlTimeoutError(Exception):
32
- """Renamed from TimeoutError to avoid shadowing the Python 3.3+ built-in."""
33
47
 
34
- pass
48
+ class ExecSqlTimeoutError(ExecSqlError):
49
+ """Timeout during alarm-timer operations.
50
+
51
+ Inherits from :class:`ExecSqlError` so that generic ``except ExecSqlError``
52
+ handlers will catch timeouts. Accepts an optional message (defaults to
53
+ ``"Operation timed out"``), keeping it compatible with bare
54
+ ``raise ExecSqlTimeoutError`` usage.
55
+ """
56
+
57
+ def __init__(self, errmsg: str = "Operation timed out") -> None:
58
+ super().__init__(errmsg)
35
59
 
36
60
 
37
61
  class ErrInfo(Exception):
38
- """Both an exception and a data carrier for error information."""
62
+ """Rich exception and error-data carrier for execsql.
63
+
64
+ ``str(e)`` returns the most informative available message (``other_msg``,
65
+ then ``exception_msg``, then ``type``) so that standard logging and
66
+ exception handlers produce useful output without requiring callers to know
67
+ about the execsql-specific ``eval_err()`` / ``write()`` interface.
68
+
69
+ ``eval_err()`` / ``write()`` remain available for the full formatted
70
+ message including script location, timestamp, and command context.
71
+ """
39
72
 
40
73
  def __repr__(self) -> str:
41
74
  return f"ErrInfo({self.type!r}, {self.command!r}, {self.exception!r}, {self.other!r})"
42
75
 
76
+ def __str__(self) -> str:
77
+ return self.other or self.exception or self.type or "ErrInfo"
78
+
43
79
  def __init__(
44
80
  self,
45
81
  type: str,
@@ -56,6 +92,9 @@ class ErrInfo(Exception):
56
92
  self.cmd = None
57
93
  self.cmdtype = None
58
94
  self.error_message = None
95
+ # Pass a concise message to Exception so str(e), e.args, and
96
+ # standard loggers produce useful output.
97
+ super().__init__(self.other or self.exception or self.type)
59
98
 
60
99
  def script_info(self) -> str | None:
61
100
  if self.script_line_no:
@@ -117,6 +156,7 @@ class DataTypeError(Exception):
117
156
  def __init__(self, data_type_name: str, error_msg: str) -> None:
118
157
  self.data_type_name = data_type_name or "Unspecified data type"
119
158
  self.error_msg = error_msg or "Unspecified error"
159
+ super().__init__(str(self))
120
160
 
121
161
  def __repr__(self) -> str:
122
162
  return f"DataTypeError({self.data_type_name!r}, {self.error_msg!r})"
@@ -130,6 +170,7 @@ class DbTypeError(Exception):
130
170
  self.dbms_id = dbms_id
131
171
  self.data_type = data_type
132
172
  self.error_msg = error_msg or "Unspecified error"
173
+ super().__init__(str(self))
133
174
 
134
175
  def __repr__(self) -> str:
135
176
  return f"DbTypeError({self.dbms_id!r}, {self.data_type!r}, {self.error_msg!r})"
@@ -141,32 +182,19 @@ class DbTypeError(Exception):
141
182
  return f"{self.dbms_id} DBMS type error: {self.error_msg}"
142
183
 
143
184
 
144
- class ColumnError(Exception):
145
- def __init__(self, errmsg: str) -> None:
146
- self.value = errmsg
147
-
148
- def __repr__(self) -> str:
149
- return f"ColumnError({self.value!r})"
150
-
151
- def __str__(self) -> str:
152
- return repr(self.value)
185
+ class ColumnError(ExecSqlError):
186
+ """Raised for column-level data errors."""
153
187
 
154
188
 
155
- class DataTableError(Exception):
156
- def __init__(self, errmsg: str) -> None:
157
- self.value = errmsg
158
-
159
- def __repr__(self) -> str:
160
- return f"DataTableError({self.value})"
161
-
162
- def __str__(self) -> str:
163
- return repr(self.value)
189
+ class DataTableError(ExecSqlError):
190
+ """Raised for DataTable-level errors."""
164
191
 
165
192
 
166
193
  class DatabaseNotImplementedError(Exception):
167
194
  def __init__(self, db_name: str, method: str) -> None:
168
195
  self.db_name = db_name
169
196
  self.method = method
197
+ super().__init__(str(self))
170
198
 
171
199
  def __repr__(self) -> str:
172
200
  return f"DatabaseNotImplementedError({self.db_name!r}, {self.method!r})"
@@ -175,49 +203,25 @@ class DatabaseNotImplementedError(Exception):
175
203
  return f"Method {self.method} is not implemented for database {self.db_name}"
176
204
 
177
205
 
178
- class OdsFileError(Exception):
179
- def __init__(self, errmsg: str) -> None:
180
- self.value = errmsg
181
-
182
- def __repr__(self) -> str:
183
- return f"OdsFileError({self.value!r})"
184
-
185
-
186
- class XlsFileError(Exception):
187
- def __init__(self, errmsg: str) -> None:
188
- self.value = errmsg
189
-
190
- def __repr__(self) -> str:
191
- return f"XlsFileError({self.value!r})"
192
-
193
-
194
- class XlsxFileError(Exception):
195
- def __init__(self, errmsg: str) -> None:
196
- self.value = errmsg
206
+ class OdsFileError(ExecSqlError):
207
+ """Raised for ODS file I/O errors."""
197
208
 
198
- def __repr__(self) -> str:
199
- return f"XlsxFileError({self.value!r})"
200
209
 
210
+ class XlsFileError(ExecSqlError):
211
+ """Raised for XLS file I/O errors."""
201
212
 
202
- class ConsoleUIError(Exception):
203
- def __init__(self, errmsg: str) -> None:
204
- self.value = errmsg
205
213
 
206
- def __repr__(self) -> str:
207
- return f"ConsoleUIError({self.value!r})"
214
+ class XlsxFileError(ExecSqlError):
215
+ """Raised for XLSX file I/O errors."""
208
216
 
209
217
 
210
- class CondParserError(Exception):
211
- def __init__(self, errmsg: str) -> None:
212
- self.value = errmsg
218
+ class ConsoleUIError(ExecSqlError):
219
+ """Raised for GUI console errors."""
213
220
 
214
- def __repr__(self) -> str:
215
- return f"CondParserError({self.value!r})"
216
221
 
222
+ class CondParserError(ExecSqlError):
223
+ """Raised for conditional-expression parse errors."""
217
224
 
218
- class NumericParserError(Exception):
219
- def __init__(self, errmsg: str) -> None:
220
- self.value = errmsg
221
225
 
222
- def __repr__(self) -> str:
223
- return f"NumericParserError({self.value!r})"
226
+ class NumericParserError(ExecSqlError):
227
+ """Raised for numeric-expression parse errors."""
@@ -10,5 +10,5 @@ importing directly from here.
10
10
 
11
11
  Sub-modules: ``base``, ``delimited``, ``json``, ``xml``, ``html``,
12
12
  ``latex``, ``ods``, ``xls``, ``zip``, ``raw``, ``pretty``, ``values``,
13
- ``templates``, ``feather``, ``duckdb``, ``sqlite``.
13
+ ``templates``, ``feather``, ``parquet``, ``duckdb``, ``sqlite``.
14
14
  """
execsql/exporters/base.py CHANGED
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  """
4
2
  Base export infrastructure — metadata tracking and write specifications.
5
3
 
@@ -14,42 +12,63 @@ Provides:
14
12
  (message text, file path, encoding) used by halt/cancel hooks.
15
13
  """
16
14
 
17
- import os
18
- import re
19
- from typing import Any, Optional, List
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any
20
19
 
21
20
  import execsql.state as _state
21
+ from execsql.script import current_script_line
22
+ from execsql.utils.errors import file_size_date
23
+ from execsql.utils.gui import ConsoleUIError
22
24
 
23
25
 
24
26
  class ExportRecord:
27
+ """Records the details of a single EXPORT operation for metadata tracking.
28
+
29
+ Captures the query name, output file path, optional zip file, user
30
+ description, originating script location, and database connection info.
31
+ """
32
+
25
33
  def __init__(
26
34
  self,
27
35
  queryname: str,
28
36
  outfile: str,
29
- zipfile: Optional[str] = None,
30
- description: Optional[str] = None,
37
+ zipfile: str | None = None,
38
+ description: str | None = None,
31
39
  ) -> None:
32
40
  self.exported = False
33
41
  # Record is a list of: table_or_query_name, filename, zipfilename, file_path, user_description, script_name,
34
42
  # script_path, script_line_no, script_datetime, database_name, database_server, user_name.
35
43
  if zipfile is not None:
36
- fpath, zfname = os.path.split(os.path.abspath(zipfile))
44
+ fpath, zfname = str(Path(zipfile).resolve().parent), Path(zipfile).resolve().name
37
45
  fname = outfile
38
46
  else:
39
- fpath, fname = os.path.split(os.path.abspath(outfile))
47
+ fpath, fname = str(Path(outfile).resolve().parent), Path(outfile).resolve().name
40
48
  zfname = None
41
- script, lno = _state.current_script_line()
42
- spath, sname = os.path.split(os.path.abspath(script))
43
- ssz, sdt = _state.file_size_date(script)
49
+ import getpass
50
+
51
+ script, lno = current_script_line()
52
+ if script and Path(script).is_file():
53
+ spath, sname = str(Path(script).resolve().parent), Path(script).resolve().name
54
+ _, sdt = file_size_date(script)
55
+ else:
56
+ spath, sname = "", script or "<inline>"
57
+ _, sdt = 0, ""
44
58
  db = _state.dbs.current()
45
59
  svr = db.server_name
46
60
  dbn = db.db_name
47
- usr = db.user if db.user is not None else _state.getpass.getuser()
61
+ usr = db.user if db.user is not None else getpass.getuser()
48
62
  self.record = [queryname, fname, zfname, fpath, description, sname, spath, lno, sdt, dbn, svr, usr]
49
63
 
50
64
 
51
65
  class ExportMetadata:
52
- # A list of ExportRecord objects.
66
+ """Collection of :class:`ExportRecord` objects; can write itself as JSON.
67
+
68
+ Accumulates export records during a script run and provides them to the
69
+ EXPORT METADATA metacommand for serialisation.
70
+ """
71
+
53
72
  colhdrs = [
54
73
  "query",
55
74
  "filename",
@@ -66,7 +85,7 @@ class ExportMetadata:
66
85
  ]
67
86
 
68
87
  def __init__(self) -> None:
69
- self.recordlist: List[ExportRecord] = []
88
+ self.recordlist: list[ExportRecord] = []
70
89
 
71
90
  def add(self, exp_record: ExportRecord) -> None:
72
91
  self.recordlist.append(exp_record)
@@ -85,10 +104,16 @@ class ExportMetadata:
85
104
 
86
105
 
87
106
  class WriteSpec:
107
+ """Specification for a deferred WRITE operation used by halt/cancel hooks.
108
+
109
+ Stores a message, optional destination file, tee flag, and repeatability
110
+ setting. Resolved and executed later by the hook machinery.
111
+ """
112
+
88
113
  def __repr__(self) -> str:
89
114
  return f"WriteSpec({self.msg}, {self.outfile}, {self.tee})"
90
115
 
91
- def __init__(self, message: str, dest: Optional[str] = None, tee: Any = None, repeatable: bool = False) -> None:
116
+ def __init__(self, message: str, dest: str | None = None, tee: Any = None, repeatable: bool = False) -> None:
92
117
  # Inputs
93
118
  # message: Text to write. May contain substitution variable references.
94
119
  # dest: The to which the text should be written. If omitted, the message
@@ -122,7 +147,7 @@ class WriteSpec:
122
147
  if (not self.outfile) or self.tee:
123
148
  try:
124
149
  _state.output.write(msg.encode(conf.output_encoding))
125
- except _state.ConsoleUIError as e:
150
+ except ConsoleUIError as e:
126
151
  _state.output.reset()
127
152
  _state.exec_log.log_status_info(
128
153
  f"Console UI write failed (message {{{e.value}}}); output reset to stdout.",