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/gui/tui.py CHANGED
@@ -55,6 +55,8 @@ from textual.widgets import (
55
55
  Static,
56
56
  )
57
57
 
58
+ __all__ = ["TextualBackend"]
59
+
58
60
 
59
61
  # ---------------------------------------------------------------------------
60
62
  # Helpers
@@ -99,6 +101,10 @@ def _button_row(button_list: list) -> list[Button]:
99
101
  class _BaseDialog(ModalScreen):
100
102
  """Common infrastructure for all execsql dialog screens."""
101
103
 
104
+ BINDINGS = [
105
+ Binding("escape", "cancel", "Cancel", show=True),
106
+ ]
107
+
102
108
  DEFAULT_CSS = """
103
109
  _BaseDialog {
104
110
  align: center middle;
@@ -149,6 +155,11 @@ class _BaseDialog(ModalScreen):
149
155
  def result(self) -> dict:
150
156
  return self._result
151
157
 
158
+ def action_cancel(self) -> None:
159
+ """Dismiss the dialog as cancelled (triggered by Escape key)."""
160
+ self._result = {"button": None, "cancelled": True}
161
+ self.dismiss(self._result)
162
+
152
163
  @on(Button.Pressed, "#btn_cancel_exit")
153
164
  def _on_cancel_exit(self, event: Button.Pressed) -> None:
154
165
  """Universal Cancel handler — dismisses with cancelled=True so the sync queue can exit."""
@@ -165,6 +176,11 @@ class _BaseDialog(ModalScreen):
165
176
  class MsgScreen(_BaseDialog):
166
177
  """Simple message dialog with Continue and Cancel buttons."""
167
178
 
179
+ BINDINGS = [
180
+ *_BaseDialog.BINDINGS,
181
+ Binding("enter", "submit", "Continue", show=True),
182
+ ]
183
+
168
184
  def compose(self) -> ComposeResult:
169
185
  title = self.args.get("title", "Message")
170
186
  message = self.args.get("message", "")
@@ -174,6 +190,12 @@ class MsgScreen(_BaseDialog):
174
190
  with Horizontal(id="buttons"):
175
191
  yield Button("Cancel", id="btn_cancel_exit", variant="warning")
176
192
  yield Button("Continue", id="btn_close", variant="primary")
193
+ yield Footer()
194
+
195
+ def action_submit(self) -> None:
196
+ """Continue the dialog (triggered by Enter key)."""
197
+ self._result = {"button": 1}
198
+ self.dismiss(self._result)
177
199
 
178
200
  def on_button_pressed(self, event: Button.Pressed) -> None:
179
201
  if event.button.id == "btn_close":
@@ -189,6 +211,11 @@ class MsgScreen(_BaseDialog):
189
211
  class PauseScreen(_BaseDialog):
190
212
  """Pause dialog with optional countdown and Continue/Cancel buttons."""
191
213
 
214
+ BINDINGS = [
215
+ *_BaseDialog.BINDINGS,
216
+ Binding("enter", "submit", "Continue", show=True),
217
+ ]
218
+
192
219
  def compose(self) -> ComposeResult:
193
220
  title = self.args.get("title", "Pause")
194
221
  message = self.args.get("message", "")
@@ -198,6 +225,7 @@ class PauseScreen(_BaseDialog):
198
225
  with Horizontal(id="buttons"):
199
226
  yield Button("Cancel", id="btn_cancel_exit", variant="warning")
200
227
  yield Button("Continue", id="btn_continue", variant="primary")
228
+ yield Footer()
201
229
 
202
230
  def on_mount(self) -> None:
203
231
  countdown = self.args.get("countdown")
@@ -208,6 +236,11 @@ class PauseScreen(_BaseDialog):
208
236
  self._result = {"quit": False}
209
237
  self.dismiss(self._result)
210
238
 
239
+ def action_submit(self) -> None:
240
+ """Continue the dialog (triggered by Enter key)."""
241
+ self._result = {"quit": False}
242
+ self.dismiss(self._result)
243
+
211
244
  def on_button_pressed(self, event: Button.Pressed) -> None:
212
245
  if event.button.id == "btn_continue":
213
246
  self._result = {"quit": False}
@@ -222,6 +255,11 @@ class PauseScreen(_BaseDialog):
222
255
  class DisplayScreen(_BaseDialog):
223
256
  """Data display dialog: title, message, optional table, optional text entry, buttons."""
224
257
 
258
+ BINDINGS = [
259
+ *_BaseDialog.BINDINGS,
260
+ Binding("enter", "submit", "Submit", show=True),
261
+ ]
262
+
225
263
  def compose(self) -> ComposeResult:
226
264
  title = self.args.get("title", "")
227
265
  message = self.args.get("message", "")
@@ -249,6 +287,23 @@ class DisplayScreen(_BaseDialog):
249
287
  )
250
288
  with Horizontal(id="buttons"):
251
289
  yield from _button_row(button_list)
290
+ yield Footer()
291
+
292
+ def action_submit(self) -> None:
293
+ """Submit the first primary button value (triggered by Enter key)."""
294
+ button_list = self.args.get("button_list", [("Continue", 1)])
295
+ # Find the first non-cancel button value.
296
+ value = None
297
+ for btn in button_list:
298
+ if btn[1]:
299
+ value = btn[1]
300
+ break
301
+ if value is None:
302
+ return
303
+ text_input = self.query_one("#text_input", Input) if self.args.get("textentry") else None
304
+ return_value = text_input.value if text_input else None
305
+ self._result = {"button": value, "return_value": return_value}
306
+ self.dismiss(self._result)
252
307
 
253
308
  def on_button_pressed(self, event: Button.Pressed) -> None:
254
309
  btn_id = event.button.id
@@ -270,6 +325,11 @@ class DisplayScreen(_BaseDialog):
270
325
  class EntryFormScreen(_BaseDialog):
271
326
  """Multi-field entry form dialog."""
272
327
 
328
+ BINDINGS = [
329
+ *_BaseDialog.BINDINGS,
330
+ Binding("enter", "submit", "OK", show=True),
331
+ ]
332
+
273
333
  DEFAULT_CSS = (
274
334
  _BaseDialog.DEFAULT_CSS
275
335
  + """
