execsql2 2.4.5__py3-none-any.whl → 2.6.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 (62) hide show
  1. execsql/cli/__init__.py +14 -0
  2. execsql/cli/dsn.py +2 -0
  3. execsql/cli/help.py +2 -0
  4. execsql/cli/run.py +4 -2
  5. execsql/constants.py +11 -0
  6. execsql/db/access.py +20 -0
  7. execsql/db/base.py +4 -0
  8. execsql/db/dsn.py +11 -8
  9. execsql/db/duckdb.py +12 -8
  10. execsql/db/firebird.py +17 -8
  11. execsql/db/mysql.py +13 -8
  12. execsql/db/oracle.py +22 -8
  13. execsql/db/postgres.py +21 -9
  14. execsql/db/sqlite.py +18 -8
  15. execsql/db/sqlserver.py +14 -8
  16. execsql/exporters/__init__.py +6 -1
  17. execsql/exporters/base.py +2 -0
  18. execsql/exporters/delimited.py +10 -0
  19. execsql/exporters/protocol.py +128 -0
  20. execsql/exporters/xls.py +8 -0
  21. execsql/format.py +3 -1
  22. execsql/gui/__init__.py +2 -0
  23. execsql/gui/base.py +2 -0
  24. execsql/gui/console.py +2 -0
  25. execsql/gui/desktop.py +1 -0
  26. execsql/gui/tui.py +134 -0
  27. execsql/importers/base.py +1 -0
  28. execsql/importers/csv.py +2 -0
  29. execsql/importers/feather.py +2 -0
  30. execsql/importers/ods.py +1 -0
  31. execsql/importers/xls.py +1 -0
  32. execsql/metacommands/__init__.py +386 -180
  33. execsql/metacommands/dispatch.py +2 -0
  34. execsql/metacommands/io.py +41 -0
  35. execsql/models.py +17 -0
  36. execsql/parser.py +41 -0
  37. execsql/script/control.py +2 -0
  38. execsql/script/engine.py +19 -0
  39. execsql/script/variables.py +9 -5
  40. execsql/state.py +312 -199
  41. execsql/types.py +46 -0
  42. {execsql2-2.4.5.dist-info → execsql2-2.6.0.dist-info}/METADATA +2 -2
  43. {execsql2-2.4.5.dist-info → execsql2-2.6.0.dist-info}/RECORD +62 -61
  44. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/README.md +0 -0
  45. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  46. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  47. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/execsql.conf +0 -0
  48. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  49. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  50. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  51. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  52. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  53. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  54. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  55. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/script_template.sql +0 -0
  56. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  57. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  58. {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  59. {execsql2-2.4.5.dist-info → execsql2-2.6.0.dist-info}/WHEEL +0 -0
  60. {execsql2-2.4.5.dist-info → execsql2-2.6.0.dist-info}/entry_points.txt +0 -0
  61. {execsql2-2.4.5.dist-info → execsql2-2.6.0.dist-info}/licenses/LICENSE.txt +0 -0
  62. {execsql2-2.4.5.dist-info → execsql2-2.6.0.dist-info}/licenses/NOTICE +0 -0
execsql/db/sqlite.py CHANGED
@@ -50,6 +50,7 @@ class SQLiteDatabase(Database):
50
50
  return f"SQLiteDatabase({self.db_name!r})"
51
51
 
52
52
  def open_db(self) -> None:
53
+ """Open a connection to the SQLite database file."""
53
54
  import sqlite3
54
55
 
55
56
  if self.conn is None:
@@ -67,18 +68,20 @@ class SQLiteDatabase(Database):
67
68
  self.encoding = pragma_data[0][0]
68
69
 
69
70
  def exec_cmd(self, querycommand: str) -> None:
71
+ """Execute a query command as a view selection, since SQLite lacks stored procedures."""
70
72
  # SQLite does not support stored functions or views, so the querycommand
71
73
  # is treated as (and therefore must be) a view.
72
- curs = self.cursor()
73
- cmd = f"select * from {querycommand};"
74
- try:
75
- curs.execute(cmd.encode(self.encoding))
76
- _state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
77
- except Exception:
78
- self.rollback()
79
- raise
74
+ with self._cursor() as curs:
75
+ cmd = f"select * from {querycommand};"
76
+ try:
77
+ curs.execute(cmd.encode(self.encoding))
78
+ _state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
79
+ except Exception:
80
+ self.rollback()
81
+ raise
80
82
 
81
83
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
84
+ """Return True if the named table exists in the SQLite database."""
82
85
  curs = self.cursor()
83
86
  sql = "select name from sqlite_master where type='table' and name=?;"
84
87
  try:
@@ -102,10 +105,12 @@ class SQLiteDatabase(Database):
102
105
  column_name: str,
103
106
  schema_name: str | None = None,
104
107
  ) -> bool:
