execsql2 2.16.18__py3-none-any.whl → 2.17.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 (74) hide show
  1. execsql/__init__.py +6 -2
  2. execsql/api.py +25 -6
  3. execsql/cli/__init__.py +5 -3
  4. execsql/cli/lint.py +30 -34
  5. execsql/cli/run.py +10 -0
  6. execsql/config.py +145 -92
  7. execsql/db/access.py +54 -40
  8. execsql/db/base.py +33 -6
  9. execsql/db/firebird.py +3 -1
  10. execsql/db/mysql.py +4 -3
  11. execsql/db/oracle.py +36 -14
  12. execsql/db/postgres.py +8 -6
  13. execsql/db/sqlite.py +5 -2
  14. execsql/db/sqlserver.py +8 -6
  15. execsql/debug/repl.py +59 -21
  16. execsql/exceptions.py +19 -4
  17. execsql/exporters/base.py +3 -2
  18. execsql/exporters/delimited.py +2 -3
  19. execsql/exporters/feather.py +3 -3
  20. execsql/exporters/ods.py +1 -1
  21. execsql/exporters/xls.py +12 -4
  22. execsql/exporters/xlsx.py +1 -1
  23. execsql/gui/desktop.py +129 -15
  24. execsql/importers/__init__.py +1 -1
  25. execsql/importers/ods.py +1 -1
  26. execsql/importers/xls.py +1 -1
  27. execsql/metacommands/__init__.py +34 -5
  28. execsql/metacommands/conditions.py +26 -14
  29. execsql/metacommands/connect.py +21 -14
  30. execsql/metacommands/control.py +55 -68
  31. execsql/metacommands/data.py +25 -9
  32. execsql/metacommands/debug.py +132 -77
  33. execsql/metacommands/io_export.py +14 -2
  34. execsql/metacommands/io_import.py +11 -2
  35. execsql/metacommands/io_write.py +113 -11
  36. execsql/metacommands/prompt.py +46 -32
  37. execsql/metacommands/script_ext.py +63 -34
  38. execsql/metacommands/system.py +4 -3
  39. execsql/metacommands/upsert.py +0 -29
  40. execsql/script/__init__.py +28 -37
  41. execsql/script/ast.py +7 -7
  42. execsql/script/control.py +4 -101
  43. execsql/script/engine.py +37 -251
  44. execsql/script/executor.py +193 -230
  45. execsql/script/parser.py +1 -3
  46. execsql/script/variables.py +8 -3
  47. execsql/state.py +125 -37
  48. execsql/utils/errors.py +0 -2
  49. execsql/utils/fileio.py +47 -3
  50. execsql/utils/mail.py +3 -2
  51. execsql/utils/strings.py +5 -5
  52. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/METADATA +42 -36
  53. execsql2-2.17.2.dist-info/RECORD +124 -0
  54. execsql2-2.17.2.dist-info/licenses/NOTICE +11 -0
  55. execsql2-2.16.18.dist-info/RECORD +0 -124
  56. execsql2-2.16.18.dist-info/licenses/NOTICE +0 -10
  57. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/README.md +0 -0
  58. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  59. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  60. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/execsql.conf +0 -0
  61. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
  62. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/md_compare.sql +0 -0
  63. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
  64. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
  65. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
  66. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  67. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  68. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/script_template.sql +0 -0
  69. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
  70. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  71. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  72. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/WHEEL +0 -0
  73. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/entry_points.txt +0 -0
  74. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/licenses/LICENSE.txt +0 -0
execsql/db/base.py CHANGED
@@ -46,7 +46,26 @@ def _default_dt_cast() -> dict[type, Callable]:
46
46
 
47
47
 
48
48
  class Database(ABC):
49
- """Abstract base class for all database connections."""
49
+ """Abstract base class for every DBMS adapter.
50
+
51
+ Concrete adapters in sibling modules (``postgres.py``, ``sqlite.py``,
52
+ ``duckdb.py``, etc.) subclass ``Database`` and override the two
53
+ abstract methods (:meth:`open_db`, :meth:`exec_cmd`) plus any
54
+ introspection or data-handling method whose default ANSI
55
+ ``information_schema`` implementation does not work for the target
56
+ DBMS (``schema_exists``, ``table_exists``, ``column_exists``,
57
+ ``table_columns``, ``view_exists``, ``role_exists``, ``drop_table``,
58
+ ``populate_table``).
59
+
60
+ The base class provides shared implementations of :meth:`execute`,
61
+ :meth:`select_data`, :meth:`select_rowsource`, :meth:`select_rowdict`,
62
+ :meth:`commit`, :meth:`rollback`, :meth:`quote_identifier`, and
63
+ :meth:`paramsubs` that work for any DB-API 2.0 driver.
64
+
65
+ Adapter instances are owned by :class:`DatabasePool` (accessed at
66
+ runtime via ``_state.dbs``); metacommand handlers call
67
+ ``_state.dbs.current()`` rather than constructing adapters directly.
68
+ """
50
69
 