@@ -335,9 +395,26 @@ class EntryFormScreen(_BaseDialog):
335
395
  with Horizontal(id="buttons"):
336
396
  yield Button("Cancel", id="btn_cancel_exit", variant="warning")
337
397
  yield Button("OK", id="btn_ok", variant="primary")
398
+ yield Footer()
338
399
 
339
400
  self._specs = specs
340
401
 
402
+ def action_submit(self) -> None:
403
+ """Submit the form (triggered by Enter key)."""
404
+ for spec in self._specs:
405
+ widget = self._inputs.get(spec.varname)
406
+ if widget is None:
407
+ continue
408
+ etype = (spec.entry_type or "text").lower()
409
+ if etype == "checkbox":
410
+ spec.value = "True" if widget.value else "False"
411
+ elif etype in ("dropdown", "select"):
412
+ spec.value = str(widget.value) if widget.value is not None else ""
413
+ else:
414
+ spec.value = widget.value
415
+ self._result = {"button": 1, "return_value": self._specs}
416
+ self.dismiss(self._result)
417
+
341
418
  def on_button_pressed(self, event: Button.Pressed) -> None:
342
419
  if event.button.id == "btn_ok":
343
420
  for spec in self._specs:
@@ -363,6 +440,11 @@ class EntryFormScreen(_BaseDialog):
363
440
  class CompareScreen(_BaseDialog):
364
441
  """Side-by-side table comparison dialog."""
365
442
 
