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.
- execsql/cli/__init__.py +14 -0
- execsql/cli/dsn.py +2 -0
- execsql/cli/help.py +2 -0
- execsql/cli/run.py +4 -2
- execsql/constants.py +11 -0
- execsql/db/access.py +20 -0
- execsql/db/base.py +4 -0
- execsql/db/dsn.py +11 -8
- execsql/db/duckdb.py +12 -8
- execsql/db/firebird.py +17 -8
- execsql/db/mysql.py +13 -8
- execsql/db/oracle.py +22 -8
- execsql/db/postgres.py +21 -9
- execsql/db/sqlite.py +18 -8
- execsql/db/sqlserver.py +14 -8
- execsql/exporters/__init__.py +6 -1
- execsql/exporters/base.py +2 -0
- execsql/exporters/delimited.py +10 -0
- execsql/exporters/protocol.py +128 -0
- execsql/exporters/xls.py +8 -0
- execsql/format.py +3 -1
- execsql/gui/__init__.py +2 -0
- execsql/gui/base.py +2 -0
- execsql/gui/console.py +2 -0
- execsql/gui/desktop.py +1 -0
- execsql/gui/tui.py +134 -0
- execsql/importers/base.py +1 -0
- execsql/importers/csv.py +2 -0
- execsql/importers/feather.py +2 -0
- execsql/importers/ods.py +1 -0
- execsql/importers/xls.py +1 -0
- execsql/metacommands/__init__.py +386 -180
- execsql/metacommands/dispatch.py +2 -0
- execsql/metacommands/io.py +41 -0
- execsql/models.py +17 -0
- execsql/parser.py +41 -0
- execsql/script/control.py +2 -0
- execsql/script/engine.py +19 -0
- execsql/script/variables.py +9 -5
- execsql/state.py +312 -199
- execsql/types.py +46 -0
- {execsql2-2.4.5.dist-info → execsql2-2.6.0.dist-info}/METADATA +2 -2
- {execsql2-2.4.5.dist-info → execsql2-2.6.0.dist-info}/RECORD +62 -61
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.4.5.data → execsql2-2.6.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.4.5.dist-info → execsql2-2.6.0.dist-info}/WHEEL +0 -0
- {execsql2-2.4.5.dist-info → execsql2-2.6.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.4.5.dist-info → execsql2-2.6.0.dist-info}/licenses/LICENSE.txt +0 -0
- {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
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:
|
execsql/importers/feather.py
CHANGED
|
@@ -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)
|