51
70
  _dt_cast: dict[type, Callable] | None = None
52
71
 
@@ -125,7 +144,7 @@ class Database(ABC):
125
144
  def close(self) -> None:
126
145
  """Close the database connection, logging a warning if autocommit is off."""
127
146
  if self.conn:
128
- if not self.autocommit:
147
+ if not self.autocommit and _state.exec_log is not None:
129
148
  _state.exec_log.log_status_info(
130
149
  f"Closing {self.name()} when AUTOCOMMIT is OFF; transactions may not have completed.",
131
150
  )
@@ -412,7 +431,14 @@ class Database(ABC):
412
431
  return len(rows) > 0
413
432
 
414
433
  def role_exists(self, rolename: str) -> bool:
415
- """Return ``True`` if *rolename* exists; subclasses must override this."""
434
+ """Return ``True`` if *rolename* exists in this database.
435
+
436
+ The default implementation raises :class:`~execsql.exceptions.DatabaseNotImplementedError`;
437
+ adapters for DBMSes that have a concept of roles (PostgreSQL,
438
+ SQL Server, etc.) override this to query the appropriate
439
+ catalog. Calling ``ROLE_EXISTS()`` from a script against a DBMS
440
+ without role support will surface the raised error.
441
+ """
416
442
  from execsql.exceptions import DatabaseNotImplementedError
417
443
 
418
444
  raise DatabaseNotImplementedError(self.name(), "role_exists")
@@ -682,9 +708,10 @@ class DatabasePool:
682
708
  type="error",
683
709
  other_msg="You may not reassign the alias of a database that is currently used in a batch.",
684
710
  )
685
- _state.exec_log.log_status_info(
686
- f"Reassigning database alias '{db_alias}' from {self.pool[db_alias].name()} to {db_obj.name()}.",
687
- )
711
+ if _state.exec_log is not None:
712
+ _state.exec_log.log_status_info(
713
+ f"Reassigning database alias '{db_alias}' from {self.pool[db_alias].name()} to {db_obj.name()}.",
714
+ )
688
715
  self.pool[db_alias].close()
689
716
  self.pool[db_alias] = db_obj
690
717
  # Refresh static system vars so $DB_NAME, $DB_USER, etc. reflect the new connection.
execsql/db/firebird.py CHANGED
@@ -191,10 +191,12 @@ class FirebirdDatabase(Database):
191
191
 
192
192
  def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
193
193
  """Return True if the named view exists in the Firebird database."""
194
+ # Firebird folds unquoted identifiers to uppercase in the system catalog;
195
+ # match the case of the table_exists() handling above.
194
196
  sql = "select distinct rdb$view_name from rdb$view_relations where rdb$view_name = ?;"
195
197
  with self._cursor() as curs:
196
198
  try:
197
- curs.execute(sql, (view_name,))
199
+ curs.execute(sql, (view_name.upper(),))
198
200
  except ErrInfo:
199
201
  raise
200
202
  except Exception as e:
execsql/db/mysql.py CHANGED
@@ -220,9 +220,10 @@ class MySQLDatabase(Database):
220
220
  import_sql = f"{import_sql} optionally enclosed by '{safe_quote}'"
221
221
  import_sql = f"{import_sql} ignore {1 + csv_file_obj.junk_header_lines} lines"
222
222
  import_sql = f"{import_sql} ({input_col_list});"
223
- _state.exec_log.log_status_info(
224
- f"IMPORTing {csv_file_obj.csvfname} using the DBMS' fast file reading routine",
225
- )
223
+ if _state.exec_log is not None:
224
+ _state.exec_log.log_status_info(
225
+ f"IMPORTing {csv_file_obj.csvfname} using the DBMS' fast file reading routine",
226
+ )
226
227
  self.execute(import_sql)
227
228
  else:
228
229
  data_indexes = [csv_file_cols.index(col) for col in import_cols]
execsql/db/oracle.py CHANGED
@@ -142,11 +142,13 @@ class OracleDatabase(Database):
142
142
 
143
143
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
144
144
  """Return True if the named table exists in the Oracle database."""
145
- params = {"tname": table_name}
145
+ # Oracle folds unquoted identifiers to uppercase; normalise so a lookup
146
+ # of "mytable" finds the catalog entry for MYTABLE.
147
+ params = {"tname": table_name.upper()}
146
148
  owner_clause = ""
147
149
  if schema_name:
148
150
  owner_clause = " and owner = :owner"
149
- params["owner"] = schema_name
151
+ params["owner"] = schema_name.upper()
150
152
  sql = f"select table_name from sys.all_tables where table_name = :tname{owner_clause}"
151
153
  with self._cursor() as curs:
152
154
  try:
@@ -171,11 +173,12 @@ class OracleDatabase(Database):
171
173
  schema_name: str | None = None,
172
174
  ) -> bool:
173
175
  """Return True if the named column exists in the given Oracle table."""
174
- params = {"tname": table_name, "cname": column_name}
176
+ # Oracle folds unquoted identifiers to uppercase.
177
+ params = {"tname": table_name.upper(), "cname": column_name.upper()}
175
178
  owner_clause = ""
176
179
  if schema_name:
177
180
  owner_clause = " and owner = :owner"
178
- params["owner"] = schema_name
181
+ params["owner"] = schema_name.upper()
179
182
  sql = f"select column_name from all_tab_columns where table_name=:tname{owner_clause} and column_name=:cname"
180
183
  with self._cursor() as curs:
181
184
  try:
@@ -195,11 +198,12 @@ class OracleDatabase(Database):
195
198
 
196
199
  def table_columns(self, table_name: str, schema_name: str | None = None) -> list[str]:
197
200
  """Return a list of column names for the given Oracle table."""
198
- params = {"tname": table_name}
201
+ # Oracle folds unquoted identifiers to uppercase.
202
+ params = {"tname": table_name.upper()}
199
203
  owner_clause = ""
200
204
  if schema_name:
201
205
  owner_clause = " and owner=:owner"
202
- params["owner"] = schema_name
206
+ params["owner"] = schema_name.upper()
203
207
  sql = f"select column_name from all_tab_columns where table_name=:tname{owner_clause} order by column_id"
204
208
  with self._cursor() as curs:
205
209
  try:
@@ -219,11 +223,12 @@ class OracleDatabase(Database):
219
223
 
220
224
  def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
221
225
  """Return True if the named view exists in the Oracle database."""
222
- params = {"vname": view_name}
226
+ # Oracle folds unquoted identifiers to uppercase.
227
+ params = {"vname": view_name.upper()}
223
228
  owner_clause = ""
224
229
  if schema_name:
225
230
  owner_clause = " and owner = :owner"
226
- params["owner"] = schema_name
231
+ params["owner"] = schema_name.upper()
227
232
  sql = f"select view_name from sys.all_views where view_name = :vname{owner_clause}"
228
233
  with self._cursor() as curs:
229
234
  try:
@@ -242,13 +247,30 @@ class OracleDatabase(Database):
242
247
  return len(rows) > 0
243
248
 
244
249
  def role_exists(self, rolename: str) -> bool:
245
- """Return True if the named role or user exists in the Oracle database."""
250
+ """Return True if the named role or user exists in the Oracle database.
251
+
252
+ ``dba_roles`` is restricted to DBA accounts and would raise
253
+ ``ORA-00942: table or view does not exist`` for non-DBA users; we
254
+ try it first and fall back to ``session_roles`` (always readable
255
+ for the current session) if the catalog isn't accessible.
256
+ """
257
+ # Oracle folds unquoted identifiers to uppercase in the catalog.
258
+ params = {"rname": rolename.upper()}
246
259
  with self._cursor() as curs:
247
- curs.execute(
248
- "select role from dba_roles where role = :rname union "
249
- " select username from all_users where username = :rname",
250
- {"rname": rolename},
251
- )
260
+ try:
261
+ curs.execute(
262
+ "select role from dba_roles where role = :rname union "
263
+ " select username from all_users where username = :rname",
264
+ params,
265
+ )
266
+ except Exception:
267
+ # Non-DBA fallback: enumerate session roles + users we can see.
268
+ self.rollback()
269
+ curs.execute(
270
+ "select role from session_roles where role = :rname union "
271
+ " select username from all_users where username = :rname",
272
+ params,
273
+ )
252
274
  rows = curs.fetchall()