108
+ """Return True if the named column exists in the given SQLite table."""
105
109
  cols = self.table_columns(table_name, schema_name)
106
110
  return column_name in cols
107
111
 
108
112
  def table_columns(self, table_name: str, schema_name: str | None = None) -> list[str]:
113
+ """Return a list of column names for the given SQLite table."""
109
114
  curs = self.cursor()
110
115
  quoted_tbl = self.quote_identifier(table_name)
111
116
  sql = f"select * from {quoted_tbl} where 1=0;"
@@ -124,6 +129,7 @@ class SQLiteDatabase(Database):
124
129
  return [d[0] for d in curs.description]
125
130
 
126
131
  def view_exists(self, view_name: str) -> bool:
132
+ """Return True if the named view exists in the SQLite database."""
127
133
  curs = self.cursor()
128
134
  sql = "select name from sqlite_master where type='view' and name=?;"
129
135
  try:
@@ -142,9 +148,11 @@ class SQLiteDatabase(Database):
142
148
  return len(rows) > 0
143
149
 
144
150
  def schema_exists(self, schema_name: str) -> bool:
151
+ """Return False; SQLite does not support schemas."""
145
152
  return False
146
153
 
147
154
  def drop_table(self, tablename: str) -> None:
155
+ """Drop the named table from the SQLite database if it exists."""
148
156
  tablename = self.type.quoted(tablename)
149
157
  self.execute(f"drop table if exists {tablename};")
150
158
 
@@ -156,6 +164,7 @@ class SQLiteDatabase(Database):
156
164
  column_list: list[str],
157
165
  tablespec_src: Any,
158
166
  ) -> None:
167
+ """Populate a SQLite table from a row source generator."""
159
168
  # The rowsource argument must be a generator yielding a list of values for the columns of the table.
160
169
  # The column_list argument must an iterable containing column names in the same order as produced by the rowsource.
161
170
  sq_name = self.schema_qualified_table_name(None, table_name)
@@ -233,6 +242,7 @@ class SQLiteDatabase(Database):
233
242
  column_name: str,
234
243
  file_name: str,
235
244
  ) -> None:
245
+ """Import an entire binary file into a single column of a table."""
236
246
  import sqlite3
237
247
 
238
248
  with open(file_name, "rb") as f:
execsql/db/sqlserver.py CHANGED
@@ -58,6 +58,7 @@ class SqlServerDatabase(Database):
58
58
  )
59
59
 
60
60
  def open_db(self) -> None:
61
+ """Open a connection to the SQL Server database via pyodbc."""
61
62
  import pyodbc
62
63
 
63
64
  if self.conn is None:
@@ -138,17 +139,19 @@ class SqlServerDatabase(Database):
138
139
  self.conn.commit()
139
140
 
140
141
  def exec_cmd(self, querycommand: str) -> None:
142
+ """Execute a stored procedure by name."""
141
143
  # The querycommand must be a stored procedure
