execsql2 2.22.3__py3-none-any.whl → 2.22.5__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 (25) hide show
  1. execsql/format.py +190 -1
  2. execsql/gui/base.py +56 -1
  3. execsql/gui/console.py +10 -33
  4. execsql/gui/tui.py +45 -15
  5. execsql/metacommands/control.py +26 -0
  6. {execsql2-2.22.3.dist-info → execsql2-2.22.5.dist-info}/METADATA +3 -3
  7. {execsql2-2.22.3.dist-info → execsql2-2.22.5.dist-info}/RECORD +25 -25
  8. {execsql2-2.22.3.dist-info → execsql2-2.22.5.dist-info}/WHEEL +1 -1
  9. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/README.md +0 -0
  10. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  11. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  12. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/make_config_db.sql +0 -0
  13. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/md_compare.sql +0 -0
  14. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/md_glossary.sql +0 -0
  15. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/md_upsert.sql +0 -0
  16. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/pg_compare.sql +0 -0
  17. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  18. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  19. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/script_template.sql +0 -0
  20. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/ss_compare.sql +0 -0
  21. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  22. {execsql2-2.22.3.data → execsql2-2.22.5.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  23. {execsql2-2.22.3.dist-info → execsql2-2.22.5.dist-info}/entry_points.txt +0 -0
  24. {execsql2-2.22.3.dist-info → execsql2-2.22.5.dist-info}/licenses/LICENSE.txt +0 -0
  25. {execsql2-2.22.3.dist-info → execsql2-2.22.5.dist-info}/licenses/NOTICE +0 -0
execsql/format.py CHANGED
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import contextlib
15
15
  import io
16
16
  import re
17
+ from collections.abc import Iterator
17
18
  from pathlib import Path
18
19
 
19
20
  __all__ = ["collect_paths", "format_file", "main", "parse_keyword"]
@@ -169,6 +170,170 @@ def parse_keyword(payload: str) -> str:
169
170
  _EXECSQL_VAR_RE = re.compile(r"""!(['"]?)!([^!\s][^!]*)!\1!|!\{[^}]+\}!""")
170
171
 
171
172
 
173
+ # Characters that may appear in an identifier, used to tell an ``E'...'``
174
+ # escape-string prefix from a trailing ``e`` on an identifier (``abce'x'`` is
175
+ # the identifier ``abce`` followed by a separate literal).
176
+ _IDENT_CHAR_RE = re.compile(r"[A-Za-z0-9_$]")
177
+
178
+
179
+ def _iter_sql_literals(sql: str) -> Iterator[tuple[int, int, str]]:
180
+ """Yield ``(start, end, kind)`` spans for every string literal in *sql*.
181
+
182
+ This is a lexical walk rather than a regex sweep, because an apostrophe
183
+ only starts a literal in some contexts. Line comments, block comments
184
+ (which nest in PostgreSQL), and quoted identifiers are skipped entirely,
185
+ so quotes inside them never open a literal.
186
+
187
+ *kind* is one of:
188
+
189
+ ``"estring"``
190
+ A PostgreSQL escape string, ``E'...'``. Backslash escapes are
191
+ significant inside it, which is what makes it unsafe to hand to
192
+ sqlglot — see :func:`_protect_estrings`.
193
+ ``"plain"``
194
+ An ordinary ``'...'`` literal, where only a doubled ``''`` escapes.
195
+ ``"dollar"``
196
+ A dollar-quoted body, ``$$...$$`` or ``$tag$...$tag$``, in which no
197
+ character is special.
198
+ """
199
+ i, n = 0, len(sql)
200
+ while i < n:
201
+ c = sql[i]
202
+
203
+ if c == "-" and sql.startswith("--", i):
204
+ nl = sql.find("\n", i)
205
+ i = n if nl < 0 else nl + 1
206
+ continue
207
+
208
+ if c == "/" and sql.startswith("/*", i):
209
+ depth, i = 1, i + 2
210
+ while i < n and depth:
211
+ if sql.startswith("/*", i):
212
+ depth, i = depth + 1, i + 2
213
+ elif sql.startswith("*/", i):
214
+ depth, i = depth - 1, i + 2
215
+ else:
216
+ i += 1
217
+ continue
218
+
219
+ if c == '"':
220
+ i += 1
221
+ while i < n:
222
+ if sql[i] == '"':
223
+ if sql.startswith('""', i):
224
+ i += 2
225
+ continue
226
+ i += 1
227
+ break
228
+ i += 1
229
+ continue
230
+
231
+ if c == "$":
232
+ m = _DOLLAR_QUOTE_RE.match(sql, i)
233
+ if m:
234
+ tag = m.group(0)
235
+ close = sql.find(tag, m.end())
236
+ stop = n if close < 0 else close + len(tag)
237
+ yield i, stop, "dollar"
238
+ i = stop
239
+ continue
240
+
241
+ # E'...' — only when the `e` is not the tail of an identifier.
242
+ if c in "eE" and sql.startswith("'", i + 1) and not (i and _IDENT_CHAR_RE.match(sql[i - 1])):
243
+ j = i + 2
244
+ while j < n:
245
+ if sql[j] == "\\" and j + 1 < n:
246
+ j += 2
247
+ continue
248
+ if sql[j] == "'":
249
+ if sql.startswith("''", j):
250
+ j += 2
251
+ continue
252
+ j += 1
253
+ break
254
+ j += 1
255
+ yield i, j, "estring"
256
+ i = j
257
+ continue
258
+
259
+ if c == "'":
260
+ j = i + 1
261
+ while j < n:
262
+ if sql[j] == "'":
263
+ if sql.startswith("''", j):
264
+ j += 2
265
+ continue
266
+ j += 1
267
+ break
268
+ j += 1
269
+ yield i, j, "plain"
270
+ i = j
271
+ continue
272
+
273
+ i += 1
274
+
275
+
276
+ def _sql_literal_texts(sql: str) -> list[str]:
277
+ """Return the source text of every string literal in *sql*, in order."""
278
+ return [sql[start:end] for start, end, _ in _iter_sql_literals(sql)]
279
+
280
+
281
+ # Placeholder standing in for a masked E'...' literal. It is a plain literal
282
+ # so that sqlglot sees a string where a string was, and lays the statement out
283
+ # the same way it would have for the real one.
284
+ _ESTR_PLACEHOLDER = "execsqlestr"
285
+ _ESTR_RESTORE_RE = re.compile(rf"'{_ESTR_PLACEHOLDER}(\d+)'")
286
+
287
+
288
+ def _protect_estrings(sql: str) -> tuple[str, list[str]]:
289
+ """Replace ``E'...'`` literals with placeholders, return (protected, originals).
290
+
291
+ sqlglot's Postgres tokenizer consumes backslash escapes inside an escape
292
+ string but its generator never re-emits them, so ``E'\\\\s+'`` round-trips
293
+ to ``e'\\s+'`` — a different string (tobymao/sqlglot#8191). Masking these
294
+ literals keeps them out of the round trip entirely and restores them
295
+ byte-for-byte, independently of the installed sqlglot version.
296
+ """
297
+ originals: list[str] = []
298
+ out: list[str] = []
299
+ last = 0
300
+ for start, end, kind in _iter_sql_literals(sql):
301
+ if kind != "estring":
302
+ continue
303
+ out.append(sql[last:start])
304
+ out.append(f"'{_ESTR_PLACEHOLDER}{len(originals)}'")
305
+ originals.append(sql[start:end])
306
+ last = end
307
+ if not originals:
308
+ return sql, []
309
+ out.append(sql[last:])
310
+ return "".join(out), originals
311
+
312
+
313
+ def _restore_estrings(sql: str, originals: list[str]) -> str | None:
314
+ """Put masked ``E'...'`` literals back; return None if any went missing.
315
+
316
+ A missing placeholder means sqlglot dropped or mangled the literal, so the
317
+ caller must fall back to the unformatted text rather than emit SQL with a
318
+ placeholder left in it.
319
+ """
320
+ if not originals:
321
+ return sql
322
+ seen: set[int] = set()
323
+
324
+ def replace(m: re.Match[str]) -> str:
325
+ idx = int(m.group(1))
326
+ if idx >= len(originals):
327
+ return m.group(0)
328
+ seen.add(idx)
329
+ return originals[idx]
330
+
331
+ restored = _ESTR_RESTORE_RE.sub(replace, sql)
332
+ if len(seen) != len(originals) or _ESTR_PLACEHOLDER in restored:
333
+ return None
334
+ return restored
335
+
336
+
172
337
  def _protect_variables(sql: str) -> tuple[str, list[tuple[str, str]]]:
173
338
  """Replace execsql substitutions with valid SQL identifiers, return (protected, replacements)."""
174
339
  replacements: list[tuple[str, str]] = []
@@ -224,6 +389,9 @@ def _sqlglot_format(
224
389
 
225
390
  text = "\n".join(sql_lines)
226
391
  protected, replacements = _protect_variables(text)
392
+ # Keep E'...' literals out of the round trip; sqlglot loses their
393
+ # backslash escapes (see _protect_estrings).
394
+ protected, estrings = _protect_estrings(protected)
227
395
 
228
396
  # Count semicolons in input as a rough statement count.
229
397
  input_semis = protected.count(";")
@@ -275,8 +443,29 @@ def _sqlglot_format(
275
443
  output_alnum_len = len(_alnum.sub("", joined))
276
444
  if input_alnum_len and output_alnum_len < input_alnum_len * 0.7:
277
445
  return sql_lines
446
+
447
+ # Literal round-trip check. The alphanumeric check above is a
448
+ # gross-token-loss detector: it deletes every non-alphanumeric
449
+ # character before counting, so a transformation that changes only
450
+ # punctuation — a dropped backslash, a swapped quote — is invisible to
451
+ # it at any threshold. Requiring every literal to survive verbatim
452
+ # closes that blind spot for the whole class, not just for the E'...'
453
+ # case masked above.
454
+ #
455
+ # Containment, not equality: sqlglot rewrites dialect-specific
456
+ # constructs into Postgres equivalents that can legitimately repeat a
457
+ # literal (``SUBSTRING_INDEX(d, ',', 1)`` expands into a form using
458
+ # ``','`` more than once), so the output may hold more literals than
459
+ # the input. What must never happen is an input literal coming back
460
+ # altered or not at all — that is content loss, and it falls back.
461
+ if set(_sql_literal_texts(protected)) - set(_sql_literal_texts(joined)):
462
+ return sql_lines
463
+
278
464
  joined = re.sub(r"\bINTO TEMPORARY\b(?!\s+TABLE)", "INTO TEMPORARY TABLE", joined)
279
- return _restore_variables(joined, replacements).split("\n")
465
+ restored = _restore_estrings(joined, estrings)
466
+ if restored is None:
467
+ return sql_lines
468
+ return _restore_variables(restored, replacements).split("\n")
280
469
  except Exception:
281
470
  return sql_lines
282
471
 
execsql/gui/base.py CHANGED
@@ -9,11 +9,66 @@ from typing import TYPE_CHECKING, Any
9
9
  if TYPE_CHECKING:
10
10
  pass
11
11
 
12
- __all__ = ["DiffResult", "GuiBackend", "compare_stats", "compute_row_diffs"]
12
+ __all__ = ["DiffResult", "GuiBackend", "compare_stats", "compute_row_diffs", "format_table", "row_count_text"]
13
13
 
14
14
  DIFF_MARKER = "● "
15
15
 
16
16
 
17
+ # ---------------------------------------------------------------------------
18
+ # Plain-text table rendering (shared by the console backend and by
19
+ # non-interactive metacommand fallbacks such as HALT ... DISPLAY)
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ def row_count_text(n: int) -> str:
24
+ """Return a human-readable row count string, e.g. ``'3 rows'`` or ``'1 row'``."""
25
+ return f"{n:,} row{'s' if n != 1 else ''}"
26
+
27
+
28
+ def format_table(
29
+ headers: list,
30
+ rows: list,
31
+ row_states: list[str] | None = None,
32
+ changed_cols: list[set[str]] | None = None,
33
+ ) -> str:
34
+ """Render *headers* and *rows* as a plain-text table.
35
+
36
+ Columns are left-aligned and padded to the widest value. When *row_states*
37
+ and *changed_cols* are provided, cells that differ in ``"changed"`` rows are
38
+ prefixed with the diff marker (``"● "``) and the marker width is included in
39
+ the column sizing.
40
+
41
+ Returns an empty string when *headers* is empty. The result has no trailing
42
+ newline.
43
+ """
44
+ if not headers:
45
+ return ""
46
+ headers_str = [str(h) for h in headers]
47
+
48
+ # Pre-compute display values so column widths account for markers.
49
+ display_rows: list[list[str]] = []
50
+ for ridx, row in enumerate(rows):
51
+ cells: list[str] = []
52
+ state = row_states[ridx] if row_states and ridx < len(row_states) else ""
53
+ diff_set = changed_cols[ridx] if changed_cols and ridx < len(changed_cols) else set()
54
+ for ci, cell in enumerate(row):
55
+ val = str(cell) if cell is not None else ""
56
+ if state == "changed" and ci < len(headers_str) and headers_str[ci] in diff_set:
57
+ val = f"{DIFF_MARKER}{val}"
58
+ cells.append(val)
59
+ display_rows.append(cells)
60
+
61
+ col_widths = [len(h) for h in headers_str]
62
+ for cells in display_rows:
63
+ for i, cell in enumerate(cells):
64
+ col_widths[i] = max(col_widths[i], len(cell))
65
+ fmt = " " + " ".join(f"{{:<{w}}}" for w in col_widths)
66
+ sep = " " + " ".join("-" * w for w in col_widths)
67
+ lines = [fmt.format(*headers_str), sep]
68
+ lines.extend(fmt.format(*cells) for cells in display_rows)
69
+ return "\n".join(lines)
70
+
71
+
17
72
  # ---------------------------------------------------------------------------
18
73
  # Value comparison helpers
19
74
  # ---------------------------------------------------------------------------
execsql/gui/console.py CHANGED
@@ -11,7 +11,13 @@ import sys
11
11
  import time
12
12
  from typing import Any, cast
13
13
 
14
- from execsql.gui.base import DIFF_MARKER, GuiBackend, compare_stats as _compare_stats, compute_row_diffs
14
+ from execsql.gui.base import (
15
+ GuiBackend,
16
+ compare_stats as _compare_stats,
17
+ compute_row_diffs,
18
+ format_table,
19
+ row_count_text as _row_count_text,
20
+ )
15
21
 
16
22
  __all__ = ["ConsoleBackend"]
17
23
 
@@ -23,11 +29,6 @@ def _print_help_url(args: dict) -> None:
23
29
  print(f" Help: {url}", file=sys.stderr)
24
30
 
25
31
 
26
- def _row_count_text(n: int) -> str:
27
- """Return a human-readable row count string, e.g. '3 rows' or '1 row'."""
28
- return f"{n:,} row{'s' if n != 1 else ''}"
29
-
30
-
31
32
  def _print_table(
32
33
  headers: list,
33
34
  rows: list,
@@ -42,33 +43,9 @@ def _print_table(
42
43
  """
43
44
  if file is None:
44
45
  file = sys.stderr
45
- if not headers:
46
- return
47
- headers_str = [str(h) for h in headers]
48
-
49
- # Pre-compute display values so column widths account for markers.
50
- display_rows: list[list[str]] = []
51
- for ridx, row in enumerate(rows):
52
- cells: list[str] = []
53
- state = row_states[ridx] if row_states and ridx < len(row_states) else ""
54
- diff_set = changed_cols[ridx] if changed_cols and ridx < len(changed_cols) else set()
55
- for ci, cell in enumerate(row):
56
- val = str(cell) if cell is not None else ""
57
- if state == "changed" and ci < len(headers_str) and headers_str[ci] in diff_set:
58
- val = f"{DIFF_MARKER}{val}"
59
- cells.append(val)
60
- display_rows.append(cells)
61
-
62
- col_widths = [len(h) for h in headers_str]
63
- for cells in display_rows:
64
- for i, cell in enumerate(cells):
65
- col_widths[i] = max(col_widths[i], len(cell))
66
- fmt = " " + " ".join(f"{{:<{w}}}" for w in col_widths)
67
- sep = " " + " ".join("-" * w for w in col_widths)
68
- print(fmt.format(*headers_str), file=file)
69
- print(sep, file=file)
70
- for cells in display_rows:
71
- print(fmt.format(*cells), file=file)
46
+ rendered = format_table(headers, rows, row_states=row_states, changed_cols=changed_cols)
47
+ if rendered:
48
+ print(rendered, file=file)
72
49
 
73
50
 
74
51
  def _prompt_buttons(button_list: list) -> int | None:
execsql/gui/tui.py CHANGED
@@ -89,14 +89,18 @@ def _help_button(url: str | None) -> Button | None:
89
89
  return None
90
90
 
91
91
 
92
- def _button_row(button_list: list) -> list[Button]:
92
+ def _button_row(button_list: list, no_cancel: bool = False) -> list[Button]:
93
93
  """Create Button widgets from a button_list of (label, value, key?) tuples.
94
94
 
95
- A Cancel button (id ``btn_cancel_exit``) is always prepended so it appears
96
- on the left while the primary action button sits on the far right.
97
- Entries in *button_list* with a falsy value (0 or None) are treated as the
98
- cancel label source and are otherwise excluded from the numbered buttons so
99
- that ``btn_N`` indices stay aligned with ``button_list`` positions.
95
+ A Cancel button (id ``btn_cancel_exit``) is prepended so it appears on the
96
+ left while the primary action button sits on the far right. Entries in
97
+ *button_list* with a falsy value (0 or None) are treated as the cancel
98
+ label source and are otherwise excluded from the numbered buttons so that
99
+ ``btn_N`` indices stay aligned with ``button_list`` positions.
100
+
101
+ When *no_cancel* is set, the Cancel button is omitted — dialogs such as
102
+ HALT have no meaningful cancel action, and cancelling one would exit with
103
+ the generic status 2 rather than the status the caller asked for.
100
104
  """
101
105
  cancel_label = "Cancel"
102
106
  primary_buttons = []
@@ -107,6 +111,8 @@ def _button_row(button_list: list) -> list[Button]:
107
111
  else:
108
112
  variant: Literal["primary", "default"] = "primary" if i == 0 else "default"
109
113
  primary_buttons.append(Button(label, id=f"btn_{i}", variant=variant))
114
+ if no_cancel:
115
+ return primary_buttons
110
116
  return [Button(cancel_label, id="btn_cancel_exit", variant="warning")] + primary_buttons
111
117
 
112
118
 
@@ -182,7 +188,14 @@ class _BaseDialog(ModalScreen):
182
188
  return self._result
183
189
 
184
190
  def action_cancel(self) -> None:
185
- """Dismiss the dialog as cancelled (triggered by Escape key)."""
191
+ """Dismiss the dialog as cancelled (triggered by Escape key).
192
+
193
+ Ignored for dialogs that pass ``no_cancel`` (e.g. HALT), which have no
194
+ meaningful cancel action. This matches the Tkinter backend, which does
195
+ not bind ``<Escape>`` to cancellation in that case.
196
+ """
197
+ if self.args.get("no_cancel", False):
198
+ return
186
199
  self._result = {"button": None, "cancelled": True}
187
200
  self.dismiss(self._result)
188
201
 
@@ -210,31 +223,48 @@ class _BaseDialog(ModalScreen):
210
223
 
211
224
 
212
225
  class MsgScreen(_BaseDialog):
213
- """Simple message dialog with Continue and Cancel buttons."""
226
+ """Message dialog with an optional data table.
227
+
228
+ Honours the caller's ``button_list`` and ``no_cancel`` arguments, and
229
+ renders ``column_headers``/``rowset`` when supplied (as the HALT
230
+ metacommand's DISPLAY clause does), matching the Tkinter MsgDialog.
231
+ """
214
232
 
215
233
  BINDINGS = [
216
234
  *_BaseDialog.BINDINGS,
217
235
  Binding("enter", "submit", "Continue", show=True),
218
236
  ]
219
237
 
238
+ def _button_list(self) -> list:
239
+ return cast(list, self.args.get("button_list") or [("Continue", 1, "<Return>")])
240
+
220
241
  def compose(self) -> ComposeResult:
221
242
  title = self.args.get("title", "Message")
222
243
  message = self.args.get("message", "")
244
+ headers = self.args.get("column_headers")
245
+ rows = self.args.get("rowset")
223
246
  with Container(id="dialog"):
224
247
  yield Label(title, id="title")
225
248
  yield Static(message, id="message")
249
+ if headers and rows:
250
+ with ScrollableContainer(id="table_container"):
251
+ yield _make_table_widget("msg_table", headers, rows)
252
+ yield Static(_row_count_text(len(rows)), classes="row-count")
226
253
  with Horizontal(id="buttons"):
227
- yield Button("Cancel", id="btn_cancel_exit", variant="warning")
228
- yield Button("Continue", id="btn_close", variant="primary")
254
+ yield from _button_row(self._button_list(), no_cancel=self.args.get("no_cancel", False))
229
255
 
230
256
  def action_submit(self) -> None:
231
- """Continue the dialog (triggered by Enter key)."""
232
- self._result = {"button": 1}
233
- self.dismiss(self._result)
257
+ """Dismiss with the first non-cancel button value (triggered by Enter)."""
258
+ for btn in self._button_list():
259
+ if btn[1]:
260
+ self._result = {"button": btn[1]}
261
+ self.dismiss(self._result)
262
+ return
234
263
 
235
264
  def on_button_pressed(self, event: Button.Pressed) -> None:
236
- if event.button.id == "btn_close":
237
- self._result = {"button": 1}
265
+ btn_id = event.button.id
266
+ if btn_id and btn_id.startswith("btn_") and btn_id[4:].isdigit():
267
+ self._result = {"button": self._button_list()[int(btn_id[4:])][1]}
238
268
  self.dismiss(self._result)
239
269
 
240
270
 
@@ -33,6 +33,7 @@ from execsql.exceptions import ErrInfo
33
33
  from typing import Any
34
34
 
35
35
  import execsql.state as _state
36
+ from execsql.gui.base import format_table, row_count_text
36
37
  from execsql.script import current_script_line
37
38
  from execsql.utils.errors import exit_now
38
39
  from execsql.utils.fileio import EncodedFile, check_dir
@@ -214,6 +215,14 @@ def x_wait_until(**kwargs: Any) -> None:
214
215
 
215
216
  # x_halt_msg is also needed by x_halt - define it here
216
217
  def x_halt_msg(**kwargs: Any) -> None:
218
+ """Halt the script, reporting *errmsg* in a dialog or on the console.
219
+
220
+ The dialog is used only when the run is interactive — either a GUI console
221
+ is already running, or ``gui_level`` is greater than 1. Otherwise the
222
+ message (and any ``DISPLAY`` rowset) is written to the error output and the
223
+ script exits, so that unattended runs fail instead of blocking forever on a
224
+ modal nobody can dismiss.
225
+ """
217
226
  import queue as _queue
218
227
 
219
228
  errmsg = kwargs["errmsg"]
@@ -242,6 +251,23 @@ def x_halt_msg(**kwargs: Any) -> None:
242
251
  headers, rows = db.select_data(sql)
243
252
  else:
244
253
  headers, rows = None, None
254
+
255
+ # Non-interactive runs (no GUI console, gui_level 0 or 1) must not block on
256
+ # a modal dialog: report on the console and exit instead. This mirrors the
257
+ # guard in x_halt, which the message-bearing HALT forms never reach because
258
+ # their metacommand patterns are registered later and so take priority.
259
+ if not (gui_console_isrunning() or conf.gui_level > 1):
260
+ if errmsg:
261
+ _state.output.write_err(errmsg)
262
+ if headers:
263
+ # Unlike the dialog, render an empty rowset too — a HALT diagnostic
264
+ # showing zero rows is more useful than showing nothing at all.
265
+ _state.output.write_err(format_table(headers, rows or []))
266
+ _state.output.write_err(row_count_text(len(rows or [])))
267
+ _state.exec_log.log_exit_halt(*current_script_line(), msg=errmsg)
268
+ exit_now(errlevel, None)
269
+ return
270
+
245
271
  enable_gui()
246
272
  return_queue: _queue.Queue[Any] = _queue.Queue()
247
273
  gui_args = {
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: execsql2
3
- Version: 2.22.3
3
+ Version: 2.22.5
4
4
  Summary: Runs a SQL script against a PostgreSQL, SQLite, MariaDB/MySQL, DuckDB, Firebird, MS-Access, MS-SQL-Server, or Oracle database, or an ODBC DSN. Provides metacommands to import and export data, copy data between databases, conditionally execute SQL and metacommands, and dynamically alter SQL and metacommands with substitution variables.
5
5
  Project-URL: Homepage, https://execsql2.readthedocs.io
6
6
  Project-URL: Repository, https://github.com/geocoug/execsql
@@ -384,7 +384,7 @@ execsql-format --no-sql --in-place scripts/
384
384
  ```yaml
385
385
  repos:
386
386
  - repo: https://github.com/geocoug/execsql
387
- rev: v2.22.3
387
+ rev: v2.22.5
388
388
  hooks:
389
389
  - id: execsql-format
390
390
  ```
@@ -3,7 +3,7 @@ execsql/__main__.py,sha256=HdbK-SAhyUmfB6xINY5AzxdMSxGzWSGEG_2dv42Jn64,315
3
3
  execsql/api.py,sha256=EOtBwMnm-8fmB_KmvxyjglR6C7CjdYhCPBisJ9CFfJg,22227
4
4
  execsql/config.py,sha256=txKziUJLV7UPonj2sGFgkmlinDdSV50aHmR_cuCbEIo,34369
5
5
  execsql/exceptions.py,sha256=GlphAiR8iZu4UR4U50WgSqEUMd9_8AeQJ_hhPcJOeQU,9381
6
- execsql/format.py,sha256=3w-m0EsHapBdSKx6oTFWYlXspFLdUR_RF79gMvHhrqQ,34440
6
+ execsql/format.py,sha256=tfg0m3EbDVRlAR3ZhwV8BjgMFckL3u3IdldyCONrutU,41400
7
7
  execsql/models.py,sha256=-DWTVrPNwM-n1h3e3rXMoBv9-wpWVdZahyy4RB7fRRE,15502
8
8
  execsql/parser.py,sha256=aOxZ7Mzgvk7G4-nvy9Bgz7xlaZm6oo81cffDeBk_LCk,15597
9
9
  execsql/plugins.py,sha256=l82VWa5NpvOQWD6vcO-WKP-vtn0wp9__wWDFAbwxZg0,12838
@@ -55,10 +55,10 @@ execsql/exporters/xml.py,sha256=t1PSr3i8xvTCWzd9jaxxAy5NcRpl6sqjnZALafKCqpM,2575
55
55
  execsql/exporters/yaml.py,sha256=R61Rs_0rd5G3bg344L4hC84GM79IfYXaDO2tkF-BluY,3121
56
56
  execsql/exporters/zip.py,sha256=341z4Z4MPtEkXJEtfiMSiuJigERh5_f8RDbNMBbt8KM,5078
57
57
  execsql/gui/__init__.py,sha256=oCb-cyhLZzVpWJ4WU5HbqEDBrV-lm0ytEwxemrOZyqs,2048
58
- execsql/gui/base.py,sha256=15wCaSLvSb21hToCczru46ghmd56ryMpEOOZ0R_dE3U,12740
59
- execsql/gui/console.py,sha256=hgFnxPP7lLolKgmKCBTSMXIt4uU2L4C32XcA82ytr2U,19438
58
+ execsql/gui/base.py,sha256=DMTqHLlpZlGCsNKyrLlJPbaxvukC0n1YmR4aHEN3jPY,14983
59
+ execsql/gui/console.py,sha256=dzdSl4NJNvRd9VvlwRCXHvfnXjQPimyTx7NcibcUZC8,18305
60
60
  execsql/gui/desktop.py,sha256=922OdzjV4OGNDIesOxKOmlsrNp1aJ8St8cVwED0H2sE,59521
61
- execsql/gui/tui.py,sha256=yXKBqbbJDNC6a25sUKpvy3BR10U2LhLSuGAxkw6ADcQ,65358
61
+ execsql/gui/tui.py,sha256=jlCoP1lsEJgFsFikKVqF4B3s3KyKRs6JbWfwtlFHyDc,66800
62
62
  execsql/importers/__init__.py,sha256=zZwdQxMaValCNqUrVdvaA7XPU3J8NmqVJ4uolNWY2iU,299
63
63
  execsql/importers/base.py,sha256=3Iq_Sr4cCXKHpjjJYkhZX6uhsIIRAdmvAvTdC4mFlWo,4326
64
64
  execsql/importers/csv.py,sha256=Kec1VCycRdfstNoxIXsEsSFqQprZp_edpNiMZm43eTI,4914
@@ -69,7 +69,7 @@ execsql/importers/xls.py,sha256=D7Ul42gIRYZZC5QxD35ft_wmqQv2f1jNMSbnqlzV0ZA,3851
69
69
  execsql/metacommands/__init__.py,sha256=H_D1Z5kN6zfYtxatejQhQhQV1tlVZ7rcdFZ_9uhXu2Q,13010
70
70
  execsql/metacommands/conditions.py,sha256=2bfdVV_LCl91quG4SRLur9Jk60NsIOStHlBCeJJ-Djc,30068
71
71
  execsql/metacommands/connect.py,sha256=SPk4h9yiUgTD3w1gNOT_oYx8VShggsO357by5Hoy6OI,15344
72
- execsql/metacommands/control.py,sha256=EAURAsBjEZm3YwOJG0Gr3oI2H5_8p3Cu8-AguhBD_ds,8007
72
+ execsql/metacommands/control.py,sha256=QT3CJyneANrHxgnbK7vlwmLbckRCbSSwSxy4D3oxN4A,9375
73
73
  execsql/metacommands/data.py,sha256=Rp18lHUKSTkwTFRjOQ7bDgZd42LNLVoTS3Zc_bcNEWY,12199
74
74
  execsql/metacommands/debug.py,sha256=MeVXAob8ItEg2QzuSUkKDaQCEABnH6u0XcAwJzw36CE,13015
75
75
  execsql/metacommands/dispatch.py,sha256=t7x0xWHJA0PeCrYf7jYeSMJgf0yxcZ_xh-_YAtBPHLw,87192
@@ -101,23 +101,23 @@ execsql/utils/numeric.py,sha256=_7bi5R6pHjMCkOPne_Sh5nUj-B8e9g6WFmjptFHaunA,1573
101
101
  execsql/utils/regex.py,sha256=B3mSBhRRx_DpUnX_t9IoYd05KvWAebiLMCZqQAmEwcw,3885
102
102
  execsql/utils/strings.py,sha256=UQNjpRCEFa1UO6feU-M-9e24wWAvizs_iu_4fFusLxo,8516
103
103
  execsql/utils/timer.py,sha256=eDYf5VzCNFk7oo90InJucUm3XcBdhYMogjZMqeg9xzc,1899
104
- execsql2-2.22.3.data/data/execsql2_extras/README.md,sha256=vX4NTL095dUoA_hesyRMGYBorEZ_Y_tJ9qrd-MVV09I,5032
105
- execsql2-2.22.3.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
106
- execsql2-2.22.3.data/data/execsql2_extras/example_config_prompt.sql,sha256=2e8KzzVWhho8KxYVHETSVmZdhW7wodioDsqBLSL6m4s,7487
107
- execsql2-2.22.3.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
108
- execsql2-2.22.3.data/data/execsql2_extras/md_compare.sql,sha256=qYYVAjSeHZzjszxV3Bv6bg8Ckbq2kMHl87_gh4sywMU,24140
109
- execsql2-2.22.3.data/data/execsql2_extras/md_glossary.sql,sha256=hkZ2Onn57LAKKsuXxzhR8tPtcWXkmWEQkwPE58-Tm2k,10796
110
- execsql2-2.22.3.data/data/execsql2_extras/md_upsert.sql,sha256=_CAK4BzEboRXTNy03SJR-oOjcEdSNMuRBPL6noWUptY,112560
111
- execsql2-2.22.3.data/data/execsql2_extras/pg_compare.sql,sha256=1zJd4hVUKHR0tncc2qTBC9B4qVV4Us2ITkJpsjN3tMw,24352
112
- execsql2-2.22.3.data/data/execsql2_extras/pg_glossary.sql,sha256=IKuwna-_8b20ljSkXZruuiQigrCpo7ueQdUqd1MXiuI,9908
113
- execsql2-2.22.3.data/data/execsql2_extras/pg_upsert.sql,sha256=HpPJtTHvpEjQy03j-3iPxDEOHMRkudOg7O4D4YR38UI,108315
114
- execsql2-2.22.3.data/data/execsql2_extras/script_template.sql,sha256=2J35ddZPguJ-vwTsz83wErv0jiWUyJcdW_JM0mNKDXA,11155
115
- execsql2-2.22.3.data/data/execsql2_extras/ss_compare.sql,sha256=j1qVNUPXQsEU7-DoVgDJCGcE0EuIl7whLBT3fgeiMAo,24833
116
- execsql2-2.22.3.data/data/execsql2_extras/ss_glossary.sql,sha256=2gLxv34xzKt0vy7hSzJH7a9JiMC3ETrv9MofxQwAibU,13065
117
- execsql2-2.22.3.data/data/execsql2_extras/ss_upsert.sql,sha256=G_8rQ0VzuKIZHWs24O_WrfzpC5S27R1JsL-bFBR3SUQ,117730
118
- execsql2-2.22.3.dist-info/METADATA,sha256=gSLoVf2cJOEScG6F8dJdIk7uVWiXwd_uVzRKgYdJVgY,20382
119
- execsql2-2.22.3.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
120
- execsql2-2.22.3.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
121
- execsql2-2.22.3.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
122
- execsql2-2.22.3.dist-info/licenses/NOTICE,sha256=McYzgxYav3U1OaVsY4Su1sfBrfmplpRdA9b6-gCDQCg,342
123
- execsql2-2.22.3.dist-info/RECORD,,
104
+ execsql2-2.22.5.data/data/execsql2_extras/README.md,sha256=vX4NTL095dUoA_hesyRMGYBorEZ_Y_tJ9qrd-MVV09I,5032
105
+ execsql2-2.22.5.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
106
+ execsql2-2.22.5.data/data/execsql2_extras/example_config_prompt.sql,sha256=2e8KzzVWhho8KxYVHETSVmZdhW7wodioDsqBLSL6m4s,7487
107
+ execsql2-2.22.5.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
108
+ execsql2-2.22.5.data/data/execsql2_extras/md_compare.sql,sha256=qYYVAjSeHZzjszxV3Bv6bg8Ckbq2kMHl87_gh4sywMU,24140
109
+ execsql2-2.22.5.data/data/execsql2_extras/md_glossary.sql,sha256=hkZ2Onn57LAKKsuXxzhR8tPtcWXkmWEQkwPE58-Tm2k,10796
110
+ execsql2-2.22.5.data/data/execsql2_extras/md_upsert.sql,sha256=_CAK4BzEboRXTNy03SJR-oOjcEdSNMuRBPL6noWUptY,112560
111
+ execsql2-2.22.5.data/data/execsql2_extras/pg_compare.sql,sha256=1zJd4hVUKHR0tncc2qTBC9B4qVV4Us2ITkJpsjN3tMw,24352
112
+ execsql2-2.22.5.data/data/execsql2_extras/pg_glossary.sql,sha256=IKuwna-_8b20ljSkXZruuiQigrCpo7ueQdUqd1MXiuI,9908
113
+ execsql2-2.22.5.data/data/execsql2_extras/pg_upsert.sql,sha256=HpPJtTHvpEjQy03j-3iPxDEOHMRkudOg7O4D4YR38UI,108315
114
+ execsql2-2.22.5.data/data/execsql2_extras/script_template.sql,sha256=2J35ddZPguJ-vwTsz83wErv0jiWUyJcdW_JM0mNKDXA,11155
115
+ execsql2-2.22.5.data/data/execsql2_extras/ss_compare.sql,sha256=j1qVNUPXQsEU7-DoVgDJCGcE0EuIl7whLBT3fgeiMAo,24833
116
+ execsql2-2.22.5.data/data/execsql2_extras/ss_glossary.sql,sha256=2gLxv34xzKt0vy7hSzJH7a9JiMC3ETrv9MofxQwAibU,13065
117
+ execsql2-2.22.5.data/data/execsql2_extras/ss_upsert.sql,sha256=G_8rQ0VzuKIZHWs24O_WrfzpC5S27R1JsL-bFBR3SUQ,117730
118
+ execsql2-2.22.5.dist-info/METADATA,sha256=OHGAN6pqfqOTBPDSst372rskkKNgeXUofH9K_mfl3sc,20382
119
+ execsql2-2.22.5.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
120
+ execsql2-2.22.5.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
121
+ execsql2-2.22.5.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
122
+ execsql2-2.22.5.dist-info/licenses/NOTICE,sha256=McYzgxYav3U1OaVsY4Su1sfBrfmplpRdA9b6-gCDQCg,342
123
+ execsql2-2.22.5.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any