253
275
  return len(rows) > 0
254
276
 
execsql/db/postgres.py CHANGED
@@ -3,9 +3,10 @@ from __future__ import annotations
3
3
  """
4
4
  PostgreSQL database adapter for execsql.
5
5
 
6
- Implements :class:`PostgresDatabase`, the most feature-complete adapter,
7
- supporting schema-qualified tables, server-side ``COPY``, ``LISTEN``/
8
- ``NOTIFY``, and ``psycopg2``-level connection options. Corresponds to
6
+ Implements :class:`PostgresDatabase`. Uses ``psycopg2`` for the
7
+ connection, supports schema-qualified tables, server-side ``COPY`` for
8
+ fast IMPORT, ``CREATE DATABASE`` when ``new_db=True``, ``ROLE_EXISTS``,
9
+ and the ``PG_VACUUM`` metacommand (``vacuum()`` method). Corresponds to
9
10
  ``-t p`` on the CLI.
10
11
  """
11
12
 
@@ -339,9 +340,10 @@ class PostgresDatabase(Database):
339
340
  safe_quote = csv_file_obj.quotechar.replace("'", "''")
340
341
  copy_cmd = copy_cmd + f", quote '{safe_quote}'"
341
342
  copy_cmd = copy_cmd + ")"
342
- _state.exec_log.log_status_info(
343
- f"IMPORTing {csv_file_obj.csvfname} using Postgres' fast file reading routine",
344
- )
343
+ if _state.exec_log is not None:
344
+ _state.exec_log.log_status_info(
345
+ f"IMPORTing {csv_file_obj.csvfname} using Postgres' fast file reading routine",
346
+ )
345
347
  with self._cursor() as curs:
346
348
  try:
347
349
  curs.copy_expert(copy_cmd, rf, _state.conf.import_buffer)
execsql/db/sqlite.py CHANGED
@@ -82,8 +82,10 @@ class SQLiteDatabase(Database):
82
82
 
83
83
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
84
84
  """Return True if the named table exists in the SQLite database."""
85
+ # SQLite stores names as-created; match case-insensitively so a lookup
86
+ # of "mytable" finds the catalog entry for MyTable.
85
87
  with self._cursor() as curs:
86
- sql = "select name from sqlite_master where type='table' and name=?;"
88
+ sql = "select name from sqlite_master where type='table' and lower(name)=lower(?);"
87
89
  try:
88
90
  curs.execute(sql, (table_name,))
89
91
  except ErrInfo:
@@ -130,8 +132,9 @@ class SQLiteDatabase(Database):
130
132
 
131
133
  def view_exists(self, view_name: str) -> bool:
132
134
  """Return True if the named view exists in the SQLite database."""
135
+ # Match case-insensitively — see table_exists().
133
136
  with self._cursor() as curs:
134
- sql = "select name from sqlite_master where type='view' and name=?;"
137
+ sql = "select name from sqlite_master where type='view' and lower(name)=lower(?);"
135
138
  try:
136
139
  curs.execute(sql, (view_name,))
137
140
  except ErrInfo:
execsql/db/sqlserver.py CHANGED
@@ -103,13 +103,15 @@ class SqlServerDatabase(Database):
103
103
  try:
104
104
  self.conn = pyodbc.connect(connstr)
105
105
  except Exception:
106
- _state.exec_log.log_status_info(
107
- f"Could not connect using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
108
- )
106
+ if _state.exec_log is not None:
107
+ _state.exec_log.log_status_info(
108
+ f"Could not connect using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
109
+ )
109
110
  else:
110
- _state.exec_log.log_status_info(
111
- f"Connected using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
112
- )
111
+ if _state.exec_log is not None:
112
+ _state.exec_log.log_status_info(
113
+ f"Connected using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
114
+ )
113
115
  return True
114
116
  return False
115
117
 
execsql/debug/repl.py CHANGED
@@ -345,12 +345,13 @@ def _print_all_vars(*, include_env: bool = False) -> None:
345
345
  _write(" (no substitution variables defined)\n\n")
346
346
  return
347
347
  items = list(subvars.substitutions) # list of (name, value) tuples