142
- curs = self.cursor()
143
- cmd = f"execute {querycommand};"
144
- try:
145
- curs.execute(cmd.encode(self.encoding))
146
- _state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
147
- except Exception:
148
- self.rollback()
149
- raise
144
+ with self._cursor() as curs:
145
+ cmd = f"execute {querycommand};"
146
+ try:
147
+ curs.execute(cmd.encode(self.encoding))
148
+ _state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
149
+ except Exception:
150
+ self.rollback()
151
+ raise
150
152
 
151
153
  def schema_exists(self, schema_name: str) -> bool:
154
+ """Return True if the named schema exists in the SQL Server database."""
152
155
  curs = self.cursor()
153
156
  curs.execute("select * from sys.schemas where name = ?;", (schema_name,))
154
157
  rows = curs.fetchall()
@@ -156,6 +159,7 @@ class SqlServerDatabase(Database):
156
159
  return len(rows) > 0
157
160
 
158
161
  def role_exists(self, rolename: str) -> bool:
162
+ """Return True if the named role or principal exists in the SQL Server database."""
159
163
  curs = self.cursor()
160
164
  curs.execute(
161
165
  "select name from sys.database_principals where type in ('R', 'S') and name = ?;",
@@ -166,6 +170,7 @@ class SqlServerDatabase(Database):
166
170
  return len(rows) > 0
167
171
 
168
172
  def drop_table(self, tablename: str) -> None:
173
+ """Drop the named table from the SQL Server database."""
169
174
  # SQL Server and Firebird will throw an error if there are foreign keys to the table.
170
175
  tablename = self.type.quoted(tablename)
171
176
  self.execute(f"drop table {tablename};")
@@ -177,6 +182,7 @@ class SqlServerDatabase(Database):
177
182
  column_name: str,
178
183
  file_name: str,
179
184
  ) -> None:
185
+ """Import an entire binary file into a single column of a table."""
180
186
  import pyodbc
181
187
 
182
188
  with open(file_name, "rb") as f:
@@ -10,5 +10,10 @@ 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``, ``parquet``, ``duckdb``, ``sqlite``.
13
+ ``templates``, ``feather``, ``parquet``, ``duckdb``, ``sqlite``,
14
+ ``protocol``.
14
15
  """
16
+
17
+ from execsql.exporters.protocol import QueryExporter, RowsetExporter
18
+
19
+ __all__ = ["QueryExporter", "RowsetExporter"]
execsql/exporters/base.py CHANGED
@@ -39,6 +39,7 @@ class ExportRecord:
39
39
  zipfile: str | None = None,
40
40
  description: str | None = None,
41
41
  ) -> None:
42
+ """Record export details for the given query name and output file."""
42
43
  self.exported = False
43
44
  # Record is a list of: table_or_query_name, filename, zipfilename, file_path, user_description, script_name,
44
45
  # script_path, script_line_no, script_datetime, database_name, database_server, user_name.
@@ -87,6 +88,7 @@ class ExportMetadata:
87
88
  ]
88
89
 
89
90
  def __init__(self) -> None:
91
+ """Initialise an empty record collection."""
90
92
  self.recordlist: list[ExportRecord] = []
91
93
 
92
94
  def add(self, exp_record: ExportRecord) -> None:
@@ -118,6 +118,7 @@ class CsvWriter:
118
118
  escchar: str | None,
119
119
  append: bool = False,
120
120
  ) -> None:
121
+ """Open a file and prepare a delimited writer with the given format settings."""
121
122
  mode = "wt" if not append else "at"
122
123
  if filename.lower() == "stdout":
123
124
  self.output = sys.stdout
@@ -184,9 +185,12 @@ class CsvFile(EncodedFile):
184
185
  self.lineformat_set = True
185
186
 
186
187
  class CsvLine:
188
+ """Represent a single CSV line for delimiter diagnosis and parsing."""
189
+
187
190
  escchar = "\\"
188
191
 
189
192
  def __init__(self, line_text: str) -> None:
193
+ """Store the raw line text and prepare delimiter-count storage."""
190
194
  self.text = line_text
191
195
  self.delim_counts = {}
192
196
  self.item_errors = [] # A list of error messages.
@@ -202,6 +206,7 @@ class CsvFile(EncodedFile):
202
206
  )
203
207
 
204
208
  def count_delim(self, delim: str) -> None:
209
+ """Count occurrences of the given delimiter in the line text."""
205
210
  # If the delimiter is a space, consider multiple spaces to be equivalent
206
211
  # to a single delimiter, split on the space(s), and consider the delimiter
207
212
  # count to be one fewer than the items returned.
@@ -211,6 +216,7 @@ class CsvFile(EncodedFile):
211
216
  self.delim_counts[delim] = self.text.count(delim)
212
217
 
213
218
  def delim_count(self, delim: str) -> int:
219
+ """Return the previously counted occurrence total for the given delimiter."""
214
220
  return self.delim_counts[delim]
215
221
 
216
222
  def _well_quoted(self, element: str, qchar: str):
@@ -239,9 +245,11 @@ class CsvFile(EncodedFile):
239
245
  return (False, True, False)
240
246
 
241
247
  def record_format_error(self, pos_no: int, errmsg: str) -> None:
248
+ """Append a parse error message annotated with its character position."""
242
249
  self.item_errors.append(f"{errmsg} in position {pos_no}.")
243
250
 
244
251
  def items(self, delim: str | None, qchar: str | None) -> Any:
252
+ """Parse the line into a list of items, splitting on unquoted delimiters."""
245
253
  # Parses the line into a list of items, breaking it at delimiters that are not
246
254
  # within quoted stretches.
247
255
  self.item_errors = []
@@ -348,6 +356,7 @@ class CsvFile(EncodedFile):
348
356
  return elements
349
357
 
350
358
  def well_quoted_line(self, delim: str | None, qchar: str | None):
359
+ """Return a tuple of (all-well-quoted, quote-usage-count, uses-escape-char)."""
351
360
  # Returns a tuple of boolean, int, and boolean
352
361
  wq = [self._well_quoted(el, qchar) for el in self.items(delim, qchar)]
353
362
  return (all(b[0] for b in wq), sum([b[1] for b in wq]), any(b[2] for b in wq))
@@ -358,6 +367,7 @@ class CsvFile(EncodedFile):
358
367
  possible_delimiters: list[str] | None = None,
359
368
  possible_quotechars: list[str] | None = None,
360
369
  ):
370
+ """Analyse a line stream and return the detected (delimiter, quote char, escape char) tuple."""
361
371
  # Returns a tuple consisting of the delimiter, quote character, and escape
362
372
  # character for quote characters within elements of a line. All may be None.
363
373
  conf = _state.conf
@@ -0,0 +1,128 @@
1
+ """
2
+ Exporter Protocol definitions for execsql.
3
+
4
+ Defines two ``@runtime_checkable`` Protocols that describe the two main
5
+ exporter calling conventions used throughout the ``exporters`` package:
6
+
7
+ - :class:`QueryExporter` — functions that accept a SQL SELECT statement
8
+ and a database connection, execute the query, and write the results.
9
+ - :class:`RowsetExporter` — functions that accept pre-fetched column
10
+ headers and rows and write them to an output destination.
11
+
12
+ These Protocols capture the *most common* parameter signature. Several
13
+ concrete exporters have additional keyword arguments (``tablename``,
14
+ ``sheetname``, ``template_file``, ``write_types``, ``and_val``, etc.)
15
+ that extend the base contract. Such functions remain structurally
16
+ compatible: they satisfy the Protocol when called with the base
17
+ arguments, and the extra parameters have defaults or are supplied by
18
+ the dispatch layer.
19
+
20
+ .. note::
21
+
22
+ The ``io_export.py`` dispatch chain is **not** refactored here.
23
+ These Protocols exist as a documentation and static-type-checking
24
+ layer that formalises the implicit interface already present in the
25
+ codebase.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any, Protocol, runtime_checkable
31
+
32
+ __all__ = ["QueryExporter", "RowsetExporter"]
33
+
34
+
35
+ @runtime_checkable
36
+ class QueryExporter(Protocol):
37
+ """Protocol for exporters that execute a query and write output.
38
+
39
+ The canonical signature is::
40
+
41
+ def __call__(
42
+ self,
43
+ select_stmt: str,
44
+ db: Any,
45
+ outfile: str,
46
+ append: bool = False,
47
+ desc: str | None = None,
48
+ zipfile: str | None = None,
49
+ ) -> None: ...
50
+
51
+ Conforming functions
52
+ --------------------
53
+ - ``write_query_to_json``
54
+ - ``write_query_to_html``
55
+ - ``write_query_to_cgi_html``
56
+ - ``write_query_to_latex``
57
+ - ``write_query_to_values``
58
+ - ``prettyprint_query`` (adds ``and_val``)
59
+
60
+ Functions with extended signatures
61
+ ----------------------------------
62
+ - ``write_query_to_xml`` (adds ``tablename``)
63
+ - ``write_query_to_json_ts`` (adds ``write_types``)
64
+ - ``write_query_to_ods`` (adds ``sheetname``, no ``zipfile``)
65
+ - ``write_query_to_hdf5`` (adds ``table_name``, no ``zipfile``)
66
+ - ``write_query_to_duckdb`` (adds ``tablename``, no ``desc``/``zipfile``)
67
+ - ``write_query_to_sqlite`` (adds ``tablename``, no ``desc``/``zipfile``)
68
+ - ``report_query`` (adds ``template_file``)
69
+ - ``write_queries_to_ods`` (``table_list`` instead of ``select_stmt``)
70
+ """
71
+
72
+ def __call__(
73
+ self,
74
+ select_stmt: str,
75
+ db: Any,
76
+ outfile: str,
77
+ append: bool = False,
78
+ desc: str | None = None,
79
+ zipfile: str | None = None,
80
+ ) -> None: ...
81
+
82
+
83
+ @runtime_checkable
84
+ class RowsetExporter(Protocol):
85
+ """Protocol for exporters that accept pre-fetched headers and rows.
86
+
87
+ The canonical signature is::
88
+
89
+ def __call__(
90
+ self,
91
+ outfile: str,
92
+ hdrs: list[str],
93
+ rows: Any,
94
+ append: bool = False,
95
+ desc: str | None = None,
96
+ zipfile: str | None = None,
97
+ ) -> None: ...
98
+
99
+ Conforming functions
100
+ --------------------
101
+ - ``export_values``
102
+
103
+ Functions with extended signatures
104
+ ----------------------------------
105
+ - ``export_html`` (adds ``querytext``)
106
+ - ``export_cgi_html`` (adds ``querytext``)
107
+ - ``export_latex`` (adds ``querytext``)
108
+ - ``export_ods`` (adds ``querytext``, ``sheetname``, no ``zipfile``)
109
+ - ``prettyprint_rowset`` (uses ``colhdrs``/``output_dest``, adds ``and_val``)
110
+ - ``export_duckdb`` (adds ``tablename``, no ``desc``/``zipfile``)
111
+ - ``export_sqlite`` (adds ``tablename``, no ``desc``/``zipfile``)
112
+ - ``write_query_to_feather`` (minimal: ``outfile``, ``headers``, ``rows`` only)
113
+ - ``write_query_to_parquet`` (minimal: ``outfile``, ``headers``, ``rows`` only)
114
+ - ``write_query_raw`` (uses ``rowsource`` + ``db_encoding``)
115
+ - ``write_query_b64`` (uses ``rowsource`` only)
116
+ - ``write_delimited_file`` (uses ``filefmt``, ``column_headers``, ``rowsource``,
117
+ ``file_encoding``)
118
+ """
119
+
120
+ def __call__(
121
+ self,
122
+ outfile: str,
123
+ hdrs: list[str],
124
+ rows: Any,
125
+ append: bool = False,
126
+ desc: str | None = None,
127
+ zipfile: str | None = None,
128
+ ) -> None: ...
execsql/exporters/xls.py CHANGED
@@ -26,10 +26,14 @@ class XlsFile:
26
26
  return "XlsFile()"