443
+ BINDINGS = [
444
+ *_BaseDialog.BINDINGS,
445
+ Binding("enter", "submit", "Continue", show=True),
446
+ ]
447
+
366
448
  DEFAULT_CSS = (
367
449
  _BaseDialog.DEFAULT_CSS
368
450
  + """
@@ -410,6 +492,7 @@ class CompareScreen(_BaseDialog):
410
492
  yield _make_table_widget("table2", headers2, rows2)
411
493
  with Horizontal(id="buttons"):
412
494
  yield from _button_row(button_list)
495
+ yield Footer()
413
496
 
414
497
  def on_mount(self) -> None:
415
498
  keylist = [str(k) for k in self.args.get("keylist", [])]
@@ -459,6 +542,15 @@ class CompareScreen(_BaseDialog):
459
542
  finally:
460
543
  self._syncing = False
461
544
 
545
+ def action_submit(self) -> None:
546
+ """Submit the first primary button value (triggered by Enter key)."""
547
+ button_list = self.args.get("button_list", [("Continue", 1)])
548
+ for btn in button_list:
549
+ if btn[1]:
550
+ self._result = {"button": btn[1]}
551
+ self.dismiss(self._result)
552
+ return
553
+
462
554
  def on_button_pressed(self, event: Button.Pressed) -> None:
463
555
  btn_id = event.button.id
464
556
  if btn_id and btn_id.startswith("btn_") and btn_id[4:].isdigit():
@@ -476,6 +568,11 @@ class CompareScreen(_BaseDialog):
476
568
  class SelectRowsScreen(_BaseDialog):
477
569
  """Row selection dialog: pick rows from source table and add to target."""
478
570
 
571
+ BINDINGS = [
572
+ *_BaseDialog.BINDINGS,
573
+ Binding("enter", "submit", "Continue", show=True),
574
+ ]
575
+
479
576
  DEFAULT_CSS = (
480
577
  _BaseDialog.DEFAULT_CSS
481
578
  + """
@@ -514,6 +611,7 @@ class SelectRowsScreen(_BaseDialog):
514
611
  yield _make_table_widget("dest_table", headers2, rows2)
515
612
  with Horizontal(id="buttons"):
516
613
  yield from _button_row(button_list)
614
+ yield Footer()
517
615
 
518
616
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
519
617
  if event.data_table.id == "source_table":
@@ -523,6 +621,15 @@ class SelectRowsScreen(_BaseDialog):
523
621
  values = [src.get_cell(row_key, col) for col in src.columns]
524
622
  dest.add_row(*values)
525
623
 
624
+ def action_submit(self) -> None:
625
+ """Submit the first primary button value (triggered by Enter key)."""
626
+ button_list = self.args.get("button_list", [("Continue", 1)])
627
+ for btn in button_list:
628
+ if btn[1]:
629
+ self._result = {"button": btn[1]}
630
+ self.dismiss(self._result)
631
+ return
632
+
526
633
  def on_button_pressed(self, event: Button.Pressed) -> None:
527
634
  btn_id = event.button.id
528
635
  if btn_id and btn_id.startswith("btn_") and btn_id[4:].isdigit():
@@ -586,6 +693,10 @@ class SelectSubScreen(_BaseDialog):
586
693
  class ActionScreen(_BaseDialog):
587
694
  """Action button grid dialog."""
588
695
 
696
+ BINDINGS = [
697
+ *_BaseDialog.BINDINGS,
698
+ ]
699
+
589
700
  def compose(self) -> ComposeResult:
590
701
  title = self.args.get("title", "Actions")
591
702
  message = self.args.get("message", "")
@@ -607,6 +718,7 @@ class ActionScreen(_BaseDialog):
607
718
  yield Button(f"{spec.label} — {spec.prompt}", id=f"action_{i}", variant="primary")
608
719
  if include_continue:
609
720
  yield Button("Continue", id="action_continue", variant="default")
721
+ yield Footer()
610
722
 
611
723
  self._button_specs = button_specs
612
724
 
@@ -1043,6 +1155,7 @@ class ConsoleApp(App):
1043
1155
  self._script_thread: threading.Thread | None = None
1044
1156
  self._script_exception: BaseException | None = None
1045
1157
  self._screen_map: dict | None = None
1158
+ self._console_lines: list[str] = []
1046
1159
 
1047
1160
  def compose(self) -> ComposeResult:
1048
1161
  yield Header(show_clock=False)
@@ -1116,11 +1229,19 @@ class ConsoleApp(App):
1116
1229
  self.call_from_thread(self._append_console, text)
1117
1230
 
1118
1231
  def _append_console(self, text: str) -> None:
1232
+ self._console_lines.append(text)
1119
1233
  try:
1120
1234
  self.query_one("#console_log", RichLog).write(text)
1121
1235
  except Exception:
1122
1236
  pass # Widget may not be mounted yet.
1123
1237
 
1238
+ def save(self, outfile: str, append: bool = False) -> None:
1239
+ """Save the console text contents to *outfile*."""
1240
+ mode = "a" if append else "w"
1241
+ with open(outfile, mode, encoding="utf-8") as fh:
1242
+ for line in self._console_lines:
1243
+ fh.write(line if line.endswith("\n") else line + "\n")
1244
+
1124
1245
  def set_status(self, msg: str) -> None:
1125
1246
  """Thread-safe status bar update."""
1126
1247
  self.call_from_thread(self._update_status, msg)
@@ -1197,6 +1318,19 @@ class TextualBackend(GuiBackend):
1197
1318
  pct = (num / total * 100.0) if total else num
1198
1319
  self._console_app.set_progress(pct)
1199
1320
 
1321
+ def console_save(self, outfile: str, append: bool = False) -> None:
1322
+ """Save console text contents to *outfile*."""
1323
+ if self._console_app is not None:
1324
+ self._console_app.save(outfile, append)
1325
+
1326
+ def console_hide(self) -> None:
1327
+ """Hide the console (minimize) — Textual has no window-level hide,
1328
+ so this is a no-op in a terminal environment."""
1329
+
1330
+ def console_show(self) -> None:
1331
+ """Show the console — Textual has no window-level show,
1332
+ so this is a no-op in a terminal environment."""
1333
+
1200
1334
  def console_wait_user(self, message: str = "") -> None:
1201
1335
  # ConsoleApp exits when the script is done; nothing extra needed here.
1202
1336
  pass
execsql/importers/base.py CHANGED
@@ -28,6 +28,7 @@ def import_data_table(
28
28
  hdrs: list[str],
29
29
  data: list[Any],
30
30
  ) -> None:
31
+ """Create (if needed) and populate a database table from in-memory row data."""
31
32
  from execsql.utils.errors import exception_desc
32
33
 
33
34
  conf = _state.conf
execsql/importers/csv.py CHANGED
@@ -32,6 +32,7 @@ def importtable(
32
32
  encoding: str | None = None,
33
33
  junk_header_lines: int = 0,
34
34
  ) -> None:
35
+ """Import a delimited text file into a new or existing database table."""
35
36
  from execsql.utils.errors import exception_desc
36
37
 
37
38
  conf = _state.conf
@@ -104,6 +105,7 @@ def importfile(
104
105
  columname: str,
105
106
  filename: str,
106
107
  ) -> None:
108
+ """Import an entire file as a single value into a table column."""
107
109
  from execsql.utils.errors import exception_desc
108
110
 
109
111
  if schemaname is not None:
@@ -24,6 +24,7 @@ def import_feather(
24
24
  filename: str,
25
25
  is_new: Any,
26
26
  ) -> None:
27
+ """Import an Apache Arrow Feather (IPC) file into a database table."""
27
28
  from execsql.utils.errors import exception_desc
28
29
 
29
30
  try:
@@ -47,6 +48,7 @@ def import_parquet(
47
48
  filename: str,
48
49
  is_new: Any,
49
50
  ) -> None:
51
+ """Import a Parquet file into a database table."""
50
52
  from execsql.utils.errors import exception_desc
51
53
 
52
54
  try:
execsql/importers/ods.py CHANGED
@@ -78,5 +78,6 @@ def importods(
78
78
  sheetname: str,
79
79
  junk_header_rows: int,
80
80
  ) -> None:
81
+ """Import an ODS worksheet into a new or existing database table."""
81
82
  hdrs, data = ods_data(filename, sheetname, junk_header_rows)
82
83
  import_data_table(db, schemaname, tablename, is_new, hdrs, data)
execsql/importers/xls.py CHANGED
@@ -99,5 +99,6 @@ def importxls(
99
99
  junk_header_rows: int,
100
100
  encoding: str | None,
101
101
  ) -> None:
102
+ """Import an XLS or XLSX worksheet into a new or existing database table."""
102
103
  hdrs, data = xls_data(filename, sheetname, junk_header_rows, encoding)
103
104
  import_data_table(db, schemaname, tablename, is_new, hdrs, data)