348
- # Include ~local and #param variables from the current stack frame.
349
- if _state.commandliststack:
350
- frame = _state.commandliststack[-1]
351
- items.extend(frame.localvars.substitutions)
352
- if frame.paramvals is not None:
353
- items.extend(frame.paramvals.substitutions)
348
+ # Include ~local and #param variables from the current scope frame.
349
+ localvars = _state.current_localvars()
350
+ if localvars is not None:
351
+ items.extend(localvars.substitutions)
352
+ paramvals = _state.current_paramvals()
353
+ if paramvals is not None:
354
+ items.extend(paramvals.substitutions)
354
355
  if not items:
355
356
  _write(" (no substitution variables defined)\n\n")
356
357
  return
@@ -415,12 +416,15 @@ def _print_var(varname: str) -> None:
415
416
  value = subvars.varvalue(varname)
416
417
  if value is None and len(varname) > 1 and varname[0] in "$&@#~":
417
418
  value = subvars.varvalue(varname[1:])
418
- # Check stack frame for ~local and #param variables.
419
- if value is None and _state.commandliststack:
420
- frame = _state.commandliststack[-1]
421
- value = frame.localvars.varvalue(varname)
422
- if value is None and frame.paramvals is not None:
423
- value = frame.paramvals.varvalue(varname)
419
+ # Check current scope frame for ~local and #param variables.
420
+ if value is None:
421
+ localvars = _state.current_localvars()
422
+ if localvars is not None:
423
+ value = localvars.varvalue(varname)
424
+ if value is None:
425
+ paramvals = _state.current_paramvals()
426
+ if paramvals is not None:
427
+ value = paramvals.varvalue(varname)
424
428
  if value is None:
425
429
  _write(f" {_c(_CYAN, varname)}: {_c(_DIM, '(undefined)')}\n")
426
430
  else:
@@ -428,17 +432,47 @@ def _print_var(varname: str) -> None:
428
432
 
429
433
 
430
434
  def _print_stack() -> None:
431
- """Print the current command-list stack (script name, line number, depth)."""
432
- stack = _state.commandliststack
435
+ """Print the unified AST execution stack.
436
+
437
+ Shows every nesting construct the executor is currently inside:
438
+ ``<main>`` script, ``EXECUTE SCRIPT`` calls, ``INCLUDE``'d files,
439
+ ``IF``/``ELSEIF``/``ELSE`` branches, ``LOOP`` iterations (with iteration
440
+ count), and ``BATCH`` blocks. Each frame shows source file and line.
441
+ """
442
+ stack = _state.ast_exec_stack
433
443
  if not stack:
434
- _write(" (command list stack is empty)\n\n")
444
+ _write(" (execution stack is empty)\n\n")
435
445
  return
436
446
  _write_rule(f" {_c(_BOLD + _YELLOW, 'Stack')} ")
437
447
  _write(f" {_c(_DIM, 'depth:')} {len(stack)}\n")
438
- for depth, cmdlist in enumerate(stack):
439
- listname = getattr(cmdlist, "listname", "<unknown>")
440
- cmdptr = getattr(cmdlist, "cmdptr", 0)
441
- _write(f" [{depth}] {listname} {_c(_DIM, f'(cursor at index {cmdptr})')}\n")
448
+ for depth, frame in enumerate(stack):
449
+ kind_label = frame.kind.upper().replace("LOOP_", "LOOP ")
450
+ # Build the right-hand description per kind
451
+ if frame.kind in ("if", "elseif"):
452
+ desc = f"{kind_label} {frame.label}"
453
+ elif frame.kind == "else":
454
+ desc = kind_label
455
+ elif frame.kind in ("loop_while", "loop_until"):
456
+ desc = f"{kind_label} {frame.label} {_c(_DIM, f'iter={frame.iteration}')}"
457
+ elif frame.kind == "script":
458
+ params = ""
459
+ if frame.params:
460
+ params = "(" + ", ".join(f"{k}={v!r}" for k, v in frame.params.items()) + ")"
461
+ iter_suffix = f" {_c(_DIM, f'iter={frame.iteration}')}" if frame.iteration else ""
462
+ desc = f"SCRIPT {frame.label}{params}{iter_suffix}"
463
+ elif frame.kind == "include":
464
+ desc = f"INCLUDE {frame.label}"
465
+ elif frame.kind == "batch":
466
+ desc = "BATCH"
467
+ elif frame.kind == "main":
468
+ desc = frame.label or "<main>"
469
+ else:
470
+ desc = f"{kind_label} {frame.label}"
471
+ src = ""
472
+ if frame.source:
473
+ src_name = frame.source.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
474
+ src = _c(_DIM, f" {src_name}:{frame.line}" if frame.line else f" {src_name}")
475
+ _write(f" [{depth}] {desc}{src}\n")
442
476
  _write("\n")