27
27
 
28
28
  class XlsLog:
29
+ """Capture xlrd warning messages as a list of strings."""
30
+
29
31
  def __init__(self) -> None:
32
+ """Initialise an empty message list."""
30
33
  self.log_msgs = []
31
34
 
32
35
  def write(self, msg: str) -> None:
36
+ """Append a log message to the internal list."""
33
37
  self.log_msgs.append(msg)
34
38
 
35
39
  def __init__(self) -> None:
@@ -161,10 +165,14 @@ class XlsxFile:
161
165
  return "XlsxFile()"
162
166
 
163
167
  class XlsxLog:
168
+ """Capture openpyxl warning messages as a list of strings."""
169
+
164
170
  def __init__(self) -> None:
171
+ """Initialise an empty message list."""
165
172
  self.log_msgs = []
166
173
 
167
174
  def write(self, msg: str) -> None:
175
+ """Append a log message to the internal list."""
168
176
  self.log_msgs.append(msg)
169
177
 
170
178
  def __init__(self) -> None:
execsql/format.py CHANGED
@@ -19,6 +19,8 @@ from pathlib import Path
19
19
  import sqlglot
20
20
  import sqlglot.errors
21
21
 
22
+ __all__ = ["collect_paths", "format_file", "main", "parse_keyword"]
23
+
22
24
  # ---------------------------------------------------------------------------