443
477
 
444
478
 
@@ -510,8 +544,12 @@ def _set_var(varname: str, value: str) -> None:
510
544
  if subvars is None:
511
545
  _write(" Error: substitution variables are not initialised.\n")
512
546
  return
513
- if varname.startswith("~") and _state.commandliststack:
514
- _state.commandliststack[-1].localvars.add_substitution(varname, value)
547
+ if varname.startswith("~"):
548
+ localvars = _state.current_localvars()
549
+ if localvars is not None:
550
+ localvars.add_substitution(varname, value)
551
+ else:
552
+ subvars.add_substitution(varname, value)
515
553
  else:
516
554
  subvars.add_substitution(varname, value)
517
555
  _write(f" {_c(_CYAN, varname)} {_c(_DIM, '=')} {value}\n")
execsql/exceptions.py CHANGED
@@ -1,12 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  """
4
- Custom exception hierarchy for execsql.
4
+ Custom exception hierarchy for execsql (internal use).
5
5
 
6
- All domain-specific exceptions are defined here so that callers can import
7
- from a single location. Notable exceptions:
6
+ All domain-specific exceptions are defined here so that internal callers
7
+ can import from a single location. Notable exceptions:
8
8
 
9
- - :class:`ExecSqlError` — common base for all single-message execsql exceptions.
9
+ - :class:`ExecSqlError` — common base for all internal execsql exceptions.
10
10
  - :class:`ConfigError` — invalid or missing ``execsql.conf`` values.
11
11
  - :class:`ErrInfo` — rich exception carrying type, command text, exception
12
12
  message, and script location; used as both a raised exception and an error
@@ -19,6 +19,15 @@ from a single location. Notable exceptions:
19
19
  — spreadsheet I/O failures.
20
20
  - :class:`ConsoleUIError` — GUI console errors.
21
21
  - :class:`CondParserError` / :class:`NumericParserError` — parser failures.
22
+
23
+ Note:
24
+ The ``execsql`` top-level package re-exports a *separate*
25
+ :class:`execsql.api.ExecSqlError` for library-API consumers. That
26
+ class is unrelated to this internal hierarchy; it is raised only by
27
+ :meth:`execsql.api.ScriptResult.raise_on_error`. Library callers
28
+ using ``from execsql import ExecSqlError`` get the public one;
29
+ internal modules using ``from execsql.exceptions import ExecSqlError``
30
+ get the hierarchy base defined here.
22
31
  """
23
32
 
24
33
  __all__ = [
@@ -173,6 +182,8 @@ class ErrInfo(ExecSqlError):
173
182
 
174
183
 
175
184
  class DataTypeError(ExecSqlError):
185
+ """Raised when a value cannot be cast to or rendered as a given DataType."""
186
+
176
187
  def __init__(self, data_type_name: str, error_msg: str) -> None:
177
188
  self.data_type_name = data_type_name or "Unspecified data type"
178
189
  self.error_msg = error_msg or "Unspecified error"
@@ -186,6 +197,8 @@ class DataTypeError(ExecSqlError):
186
197
 
187
198
 
188
199
  class DbTypeError(ExecSqlError):
200
+ """Raised when a DataType has no DBMS-specific mapping for the active database."""
201
+
189
202
  def __init__(self, dbms_id: str, data_type: object, error_msg: str) -> None:
190
203
  self.dbms_id = dbms_id
191
204
  self.data_type = data_type
@@ -211,6 +224,8 @@ class DataTableError(ExecSqlError):
211
224
 
212
225
 
213
226
  class DatabaseNotImplementedError(ExecSqlError):
227
+ """Raised when a Database subclass does not implement a required method for the current DBMS."""
228
+
214
229
  def __init__(self, db_name: str, method: str) -> None:
215
230
  self.db_name = db_name
216
231
  self.method = method
execsql/exporters/base.py CHANGED
@@ -147,8 +147,9 @@ class WriteSpec:
147
147
  if self.repeatable or not self.written:
148
148
  self.written = True
149
149
  msg = self.msg
150
- if _state.commandliststack:
151
- msg, _ = _state.commandliststack[-1].localvars.substitute_all(msg)
150
+ localvars = _state.current_localvars()
151
+ if localvars is not None:
152
+ msg, _ = localvars.substitute_all(msg)
152
153
  msg, _ = subvars.substitute_all(msg)
153
154
  if self.outfile:
154
155
  from execsql.utils.fileio import EncodedFile
@@ -8,9 +8,8 @@ Provides:
8
8
  - :class:`LineDelimiter` — configurable line-ending constant.
9
9
  - :class:`DelimitedWriter` / :class:`CsvWriter` — low-level writers used
10
10
  by the export logic.
11
- - :class:`CsvFile` — full delimited-file reader/writer (≈622 lines in the
12
- original monolith) supporting custom delimiters, quoting, encoding, and
13
- ZIP output.
11
+ - :class:`CsvFile` — full delimited-file reader/writer supporting custom
12
+ delimiters, quoting, encoding, and ZIP output.
14
13
  - :func:`write_delimited_file` — writes a query result set to a
15
14
  CSV/TSV/delimited text file.
16
15
  """
@@ -5,9 +5,9 @@ from execsql.exceptions import ErrInfo
5
5
  Apache Feather and HDF5 export for execsql.
6
6
 
7
7
  Provides :func:`write_query_to_feather` (Apache Arrow Feather v2 format
8
- via ``pyarrow``) and :func:`write_query_to_hdf5` (HDF5 via ``pandas``
9
- and ``tables``). Used by ``EXPORT … FORMAT feather`` and
10
- ``FORMAT hdf5``. Both packages are optional dependencies.
8
+ via ``polars``) and :func:`write_query_to_hdf5` (HDF5 via the ``tables``
9
+ library). Used by ``EXPORT … FORMAT feather`` and ``FORMAT hdf5``.
10
+ Both back-ends are optional; install via ``execsql2[formats]``.
11
11
  """
12
12
 
13
13
  from typing import Any
execsql/exporters/ods.py CHANGED
@@ -6,7 +6,7 @@ ODS (OpenDocument Spreadsheet) export for execsql.
6
6
  Provides :func:`write_query_to_ods` (single-sheet export),
7
7
  :func:`write_queries_to_ods` (multi-sheet export), and :class:`OdsFile`
8
8
  (wrapper around ``odfpy`` for writing ``.ods`` files). Requires the
9
- ``odfpy`` package (``execsql2[ods]``).
9
+ ``odfpy`` package (``execsql2[formats]``).
10
10
  """
11
11
 
12
12
  import datetime
execsql/exporters/xls.py CHANGED
@@ -2,11 +2,19 @@ from __future__ import annotations
2
2
  from execsql.types import DT_TimestampTZ
3
3
 
4
4
  """
5
- XLS and XLSX spreadsheet export for execsql.
5
+ Spreadsheet file wrappers used by the EXPORT and IMPORT metacommands.
6
6
 
7
- Provides :class:`XlsFile` (writes ``.xls`` via ``xlwt``) and
8
- :class:`XlsxFile` (writes ``.xlsx`` via ``openpyxl``), both used by the
9
- EXPORT metacommand. Requires the ``execsql2[excel]`` extras.
7
+ Despite living in the ``exporters`` package, both classes here are
8
+ mixed-use:
9
+
10
+ - :class:`XlsFile` — **read-only** wrapper around ``xlrd`` for importing
11
+ legacy ``.xls`` spreadsheets. (Writing ``.xls`` is not supported; new
12
+ exports should target ``.xlsx``.)
13
+ - :class:`XlsxFile` — read/write wrapper around ``openpyxl`` for
14
+ ``.xlsx`` spreadsheets.
15
+
16
+ Both back-ends are optional dependencies; install via
17
+ ``execsql2[formats]``.
10
18
  """
11
19
 
12
20
  import datetime
execsql/exporters/xlsx.py CHANGED
@@ -5,7 +5,7 @@ XLSX (Excel Open XML) export for execsql.
5
5
 
6
6
  Provides :func:`write_query_to_xlsx` (single-sheet export) and
7
7
  :func:`write_queries_to_xlsx` (multi-sheet export). Requires the
8
- ``openpyxl`` package (``execsql2[excel]``).
8
+ ``openpyxl`` package (``execsql2[formats]``).
9
9
  """
10
10
 
11
11
  import datetime