23
25
  # Constants
24
26
  # ---------------------------------------------------------------------------
@@ -345,7 +347,7 @@ def main() -> None:
345
347
  source = path.read_text(encoding="utf-8")
346
348
  except OSError as exc:
347
349
  _err_console.print(f"[bold red]Error:[/bold red] reading {path}: {exc}")
348
- raise typer.Exit(code=1)
350
+ raise typer.Exit(code=1) from None
349
351
 
350
352
  formatted = format_file(source, indent=indent, use_sql=use_sql)
351
353
 
execsql/gui/__init__.py CHANGED
@@ -21,6 +21,8 @@ from typing import TYPE_CHECKING, Any
21
21
  if TYPE_CHECKING:
22
22
  from execsql.gui.base import GuiBackend
23
23
 
24
+ __all__ = ["get_backend", "gui_manager_loop"]
25
+
24
26
 
25
27
  def get_backend(framework: str = "tkinter") -> GuiBackend:
26
28
  """Return the best available backend for *framework*.
execsql/gui/base.py CHANGED
@@ -8,6 +8,8 @@ from typing import TYPE_CHECKING, Any
8
8
  if TYPE_CHECKING:
9
9
  pass
10
10
 
11
+ __all__ = ["GuiBackend"]
12
+
11
13
 
12
14
  class GuiBackend(ABC):
13
15
  """Abstract base for all GUI backends.
execsql/gui/console.py CHANGED
@@ -13,6 +13,8 @@ from typing import Any
13
13
 
14
14
  from execsql.gui.base import GuiBackend
15
15
 
16
+ __all__ = ["ConsoleBackend"]
17
+
16
18
 
17
19
  def _print_table(headers: list, rows: list, file: Any = None) -> None:
18
20
  """Print a simple ASCII table to the given file (default stderr)."""
execsql/gui/desktop.py CHANGED
@@ -27,6 +27,7 @@ except ImportError as _e:
27
27
  "tkinter is not available on this Python installation.",
28
28
  ) from _e
29
29
 
30
+ __all__ = ["TkinterBackend"]
30
31
 
31
32
  # ---------------------------------------------------------------------------
32
33
  # Helpers