execsql2 2.16.8__py3-none-any.whl → 2.16.13__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 (29) hide show
  1. execsql/debug/repl.py +59 -0
  2. execsql/format.py +290 -15
  3. execsql/metacommands/debug.py +89 -1
  4. execsql/metacommands/dispatch.py +18 -0
  5. execsql/script/ast.py +43 -6
  6. execsql/script/executor.py +28 -13
  7. execsql/script/parser.py +135 -6
  8. execsql/utils/fileio.py +9 -1
  9. {execsql2-2.16.8.dist-info → execsql2-2.16.13.dist-info}/METADATA +1 -1
  10. {execsql2-2.16.8.dist-info → execsql2-2.16.13.dist-info}/RECORD +29 -29
  11. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/README.md +0 -0
  12. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  13. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  14. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/execsql.conf +0 -0
  15. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/make_config_db.sql +0 -0
  16. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/md_compare.sql +0 -0
  17. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/md_glossary.sql +0 -0
  18. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/md_upsert.sql +0 -0
  19. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/pg_compare.sql +0 -0
  20. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  21. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  22. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/script_template.sql +0 -0
  23. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/ss_compare.sql +0 -0
  24. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  25. {execsql2-2.16.8.data → execsql2-2.16.13.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  26. {execsql2-2.16.8.dist-info → execsql2-2.16.13.dist-info}/WHEEL +0 -0
  27. {execsql2-2.16.8.dist-info → execsql2-2.16.13.dist-info}/entry_points.txt +0 -0
  28. {execsql2-2.16.8.dist-info → execsql2-2.16.13.dist-info}/licenses/LICENSE.txt +0 -0
  29. {execsql2-2.16.8.dist-info → execsql2-2.16.13.dist-info}/licenses/NOTICE +0 -0
execsql/debug/repl.py CHANGED
@@ -111,6 +111,8 @@ _HELP_COMMANDS = [
111
111
  (".where", ".w", "Show the current script location and upcoming statement"),
112
112
  (".stack", "", "Show the command-list stack (script name, line, depth)"),
113
113
  (".set VAR VAL", ".s", "Set or update a substitution variable"),
114
+ (".scripts", "", "List all registered SCRIPT definitions"),
115
+ (".scripts NAME", "", "Show detail for a specific SCRIPT"),
114
116
  (".help", ".h", "Show this help text"),
115
117
  ]
116
118
 
@@ -290,6 +292,12 @@ def _handle_dot_command(line: str) -> None:
290
292
  varname = parts[0]
291
293
  value = parts[1] if len(parts) > 1 else ""
292
294
  _set_var(varname, value)
295
+ elif cmd.startswith("scripts"):
296
+ rest = cmd[7:].strip()
297
+ if rest:
298
+ _print_script_detail(rest)
299
+ else:
300
+ _print_scripts()
293
301
  else:
294
302
  _write(f" {_c(_RED, 'Unknown command:')} {line!r}. Type '.help' for available commands.\n")
295
303
 
@@ -507,3 +515,54 @@ def _set_var(varname: str, value: str) -> None:
507
515
  else:
508
516
  subvars.add_substitution(varname, value)
509
517
  _write(f" {_c(_CYAN, varname)} {_c(_DIM, '=')} {value}\n")
518
+
519
+
520
+ def _print_scripts() -> None:
521
+ """Print all registered SCRIPT definitions."""
522
+ from execsql.metacommands.debug import _format_script_signature, _format_script_source
523
+
524
+ scripts = _state.ast_scripts
525
+ if not scripts:
526
+ _write(" (no scripts registered)\n\n")
527
+ return
528
+ _write_rule(f" {_c(_BOLD + _YELLOW, 'Scripts')} {_c(_DIM, f'({len(scripts)})')} ")
529
+ sigs = {name: _format_script_signature(name, block.param_defs) for name, block in scripts.items()}
530
+ max_sig = max(len(s) for s in sigs.values())
531
+ for name, block in scripts.items():
532
+ sig = sigs[name]
533
+ src = _format_script_source(block.span)
534
+ _write(f" {_c(_CYAN, sig):<{max_sig + len(_CYAN) + len(_RESET)}} {_c(_DIM, src)}\n")
535
+ _write("\n")
536
+
537
+
538
+ def _print_script_detail(name: str) -> None:
539
+ """Print detail for a single SCRIPT definition."""
540
+ from execsql.metacommands.debug import _format_script_signature, _format_script_source
541
+
542
+ script_name = name.lower()
543
+ scripts = _state.ast_scripts
544
+ if script_name not in scripts:
545
+ _write(f" {_c(_RED, 'No script named')} {_c(_CYAN, repr(script_name))} {_c(_RED, 'is registered.')}\n")
546
+ return
547
+ block = scripts[script_name]
548
+ sig = _format_script_signature(block.name, block.param_defs)
549
+ src = _format_script_source(block.span)
550
+ _write_rule(f" {_c(_BOLD + _YELLOW, 'Script')} {_c(_DIM, '──')} {_c(_CYAN, sig)} ")
551
+ _write(f" {_c(_BOLD, 'Source:')} {src}\n")
552
+ if block.param_defs:
553
+ _write(f" {_c(_BOLD, 'Parameters:')}\n")
554
+ max_name = max(len(p.name) for p in block.param_defs)
555
+ for p in block.param_defs:
556
+ if p.default is not None:
557
+ _write(
558
+ f" {_c(_CYAN, p.name):<{max_name + len(_CYAN) + len(_RESET)}} {_c(_DIM, f'(optional, default: {p.default})')}\n",
559
+ )
560
+ else:
561
+ _write(f" {_c(_CYAN, p.name):<{max_name + len(_CYAN) + len(_RESET)}} {_c(_DIM, '(required)')}\n")
562
+ else:
563
+ _write(f" {_c(_BOLD, 'Parameters:')} (none)\n")
564
+ if block.doc:
565
+ _write("\n")
566
+ for doc_line in block.doc.split("\n"):
567
+ _write(f" {_c(_DIM, doc_line)}\n")
568
+ _write("\n")
execsql/format.py CHANGED
@@ -73,6 +73,8 @@ MULTIWORD_KEYWORDS = [
73
73
  "PROMPT ASK",
74
74
  "WITH TEMPLATE",
75
75
  "IN ZIPFILE",
76
+ "SHOW SCRIPTS",
77
+ "SHOW SCRIPT",
76
78
  ]
77
79
 
78
80
  # Depth-tracking sets
@@ -146,22 +148,66 @@ def _is_comment_line(line: str, in_block: bool) -> tuple[bool, bool]:
146
148
  return False, False
147
149
 
148
150
 
149
- def _sqlglot_format(sql_lines: list[str]) -> list[str]:
151
+ def _sqlglot_format(
152
+ sql_lines: list[str],
153
+ sql_indent: int = 4,
154
+ leading_comma: bool = False,
155
+ ) -> list[str]:
150
156
  """Format a list of SQL-only lines (no comment-only lines) via sqlglot."""
151
157
  text = "\n".join(sql_lines)
152
158
  protected, replacements = _protect_variables(text)
159
+
160
+ # Count semicolons in input as a rough statement count.
161
+ input_semis = protected.count(";")
162
+
153
163
  try:
154
164
  with contextlib.redirect_stderr(io.StringIO()):
155
165
  ast = sqlglot.parse(protected, read="postgres", error_level=sqlglot.errors.ErrorLevel.IGNORE)
156
- statements = [
157
- node.sql(dialect="postgres", pretty=True)
158
- for node in ast
159
- if node is not None and type(node).__name__ != "Command"
160
- ]
166
+ statements: list[str] = []
167
+ for node in ast:
168
+ if node is None:
169
+ continue
170
+ # For Command nodes (psql backslash commands, ERROR:, etc.)
171
+ # use unpretty output to avoid mangling.
172
+ if type(node).__name__ == "Command":
173
+ statements.append(node.sql(dialect="postgres"))
174
+ else:
175
+ statements.append(
176
+ node.sql(
177
+ dialect="postgres",
178
+ pretty=True,
179
+ pad=sql_indent,
180
+ indent=sql_indent,
181
+ max_text_width=120,
182
+ leading_comma=leading_comma,
183
+ ),
184
+ )
161
185
  stmts = [s for s in statements if s]
162
186
  if not stmts:
163
187
  return sql_lines
188
+
189
+ # Safety: if sqlglot produced more statements than the input had
190
+ # semicolons, it likely split a fragment (e.g. a SELECT column list)
191
+ # into multiple pseudo-statements. Fall back to the original text.
192
+ if len(stmts) > max(input_semis, 1):
193
+ return sql_lines
194
+
164
195
  joined = ";\n".join(stmts) + ";"
196
+
197
+ # Content-loss check: sqlglot with IGNORE error level can silently
198
+ # drop tokens it doesn't understand (e.g. ``ERROR: ...``). If the
199
+ # formatted output lost a significant fraction of the alphanumeric
200
+ # content, the formatting is unreliable — fall back.
201
+ # Exclude comment markers from the comparison — they are injected by
202
+ # _format_preserving_comments and are expected to be dropped by sqlglot
203
+ # for certain AST positions (e.g. inside CASE WHEN).
204
+ _alnum = re.compile(r"[^a-zA-Z0-9]")
205
+ _marker_alnum = re.compile(rf"{re.escape(_CMT_MARKER)}\d+")
206
+ input_for_check = _marker_alnum.sub("", protected)
207
+ input_alnum_len = len(_alnum.sub("", input_for_check))
208
+ output_alnum_len = len(_alnum.sub("", joined))
209
+ if input_alnum_len and output_alnum_len < input_alnum_len * 0.7:
210
+ return sql_lines
165
211
  joined = re.sub(r"\bINTO TEMPORARY\b(?!\s+TABLE)", "INTO TEMPORARY TABLE", joined)
166
212
  return _restore_variables(joined, replacements).split("\n")
167
213
  except Exception:
@@ -173,7 +219,194 @@ def _sqlglot_format(sql_lines: list[str]) -> list[str]:
173
219
  # ---------------------------------------------------------------------------
174
220
 
175
221
 
176
- def format_sql_block(lines: list[str], depth: int, indent: int, use_sql: bool) -> list[str]:
222
+ def _has_mid_statement_comments(lines: list[str]) -> bool:
223
+ """Return True if any comment-only line appears inside a SQL statement.
224
+
225
+ A comment is "mid-statement" if it occurs after a SQL line that does not
226
+ end with ``;`` (i.e. the statement is still open). This is a lightweight
227
+ heuristic — it can be fooled by ``;`` inside string literals, but in that
228
+ case the block simply gets the benefit of sqlglot formatting rather than
229
+ being skipped (which is harmless because the SQL isn't fragmented).
230
+ """
231
+ in_block = False
232
+ in_statement = False
233
+ for line in lines:
234
+ is_comment, in_block = _is_comment_line(line, in_block)
235
+ stripped = line.strip()
236
+ if not stripped:
237
+ continue
238
+ if is_comment:
239
+ if in_statement:
240
+ return True
241
+ else:
242
+ in_statement = True
243
+ if stripped.endswith(";"):
244
+ in_statement = False
245
+ return False
246
+
247
+
248
+ _CMT_MARKER = "EXECSQL_CMTMARKER_"
249
+ _CMT_MARKER_RE = re.compile(rf"/\*\s*({re.escape(_CMT_MARKER)}\d+)\s*\*/")
250
+
251
+
252
+ def _format_preserving_comments(
253
+ lines: list[str],
254
+ sql_indent: int = 4,
255
+ leading_comma: bool = False,
256
+ ) -> list[str]:
257
+ """Format SQL with interleaved comments via marker-based round-tripping.
258
+
259
+ Strategy
260
+ --------
261
+ 1. Replace each comment-only line with a unique inline ``/* marker */``
262
+ prepended to the *next* SQL line. This lets sqlglot see the full
263
+ statement without fragmentation while preserving comment anchors.
264
+ 2. Format the marker-annotated SQL through ``_sqlglot_format``.
265
+ 3. Walk the formatted output: wherever a marker appears, emit the
266
+ original comment on its own line **before** that SQL line, then
267
+ strip the marker from the SQL.
268
+ 4. Any marker that sqlglot dropped (e.g. inside a CASE expression)
269
+ is re-inserted by matching key tokens from its anchor SQL line
270
+ against the formatted output.
271
+ """
272
+ # ---- Step 1: extract comments, replace with inline markers ----------
273
+ comment_store: dict[str, str] = {} # marker → original comment line
274
+ # Track the SQL line that originally followed each comment, for fallback
275
+ anchor_sql: dict[str, str] = {} # marker → next SQL line (stripped)
276
+ pending_markers: list[str] = []
277
+ processed: list[str] = []
278
+ in_block = False
279
+
280
+ for line in lines:
281
+ is_comment, in_block = _is_comment_line(line, in_block)
282
+ stripped = line.strip()
283
+ if not stripped:
284
+ # Blank lines: if we have pending markers, attach blanks as
285
+ # comment entries so they reappear in the right place.
286
+ if pending_markers:
287
+ mid = f"{_CMT_MARKER}{len(comment_store)}"
288
+ comment_store[mid] = line
289
+ pending_markers.append(mid)
290
+ else:
291
+ processed.append(line)
292
+ elif is_comment:
293
+ mid = f"{_CMT_MARKER}{len(comment_store)}"
294
+ comment_store[mid] = line
295
+ pending_markers.append(mid)
296
+ else:
297
+ # SQL line — prepend any pending markers as inline comments
298
+ prefix = " ".join(f"/* {m} */" for m in pending_markers)
299
+ processed.append(f"{prefix} {line}" if prefix else line)
300
+ for m in pending_markers:
301
+ anchor_sql[m] = stripped
302
+ pending_markers.clear()
303
+
304
+ # Trailing comments with no following SQL — preserve as-is
305
+ trailing: list[str] = [comment_store[m] for m in pending_markers]
306
+
307
+ # ---- Step 2: format through sqlglot ---------------------------------
308
+ formatted = _sqlglot_format(processed, sql_indent=sql_indent, leading_comma=leading_comma)
309
+
310
+ # ---- Step 3: restore surviving markers to comment lines -------------
311
+ found_markers: set[str] = set()
312
+ result: list[str] = []
313
+ for fline in formatted:
314
+ markers_here = _CMT_MARKER_RE.findall(fline)
315
+ if markers_here:
316
+ # Strip markers to get the underlying SQL line and its indent
317
+ cleaned = _CMT_MARKER_RE.sub("", fline).strip()
318
+ # Determine indent: use the SQL line's indent from sqlglot
319
+ sql_indent = ""
320
+ if cleaned:
321
+ raw_cleaned = _CMT_MARKER_RE.sub("", fline)
322
+ sql_indent = raw_cleaned[: len(raw_cleaned) - len(raw_cleaned.lstrip())]
323
+ for m in markers_here:
324
+ if m in comment_store:
325
+ orig = comment_store[m]
326
+ # Re-indent the comment to match the SQL line it precedes
327
+ orig_stripped = orig.strip()
328
+ if orig_stripped:
329
+ result.append(sql_indent + orig_stripped)
330
+ else:
331
+ result.append("")
332
+ found_markers.add(m)
333
+ if cleaned:
334
+ result.append(sql_indent + cleaned)
335
+ else:
336
+ result.append(fline)
337
+
338
+ # ---- Step 4: reinsert lost markers ----------------------------------
339
+ lost = [m for m in comment_store if m not in found_markers and m not in set(pending_markers)]
340
+ if lost:
341
+ _reinsert_lost_comments(result, lost, comment_store, anchor_sql)
342
+
343
+ result.extend(trailing)
344
+ return result
345
+
346
+
347
+ def _reinsert_lost_comments(
348
+ result: list[str],
349
+ lost_markers: list[str],
350
+ comment_store: dict[str, str],
351
+ anchor_sql: dict[str, str],
352
+ ) -> None:
353
+ """Best-effort reinsertion of comments that sqlglot dropped.
354
+
355
+ For each lost comment, extract key tokens from its anchor SQL line and
356
+ find the output line that best matches, then insert the comment before
357
+ that line (indented to match). Operates on *result* in place.
358
+ """
359
+ _word_re = re.compile(r"[a-zA-Z_]\w*")
360
+
361
+ # Process in reverse order so earlier inserts don't shift later indices.
362
+ insertions: list[tuple[int, str]] = []
363
+ for marker in lost_markers:
364
+ anchor = anchor_sql.get(marker, "")
365
+ orig = comment_store[marker]
366
+ orig_stripped = orig.strip()
367
+ if not anchor or not orig_stripped:
368
+ insertions.append((len(result), orig))
369
+ continue
370
+
371
+ anchor_words = [w.lower() for w in _word_re.findall(anchor)]
372
+ if not anchor_words:
373
+ insertions.append((len(result), orig))
374
+ continue
375
+
376
+ # Find the output line with the best token overlap
377
+ best_idx = len(result)
378
+ best_score = 0
379
+ for i, rline in enumerate(result):
380
+ rwords = {w.lower() for w in _word_re.findall(rline)}
381
+ score = sum(1 for w in anchor_words if w in rwords)
382
+ if score > best_score:
383
+ best_score = score
384
+ best_idx = i
385
+
386
+ # Re-indent comment to match the target line
387
+ if best_idx < len(result):
388
+ target = result[best_idx]
389
+ indent_str = target[: len(target) - len(target.lstrip())]
390
+ else:
391
+ indent_str = ""
392
+ insertions.append((best_idx, indent_str + orig_stripped))
393
+
394
+ # Sort descending by index; within the same index, reverse the
395
+ # original order so sequential result.insert() calls produce the
396
+ # correct final ordering (last inserted at a given index ends up first).
397
+ indexed = list(enumerate(insertions))
398
+ indexed.sort(key=lambda x: (x[1][0], x[0]), reverse=True)
399
+ for _, (idx, text) in indexed:
400
+ result.insert(idx, text)
401
+
402
+
403
+ def format_sql_block(
404
+ lines: list[str],
405
+ depth: int,
406
+ indent: int,
407
+ use_sql: bool,
408
+ leading_comma: bool = False,
409
+ ) -> list[str]:
177
410
  """Re-indent a SQL block to the current depth, optionally formatting via sqlglot."""
178
411
  if not lines:
179
412
  return lines
@@ -189,6 +422,13 @@ def format_sql_block(lines: list[str], depth: int, indent: int, use_sql: bool) -
189
422
  if not use_sql:
190
423
  return [target_prefix + line if line.strip() else "" for line in rebased]
191
424
 
425
+ # When comments appear mid-statement, use the marker-based approach
426
+ # which preserves both comments AND sqlglot formatting. When all
427
+ # comments are between statements, the simpler segmentation works.
428
+ if _has_mid_statement_comments(rebased):
429
+ result = _format_preserving_comments(rebased, sql_indent=indent, leading_comma=leading_comma)
430
+ return [target_prefix + line if line.strip() else "" for line in result]
431
+
192
432
  result: list[str] = []
193
433
  seg: list[str] = []
194
434
  seg_is_comment: bool | None = None
@@ -200,7 +440,7 @@ def format_sql_block(lines: list[str], depth: int, indent: int, use_sql: bool) -
200
440
  if seg_is_comment:
201
441
  result.extend(seg)
202
442
  else:
203
- result.extend(_sqlglot_format(seg))
443
+ result.extend(_sqlglot_format(seg, sql_indent=indent, leading_comma=leading_comma))
204
444
  seg.clear()
205
445
 
206
446
  for line in rebased:
@@ -236,34 +476,58 @@ def format_metacommand(payload: str, depth: int, indent: int) -> str:
236
476
  return f"{prefix}-- !x! {keyword}"
237
477
 
238
478
 
239
- def format_file(source: str, indent: int = 4, use_sql: bool = True) -> str:
479
+ def format_file(source: str, indent: int = 4, use_sql: bool = True, leading_comma: bool = False) -> str:
240
480
  """Format the source text of an execsql script and return the result."""
241
481
  depth = 0
242
482
  sql_acc: list[str] = []
243
483
  output: list[str] = []
244
484
 
245
485
  in_dollar_quote = False
486
+ in_block_comment = False
487
+ # Track whether we are inside an open SQL statement (last SQL line
488
+ # did not end with ';'). Blank lines mid-statement should NOT flush
489
+ # the accumulator — doing so would split a single statement into
490
+ # fragments that sqlglot cannot parse.
491
+ in_sql_statement = False
246
492
 
247
493
  def flush_sql() -> None:
248
- nonlocal in_dollar_quote
494
+ nonlocal in_dollar_quote, in_sql_statement
249
495
  if sql_acc:
250
496
  # If any line in the accumulated block is inside a $$-delimited
251
497
  # region, skip sqlglot formatting entirely. PL/pgSQL function
252
498
  # bodies contain IF/END IF, LOOP, RETURN, etc. that sqlglot does
253
499
  # not understand and will corrupt (e.g., rewriting to COMMIT).
254
500
  safe_for_sqlglot = use_sql and not in_dollar_quote
255
- output.extend(format_sql_block(sql_acc, depth, indent, safe_for_sqlglot))
501
+ output.extend(format_sql_block(sql_acc, depth, indent, safe_for_sqlglot, leading_comma=leading_comma))
256
502
  sql_acc.clear()
503
+ in_sql_statement = False
257
504
 
258
505
  for raw_line in source.expandtabs(4).splitlines():
506
+ stripped_line = raw_line.strip()
507
+
508
+ # Track /* */ block comment boundaries (but not inside $$ regions).
509
+ # Lines inside block comments must not be processed as metacommands.
510
+ if not in_dollar_quote:
511
+ if in_block_comment:
512
+ sql_acc.append(raw_line)
513
+ if "*/" in raw_line:
514
+ in_block_comment = False
515
+ continue
516
+ if stripped_line.startswith("/*") and "*/" not in stripped_line[2:]:
517
+ in_block_comment = True
518
+ sql_acc.append(raw_line)
519
+ continue
520
+
259
521
  m = METACOMMAND_RE.match(raw_line)
260
522
 
261
- if not raw_line.strip():
262
- if not in_dollar_quote:
523
+ if not stripped_line:
524
+ if not in_dollar_quote and not in_sql_statement:
263
525
  flush_sql()
526
+ output.append("")
264
527
  else:
528
+ # Mid-statement blank line stays in the accumulator and
529
+ # will appear in the output when the block is formatted.
265
530
  sql_acc.append(raw_line)
266
- output.append("")
267
531
 
268
532
  elif m:
269
533
  flush_sql()
@@ -294,6 +558,12 @@ def format_file(source: str, indent: int = 4, use_sql: bool = True) -> str:
294
558
  if "$$" in raw_line and raw_line.count("$$") % 2 == 1:
295
559
  in_dollar_quote = not in_dollar_quote
296
560
  sql_acc.append(raw_line)
561
+ # Update statement tracking: if this SQL line ends with ';'
562
+ # (and isn't a comment), the statement is complete.
563
+ if stripped_line.endswith(";") and not stripped_line.startswith("--"):
564
+ in_sql_statement = False
565
+ elif not stripped_line.startswith("--"):
566
+ in_sql_statement = True
297
567
 
298
568
  flush_sql()
299
569
 
@@ -348,6 +618,11 @@ def main() -> None:
348
618
  in_place: bool = typer.Option(False, "-i", "--in-place", help="Modify files in place."),
349
619
  no_sql: bool = typer.Option(False, "--no-sql", help="Skip SQL formatting via sqlglot."),
350
620
  indent: int = typer.Option(4, "--indent", metavar="N", help="Spaces per indent level."),
621
+ leading_comma: bool = typer.Option(
622
+ False,
623
+ "--leading-comma",
624
+ help="Place commas at the start of lines instead of the end.",
625
+ ),
351
626
  ) -> None:
352
627
  use_sql = not no_sql
353
628
  paths = collect_paths(targets)
@@ -363,7 +638,7 @@ def main() -> None:
363
638
  _err_console.print(f"[bold red]Error:[/bold red] reading {path}: {exc}")
364
639
  raise typer.Exit(code=1) from None
365
640
 
366
- formatted = format_file(source, indent=indent, use_sql=use_sql)
641
+ formatted = format_file(source, indent=indent, use_sql=use_sql, leading_comma=leading_comma)
367
642
 
368
643
  if check:
369
644
  if formatted != source:
@@ -1,5 +1,4 @@
1
1
  from __future__ import annotations
2
- from execsql.utils.errors import fatal_error
3
2
 
4
3
  """
5
4
  Debug metacommand handlers for execsql.
@@ -7,11 +6,16 @@ Debug metacommand handlers for execsql.
7
6
  Provides ``x_debug_write_metacommands``, which implements the
8
7
  ``WRITE METACOMMANDS`` debug metacommand that prints the full registered
9
8
  metacommand list to the log/console for troubleshooting.
9
+
10
+ Also provides ``x_show_scripts`` and ``x_show_script`` for runtime
11
+ introspection of registered SCRIPT blocks.
10
12
  """
11
13
 
14
+ from pathlib import Path
12
15
  from typing import Any
13
16
 
14
17
  import execsql.state as _state
18
+ from execsql.utils.errors import fatal_error
15
19
  from execsql.utils.fileio import EncodedFile, filewriter_open_as_new, filewriter_write
16
20
 
17
21
 
@@ -173,3 +177,87 @@ def x_debug_write_config(**kwargs: Any) -> None:
173
177
 
174
178
  for line in lines:
175
179
  write(f"{line}\n")
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Helpers for SCRIPT introspection (shared by metacommands and REPL)
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ def _format_script_signature(name: str, param_defs: Any) -> str:
188
+ """Return ``name(param1, param2, opt=default)`` or ``name()``.
189
+
190
+ *param_defs* may be a list of :class:`ParamDef` objects (preferred) or
191
+ a plain list of strings (backward compat).
192
+ """
193
+ if not param_defs:
194
+ return f"{name}()"
195
+ parts: list[str] = []
196
+ for p in param_defs:
197
+ if hasattr(p, "default") and p.default is not None:
198
+ parts.append(f"{p.name}={p.default}")
199
+ elif hasattr(p, "name"):
200
+ parts.append(p.name)
201
+ else:
202
+ parts.append(str(p))
203
+ return f"{name}({', '.join(parts)})"
204
+
205
+
206
+ def _format_script_source(span: Any) -> str:
207
+ """Return ``file:start-end`` from a SourceSpan."""
208
+ filename = Path(span.file).name if span and span.file else "<unknown>"
209
+ if span and span.start_line is not None:
210
+ if span.end_line is not None and span.end_line != span.start_line:
211
+ return f"{filename}:{span.start_line}-{span.end_line}"
212
+ return f"{filename}:{span.start_line}"
213
+ return filename
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # SHOW SCRIPTS / SHOW SCRIPT metacommand handlers
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ def x_show_scripts(**kwargs: Any) -> None:
222
+ """List all registered SCRIPT definitions with parameters and source location."""
223
+ scripts = _state.ast_scripts
224
+ if not scripts:
225
+ _state.output.write("No scripts registered.\n")
226
+ return
227
+ _state.output.write(f"Registered scripts ({len(scripts)}):\n\n")
228
+ # Compute column width for alignment
229
+ sigs = {name: _format_script_signature(name, block.param_defs) for name, block in scripts.items()}
230
+ max_sig = max(len(s) for s in sigs.values())
231
+ for name, block in scripts.items():
232
+ sig = sigs[name]
233
+ src = _format_script_source(block.span)
234
+ _state.output.write(f" {sig:<{max_sig}} {src}\n")
235
+ _state.output.write("\n")
236
+
237
+
238
+ def x_show_script(**kwargs: Any) -> None:
239
+ """Show detail for a single registered SCRIPT definition."""
240
+ script_name = kwargs.get("script_id", "").lower()
241
+ scripts = _state.ast_scripts
242
+ if script_name not in scripts:
243
+ _state.output.write(f"No script named '{script_name}' is registered.\n")
244
+ return
245
+ block = scripts[script_name]
246
+ sig = _format_script_signature(block.name, block.param_defs)
247
+ src = _format_script_source(block.span)
248
+ _state.output.write(f"Script: {sig}\n")
249
+ _state.output.write(f"Source: {src}\n")
250
+ if block.param_defs:
251
+ _state.output.write("Parameters:\n")
252
+ max_name = max(len(p.name) for p in block.param_defs)
253
+ for p in block.param_defs:
254
+ if p.default is not None:
255
+ _state.output.write(f" {p.name:<{max_name}} (optional, default: {p.default})\n")
256
+ else:
257
+ _state.output.write(f" {p.name:<{max_name}} (required)\n")
258
+ else:
259
+ _state.output.write("Parameters: (none)\n")
260
+ if block.doc:
261
+ _state.output.write("\n")
262
+ for doc_line in block.doc.split("\n"):
263
+ _state.output.write(f" {doc_line}\n")
@@ -99,6 +99,8 @@ from execsql.metacommands.debug import (
99
99
  x_debug_write_metacommands,
100
100
  x_debug_write_odbc_drivers,
101
101
  x_debug_write_subvars,
102
+ x_show_script,
103
+ x_show_scripts,
102
104
  )
103
105
  from execsql.debug.repl import x_breakpoint
104
106
  from execsql.metacommands.io import (
@@ -1747,6 +1749,22 @@ def build_dispatch_table() -> MetaCommandList:
1747
1749
  run_when_false=False,
1748
1750
  )
1749
1751
 
1752
+ # ------------------------------------------------------------------
1753
+ # SHOW SCRIPTS / SHOW SCRIPT
1754
+ # ------------------------------------------------------------------
1755
+ mcl.add(
1756
+ r"^\s*SHOW\s+SCRIPTS\s*$",
1757
+ x_show_scripts,
1758
+ description="SHOW SCRIPTS",
1759
+ category="action",
1760
+ )
1761
+ mcl.add(
1762
+ r"^\s*SHOW\s+SCRIPT\s+(?P<script_id>\w+)\s*$",
1763
+ x_show_script,
1764
+ description="SHOW SCRIPT",
1765
+ category="action",
1766
+ )
1767
+
1750
1768
  # ------------------------------------------------------------------
1751
1769
  # IF / ORIF / ANDIF / ELSEIF / ELSE / ENDIF
1752
1770
  # ------------------------------------------------------------------
execsql/script/ast.py CHANGED
@@ -52,6 +52,7 @@ __all__ = [
52
52
  "IfBlock",
53
53
  "LoopBlock",
54
54
  "BatchBlock",
55
+ "ParamDef",
55
56
  "ScriptBlock",
56
57
  "SqlBlock",
57
58
  "IncludeDirective",
@@ -290,6 +291,24 @@ class BatchBlock(Node):
290
291
  return f"BatchBlock({self.span}, body={len(self.body)})"
291
292
 
292
293
 
294
+ @dataclass(frozen=True, slots=True)
295
+ class ParamDef:
296
+ """A single SCRIPT parameter definition with an optional default value.
297
+
298
+ Attributes:
299
+ name: The parameter name (as declared, without ``#`` prefix).
300
+ default: The default value string, or ``None`` for required parameters.
301
+ """
302
+
303
+ name: str
304
+ default: str | None = None
305
+
306
+ @property
307
+ def required(self) -> bool:
308
+ """Return ``True`` if this parameter has no default value."""
309
+ return self.default is None
310
+
311
+
293
312
  @dataclass
294
313
  class ScriptBlock(Node):
295
314
  """A BEGIN SCRIPT name ... END SCRIPT structure.
@@ -299,20 +318,31 @@ class ScriptBlock(Node):
299
318
 
300
319
  Attributes:
301
320
  name: The script block name (lowercased).
302
- param_names: Optional list of formal parameter names.
321
+ param_defs: Optional list of :class:`ParamDef` parameter definitions.
322
+ doc: Optional docstring extracted from comments immediately following
323
+ the BEGIN SCRIPT line.
303
324
  body: Nodes within the script block.
304
325
  """
305
326
 
306
327
  name: str
307
- param_names: list[str] | None = None
328
+ param_defs: list[ParamDef] | None = None
329
+ doc: str | None = None
308
330
  body: list[Node] = field(default_factory=list)
309
331
 
332
+ @property
333
+ def param_names(self) -> list[str] | None:
334
+ """Return parameter names only (backward compatibility)."""
335
+ if self.param_defs is None:
336
+ return None
337
+ return [p.name for p in self.param_defs]
338
+
310
339
  def children(self) -> Iterator[Node]:
311
340
  yield from self.body
312
341
 
313
342
  def __repr__(self) -> str:
314
- params = f", params={self.param_names}" if self.param_names else ""
315
- return f"ScriptBlock({self.span}, name={self.name!r}{params}, body={len(self.body)})"
343
+ params = f", params={self.param_names}" if self.param_defs else ""
344
+ doc_tag = ", doc=True" if self.doc else ""
345
+ return f"ScriptBlock({self.span}, name={self.name!r}{params}{doc_tag}, body={len(self.body)})"
316
346
 
317
347
 
318
348
  @dataclass
@@ -547,8 +577,15 @@ def _node_label(node: Node) -> str:
547
577
  if isinstance(node, BatchBlock):
548
578
  return f"{_tag('BATCH')} BEGIN BATCH"
549
579
  if isinstance(node, ScriptBlock):
550
- params = f" ({', '.join(node.param_names)})" if node.param_names else ""
551
- return f"{_tag('SCRIPT')} {node.name}{params}"
580
+ if node.param_defs:
581
+ parts = []
582
+ for p in node.param_defs:
583
+ parts.append(f"{p.name}={p.default}" if p.default is not None else p.name)
584
+ params = f" ({', '.join(parts)})"
585
+ else:
586
+ params = ""
587
+ doc_tag = " [doc]" if node.doc else ""
588
+ return f"{_tag('SCRIPT')} {node.name}{params}{doc_tag}"
552
589
  if isinstance(node, SqlBlock):
553
590
  return f"{_tag('SQL_BLK')} BEGIN SQL"
554
591
  if isinstance(node, IncludeDirective):
@@ -648,7 +648,10 @@ def _execute_include(
648
648
  WHILE/UNTIL loops are handled natively too.
649
649
  """
650
650
  if node.is_execute_script:
651
- target = node.target.lower()
651
+ # Substitute variables in the target the script name may be passed
652
+ # as a parameter (e.g., EXECUTE SCRIPT !!#script_name!!).
653
+ effective_locals = _stack_localvars(ctx) or localvars
654
+ target = substitute_vars(node.target, effective_locals, ctx=ctx).strip().lower()
652
655
 
653
656
  # Native path: target is in our AST registry
654
657
  if target in ctx.ast_scripts:
@@ -713,23 +716,35 @@ def _execute_script_native(
713
716
  for param, arg in all_prepared_args:
714
717
  paramvals.add_substitution(param, arg)
715
718
 
716
- # Validate parameter names match
717
- if script_block.param_names is not None:
719
+ # Validate parameter names match — with default parameter support
720
+ if script_block.param_defs is not None:
718
721
  passed_names = [p[0].lstrip("#") for p in all_prepared_args]
719
- if not all(p in passed_names for p in script_block.param_names):
722
+ required = [p.name for p in script_block.param_defs if p.required]
723
+ missing = [p for p in required if p not in passed_names]
724
+ if missing:
720
725
  raise ErrInfo(
721
726
  "error",
722
- other_msg=f"Formal and actual parameter name mismatch in call to {script_block.name}.",
727
+ other_msg=(f"Missing required parameter(s) ({', '.join(missing)}) in call to {script_block.name}."),
723
728
  )
729
+ # Inject defaults for optional params not provided
730
+ for pdef in script_block.param_defs:
731
+ if pdef.default is not None and pdef.name not in passed_names:
732
+ paramvals.add_substitution(f"#{pdef.name}", pdef.default)
724
733
  else:
725
- if script_block.param_names is not None:
726
- raise ErrInfo(
727
- "error",
728
- other_msg=(
729
- f"Missing expected parameters ({', '.join(script_block.param_names)}) "
730
- f"in call to {script_block.name}."
731
- ),
732
- )
734
+ if script_block.param_defs is not None:
735
+ required = [p.name for p in script_block.param_defs if p.required]
736
+ if required:
737
+ raise ErrInfo(
738
+ "error",
739
+ other_msg=(
740
+ f"Missing required parameter(s) ({', '.join(required)}) in call to {script_block.name}."
741
+ ),
742
+ )
743
+ # No args provided but all params have defaults — inject them all
744
+ paramvals = ScriptArgSubVarSet()
745
+ for pdef in script_block.param_defs:
746
+ if pdef.default is not None:
747
+ paramvals.add_substitution(f"#{pdef.name}", pdef.default)
733
748
 
734
749
  # Push a CommandList frame onto the stack so that:
735
750
  # - get_subvarset() can find ~local and +outer-scope variables
execsql/script/parser.py CHANGED
@@ -42,6 +42,7 @@ from execsql.script.ast import (
42
42
  LoopBlock,
43
43
  MetaCommandStatement,
44
44
  Node,
45
+ ParamDef,
45
46
  Script,
46
47
  ScriptBlock,
47
48
  SourceSpan,
@@ -106,7 +107,7 @@ def _strip_quotes(s: str) -> str:
106
107
  _EXEC_SCRIPT_RX = re.compile(
107
108
  r"^\s*(?:EXEC(?:UTE)?|RUN)\s+SCRIPT"
108
109
  r"(?P<exists>\s+IF\s+EXISTS)?"
109
- r"\s+(?P<script_id>\w+)"
110
+ r"\s+(?P<script_id>(?:\w+|!(?:['\"]?)![^!]+!(?:['\"]?)!))"
110
111
  r"(?:(?:\s+WITH)?(?:\s+ARG(?:UMENT)?S?)?\s*\(\s*(?P<argexp>.+?)\s*\))?"
111
112
  r"(?:\s+(?P<looptype>WHILE|UNTIL)\s*\(\s*(?P<loopcond>.+)\s*\))?"
112
113
  r"\s*$",
@@ -114,10 +115,52 @@ _EXEC_SCRIPT_RX = re.compile(
114
115
  )
115
116
 
116
117
  _WITH_PARAMS_RX = re.compile(
117
- r"(?:\s+WITH)?(?:\s+PARAM(?:ETER)?S)?\s*\(\s*(?P<params>\w+(?:\s*,\s*\w+)*)\s*\)\s*$",
118
+ r"(?:\s+WITH)?(?:\s+PARAM(?:ETER)?S)?\s*\(\s*(?P<params>"
119
+ r"\w+(?:\s*=\s*\S+)?(?:\s*,\s*\w+(?:\s*=\s*\S+)?)*"
120
+ r")\s*\)\s*$",
118
121
  re.I,
119
122
  )
120
123
 
124
+ _PARAM_TOKEN_RX = re.compile(r"(\w+)(?:\s*=\s*(\S+))?")
125
+
126
+
127
+ def _parse_param_defs(
128
+ params_str: str,
129
+ lineno: int,
130
+ source: str,
131
+ ) -> list[ParamDef]:
132
+ """Parse ``'a, b, c=100, d=false'`` into a list of :class:`ParamDef`.
133
+
134
+ Required parameters (no default) must precede optional parameters
135
+ (with default). Raises :class:`ErrInfo` if ordering is violated.
136
+ """
137
+ tokens = [t.strip() for t in params_str.split(",")]
138
+ defs: list[ParamDef] = []
139
+ seen_optional: str | None = None # name of first optional param
140
+ for token in tokens:
141
+ m = _PARAM_TOKEN_RX.match(token.strip())
142
+ if not m:
143
+ raise ErrInfo(
144
+ type="cmd",
145
+ other_msg=(f"Invalid parameter token '{token}' on line {lineno} of {source}."),
146
+ )
147
+ name, default = m.group(1), m.group(2)
148
+ if default is not None:
149
+ if seen_optional is None:
150
+ seen_optional = name
151
+ elif seen_optional is not None:
152
+ raise ErrInfo(
153
+ type="cmd",
154
+ other_msg=(
155
+ f"Required parameter '{name}' after optional parameter "
156
+ f"'{seen_optional}' on line {lineno} of {source}. "
157
+ f"Required parameters must precede optional parameters."
158
+ ),
159
+ )
160
+ defs.append(ParamDef(name=name, default=default))
161
+ return defs
162
+
163
+
121
164
  # Line classification
122
165
  _EXEC_LINE_RX = re.compile(r"^\s*--\s*!x!\s*(?P<cmd>.+)$", re.I)
123
166
  _COMMENT_LINE_RX = re.compile(r"^\s*--")
@@ -135,7 +178,16 @@ class _BlockFrame:
135
178
  to close the block correctly.
136
179
  """
137
180
 
138
- __slots__ = ("node", "kind", "start_line", "source", "_in_else", "_in_elseif")
181
+ __slots__ = (
182
+ "node",
183
+ "kind",
184
+ "start_line",
185
+ "source",
186
+ "_in_else",
187
+ "_in_elseif",
188
+ "collecting_doc",
189
+ "doc_lines",
190
+ )
139
191
 
140
192
  def __init__(self, node: Node, kind: str, start_line: int, source: str) -> None:
141
193
  self.node = node
@@ -144,6 +196,8 @@ class _BlockFrame:
144
196
  self.source = source
145
197
  self._in_else = False
146
198
  self._in_elseif = False
199
+ self.collecting_doc: bool = kind == "script" # auto-collect doc after BEGIN SCRIPT
200
+ self.doc_lines: list[str] = []
147
201
 
148
202
 
149
203
  # ---------------------------------------------------------------------------
@@ -206,9 +260,67 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
206
260
  )
207
261
  line_comment_lines = []
208
262
 
263
+ def _stop_doc_collection() -> None:
264
+ """Stop collecting docstring lines and finalize the doc on the script node."""
265
+ if block_stack and block_stack[-1].collecting_doc:
266
+ frame = block_stack[-1]
267
+ frame.collecting_doc = False
268
+ if frame.doc_lines:
269
+ doc_text = "\n".join(frame.doc_lines).strip()
270
+ if doc_text and isinstance(frame.node, ScriptBlock):
271
+ frame.node.doc = doc_text
272
+
209
273
  for file_lineno, raw_line in enumerate(lines, 1):
210
274
  line = raw_line.rstrip()
211
275
 
276
+ # --- Docstring collection for SCRIPT blocks ---
277
+ # Comments immediately following BEGIN SCRIPT are captured as the
278
+ # docstring. A blank line terminates the doc.
279
+ if block_stack and block_stack[-1].collecting_doc and not in_block_comment:
280
+ frame = block_stack[-1]
281
+ if not line:
282
+ # Blank line terminates doc collection
283
+ _stop_doc_collection()
284
+ _flush_line_comments()
285
+ continue
286
+ # Check for block comment opening
287
+ stripped = line.lstrip()
288
+ if stripped.startswith("/*"):
289
+ # Collect block comment as doc lines
290
+ comment_body = stripped[2:]
291
+ if comment_body.rstrip().endswith("*/"):
292
+ # Single-line block comment: /* text */
293
+ comment_body = comment_body.rstrip()[:-2].strip()
294
+ if comment_body:
295
+ frame.doc_lines.append(comment_body)
296
+ continue
297
+ # Multi-line block comment — collect until */
298
+ if comment_body.strip():
299
+ frame.doc_lines.append(comment_body.rstrip())
300
+ # Let the block comment tracker handle the rest, but mark
301
+ # that we're collecting doc inside a block comment.
302
+ in_block_comment = True
303
+ block_comment_lines = [line]
304
+ block_comment_start = file_lineno
305
+ sql_accum_at_block_comment = False
306
+ # We'll handle the doc finalization when */ is found below.
307
+ # For now, mark doc as still collecting.
308
+ continue
309
+ # Check for single-line comment (not metacommand)
310
+ metacommand_match_doc = _EXEC_LINE_RX.match(line)
311
+ if not metacommand_match_doc and _COMMENT_LINE_RX.match(line):
312
+ # Strip -- prefix and optional leading space
313
+ text = stripped
314
+ if text.startswith("--"):
315
+ text = text[2:]
316
+ if text.startswith(" "):
317
+ text = text[1:]
318
+ frame.doc_lines.append(text.rstrip())
319
+ continue
320
+ # Non-comment line (metacommand or SQL) — stop doc collection
321
+ _stop_doc_collection()
322
+ # Fall through to normal processing
323
+
212
324
  # --- Block comment tracking ---
213
325
  if not line:
214
326
  _flush_line_comments()
@@ -219,6 +331,23 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
219
331
  if len(line) > 1 and line.rstrip().endswith("*/"):
220
332
  in_block_comment = False
221
333
  comment_text = "\n".join(block_comment_lines)
334
+ # If we were collecting doc lines when the block comment opened,
335
+ # feed the content into the docstring instead of creating a node.
336
+ if block_stack and block_stack[-1].collecting_doc:
337
+ frame = block_stack[-1]
338
+ # Extract text between /* and */, stripping delimiters
339
+ for bc_line in block_comment_lines:
340
+ stripped_bc = bc_line.strip()
341
+ if stripped_bc.startswith("/*"):
342
+ stripped_bc = stripped_bc[2:]
343
+ if stripped_bc.endswith("*/"):
344
+ stripped_bc = stripped_bc[:-2]
345
+ stripped_bc = stripped_bc.strip()
346
+ if stripped_bc:
347
+ frame.doc_lines.append(stripped_bc)
348
+ block_comment_lines = []
349
+ sql_accum_at_block_comment = False
350
+ continue
222
351
  if sql_accum_at_block_comment:
223
352
  # Block comment started inside a SQL statement — fold it
224
353
  # back into sql_accum so the statement isn't split.
@@ -332,7 +461,7 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
332
461
  if m:
333
462
  name = m.group("name").lower()
334
463
  paramexpr = m.group("paramexpr")
335
- param_names = None
464
+ param_defs = None
336
465
  if paramexpr:
337
466
  wp = _WITH_PARAMS_RX.match(paramexpr)
338
467
  if not wp:
@@ -341,13 +470,13 @@ def _parse_lines(lines: Iterable[str], source_name: str) -> Script:
341
470
  command_text=line,
342
471
  other_msg=f"Invalid BEGIN SCRIPT metacommand on line {file_lineno} of file {source_name}.",
343
472
  )
344
- param_names = re.findall(r"\w+", wp.group("params"))
473
+ param_defs = _parse_param_defs(wp.group("params"), file_lineno, source_name)
345
474
  block_stack.append(
346
475
  _BlockFrame(
347
476
  ScriptBlock(
348
477
  span=SourceSpan(source_name, file_lineno),
349
478
  name=name,
350
- param_names=param_names,
479
+ param_defs=param_defs,
351
480
  ),
352
481
  kind="script",
353
482
  start_line=file_lineno,
execsql/utils/fileio.py CHANGED
@@ -284,7 +284,15 @@ class FileWriter(multiprocessing.Process):
284
284
  self.active = False
285
285
  self.close_all()
286
286
 
287
- def run(self) -> None:
287
+ def run(self) -> None: # pragma: no cover – runs in a subprocess
288
+ # Ignore SIGINT in the child process — the parent owns Ctrl+C handling
289
+ # and will shut us down via CMD_SHUTDOWN on the queue. Without this,
290
+ # KeyboardInterrupt races through queue.get() and close_all(), producing
291
+ # ugly tracebacks on stderr.
292
+ import signal
293
+
294
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
295
+
288
296
  # Messages in the input queue consist of a 2-tuple, of which the first element
289
297
  # is a command and the second is a tuple of arguments for the function indicated
290
298
  # by that command.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: execsql2
3
- Version: 2.16.8
3
+ Version: 2.16.13
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
@@ -3,7 +3,7 @@ execsql/__main__.py,sha256=HdbK-SAhyUmfB6xINY5AzxdMSxGzWSGEG_2dv42Jn64,315
3
3
  execsql/api.py,sha256=0D2Rl329fvDXERNrJBUzoWEt_VxqCHwRgaGzrq04rVs,19709
4
4
  execsql/config.py,sha256=6icjr8PKenUGfFF6lciSclvejjDzY8GTW1OZ1-IZt-Y,29480
5
5
  execsql/exceptions.py,sha256=j8hykBiof9H3Za9hwLIbDcVB2Xn65ODXXplp1jkvdgM,8453
6
- execsql/format.py,sha256=IsCM5T1iBC54CkBJNfO55ycJk-aKRZJjtnwXk0jepTA,12723
6
+ execsql/format.py,sha256=nftyFgzNuv9aGUsp07WD3SjfttZ_dPhY45zJ7ocObL4,24384
7
7
  execsql/models.py,sha256=kCTUQg9-vReM6WNFfB_ZrEppuOW5u1uMBQThSkfPC0o,13264
8
8
  execsql/parser.py,sha256=P3ea8k7T_XLMrbhpFNZXwytdShrY302MKnhosqza1lo,15493
9
9
  execsql/plugins.py,sha256=2voLwT6eFap6BCBoZYndNNC_bMEJO1f_aP6xQTVXwYI,12815
@@ -29,7 +29,7 @@ execsql/db/postgres.py,sha256=-GpaA9fi6_vdIDTGyRGxNRaPYAAkEB_aCb-EyLFShsM,21120
29
29
  execsql/db/sqlite.py,sha256=or2JDDt_MyBTAtSXBVhNTcojE9jCE4b98tq39sNOyUg,10401
30
30
  execsql/db/sqlserver.py,sha256=d8PwfNumt-Spit_0llafqQ900suv3CZMbrRsHLU1iB4,7683
31
31
  execsql/debug/__init__.py,sha256=j6EGUR0dHzUhWN1mHHtf1-Lhjq3Sb1V-vmnq2Ztgj1M,178
32
- execsql/debug/repl.py,sha256=rlddBAg7lelHYmeyeXhDqkK8XkKF2t2dVv4nsEMvleU,18247
32
+ execsql/debug/repl.py,sha256=TlIw6XrxwP3QUxuOaQZKp9vbJI55kEfiMtRNMxQRZo8,20747
33
33
  execsql/exporters/__init__.py,sha256=-Cnji-OgodJV8ftcDcOyTof0kQMy9J5kKVC8GVFpc3o,670
34
34
  execsql/exporters/base.py,sha256=Uhq0PBz8N_pJ7WlIN9225wY-HyxfzWSb9YabHXicBA8,6387
35
35
  execsql/exporters/delimited.py,sha256=Po9RV4UwBLOuRZmPFJk5CErpFvTdLCy--nFJJffZgxM,32383
@@ -69,8 +69,8 @@ execsql/metacommands/conditions.py,sha256=B6fBumkqoPO4wcQbw_ypYITaSnzPemAA1g5GrN
69
69
  execsql/metacommands/connect.py,sha256=Wnlp5PeeaNDaVlaWjCetvarTgQIwIeMPYe8cyslPYeA,14969
70
70
  execsql/metacommands/control.py,sha256=PlTAq34OkcmnOsPm3bZxF4mg1MaNBpENa8wzIKVEY10,8302
71
71
  execsql/metacommands/data.py,sha256=tRQBGTAuW-eJ2tBNWaoZI9OjTyNNyHJISo7gOdL-sm8,11370
72
- execsql/metacommands/debug.py,sha256=IfyvGLYclb5i6hzOcIQN_N0gqi5xQhitY_Z5j3CmmKU,8264
73
- execsql/metacommands/dispatch.py,sha256=rY259pp5Bcq1q0N86NvxFgmFyOhYrNtgByzrNjocipI,87083
72
+ execsql/metacommands/debug.py,sha256=3QVm0N5uc7mcZco36kqlDq8tiWW0sdo8E8BoQZkE_DI,11784
73
+ execsql/metacommands/dispatch.py,sha256=Gao7rwiBFdghmYKcbWVm21Va30evobQuoghLY_FOZto,87602
74
74
  execsql/metacommands/io.py,sha256=vlGBje5sgnqeilooMdhJDgSRIhysHy5_7LrKtik9Xjs,3011
75
75
  execsql/metacommands/io_export.py,sha256=J2FnXA6452FIGStv6vbZXORmA-Ges70na0W7YwpMESA,13253
76
76
  execsql/metacommands/io_fileops.py,sha256=QKFj-94W32zxJiDcMpRsaohSCdtCRPIOG10K0WPigFk,9616
@@ -81,42 +81,42 @@ execsql/metacommands/script_ext.py,sha256=sw4YKUQl0SRlVlmhIoGbMokOo_hVqh1EcTuYCN
81
81
  execsql/metacommands/system.py,sha256=azRbv_P8l0t8BkDM9bmAUkhpnLSLHSCcmByqs-a3FxQ,7352
82
82
  execsql/metacommands/upsert.py,sha256=XE_P3mjaUkqT-LR4_28n2NbXwdjSgAGXJyZmKZC4Oy4,21136
83
83
  execsql/script/__init__.py,sha256=3WaBklMVIWjtCsYQ-BVo9UAVEIATOgeGsuyv21YKnxo,3969
84
- execsql/script/ast.py,sha256=yFw9ZvLLDNNSmSgy7cwK2fXLatpp2CKYAAyOneJoKVc,18836
84
+ execsql/script/ast.py,sha256=SLn1fp0QDsqvJegNeIuyKV9f1BFnkEdzRota5EaNTfo,20053
85
85
  execsql/script/control.py,sha256=s-1eZdGARM6H1FwZ6VDdO_f50j7bvvRtTHesfUm9tbc,6144
86
86
  execsql/script/engine.py,sha256=EhuVBniOrFkzAW4I3NIZLt3INHTZJvlYoF7B99rZBLI,29391
87
- execsql/script/executor.py,sha256=SeY7Xiky1hBrNG8Z-MV6uhLhayvVFByUH02N5YZbyHA,36546
88
- execsql/script/parser.py,sha256=reqKs_JP1H_m43B2d4MeZH4zHY-pnGUGCwDX0IBdJwA,26109
87
+ execsql/script/executor.py,sha256=T-pGa3o5S0oX1ySB5zztzRoJHYRZI58CKw3qcFz_95Q,37602
88
+ execsql/script/parser.py,sha256=huznh4RnW6Y_yJyIIoMG5McUS2T9o3kfOW37CXPR5dk,31689
89
89
  execsql/script/variables.py,sha256=ZSBGQUsoii6w3dLDVY9xxoPIV6wY0sAV_BNIQ6pgQAE,14328
90
90
  execsql/utils/__init__.py,sha256=0uR6JwVJQRX3vceByNBduCAf5dd5assKjeqJUWvpZoA,278
91
91
  execsql/utils/auth.py,sha256=onXzNkNZQZxGC5w7eey06sjvAIAX_Lf9g7nUJtcsel0,7009
92
92
  execsql/utils/crypto.py,sha256=KlT6lPk-v0-qQQfAMfWvT9Q6kRdCD3_5JVaLajNocKM,3051
93
93
  execsql/utils/datetime.py,sha256=rMCXAbvj6bxKCYzC97vrludO6PU5DYQ39buZ0smDC5A,3573
94
94
  execsql/utils/errors.py,sha256=I3xiPyDYS8Ftv7Y8P0uqzrTKlCN1dTXe1pMKKFFjmoA,8651
95
- execsql/utils/fileio.py,sha256=3HDm6QFGeXwFMmCsbea4H5Ub6u2Jrx-oNsWA3Q5aytQ,23860
95
+ execsql/utils/fileio.py,sha256=x0kl2oLydCIGbhRCAJDHFknUyGcbKEOnV_M4lWcFmTk,24259
96
96
  execsql/utils/gui.py,sha256=h_7V2zYfp0l6C3Ft8QtUJAPgxCUrFkgIqwimz5s1bbQ,22821
97
97
  execsql/utils/mail.py,sha256=4QXwdPgMh6vQBKLtkdH7IDh3JBczumc3l5up2AUSOZU,5483
98
98
  execsql/utils/numeric.py,sha256=xh02ANSRk3nUpQ-rtm66ILoMqoi7HtzCoRMIOT9U8QI,1570
99
99
  execsql/utils/regex.py,sha256=diEzTZqU_HHwVMadPAvN1Vgzhl7I03eVaEFGCXyGGL8,3770
100
100
  execsql/utils/strings.py,sha256=5Dvzrk-9SIw2lpxXZQkiJbNyo1sy7iXXAtSULlZ0KG8,8488
101
101
  execsql/utils/timer.py,sha256=eDYf5VzCNFk7oo90InJucUm3XcBdhYMogjZMqeg9xzc,1899
102
- execsql2-2.16.8.data/data/execsql2_extras/README.md,sha256=sxwVyU0ZahCfANv56LahkyuM505kFjrMhe-1SvWE69E,4845
103
- execsql2-2.16.8.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
104
- execsql2-2.16.8.data/data/execsql2_extras/example_config_prompt.sql,sha256=SY3Jxn1qcVm4kPW9xmmTfknHfvURXmeEYTbRjYrjGSw,7487
105
- execsql2-2.16.8.data/data/execsql2_extras/execsql.conf,sha256=_45iJ-KWZnB8uMW_gEg067MM5pmGJ-dVl7VbAZMunAE,9530
106
- execsql2-2.16.8.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
107
- execsql2-2.16.8.data/data/execsql2_extras/md_compare.sql,sha256=B8Wd7LZ0vnMY2qvA139JIEBkPObgRH2i5xj6PejTQt8,24092
108
- execsql2-2.16.8.data/data/execsql2_extras/md_glossary.sql,sha256=DJRHcU5NbFpxTTX-IwH3yRlsboj1q6BBGrUAHKn4Cuo,10796
109
- execsql2-2.16.8.data/data/execsql2_extras/md_upsert.sql,sha256=v_7GbWh_N1mBTmw3gvTrkagOVp2q0KmXvM8hE-DlFxY,112524
110
- execsql2-2.16.8.data/data/execsql2_extras/pg_compare.sql,sha256=9dWa8hnfy5dVJI-z2iGpd9JzQmI4j2ziMlEdpnr66ro,24352
111
- execsql2-2.16.8.data/data/execsql2_extras/pg_glossary.sql,sha256=pKjIIDsROAgJq2H-1qNEcRMAWManivcZ_AEVHzUUlic,9908
112
- execsql2-2.16.8.data/data/execsql2_extras/pg_upsert.sql,sha256=k7AFiGTLBy3nf-qO5QIaZrEYTAKvdxxU3JDLx9jqkzs,108315
113
- execsql2-2.16.8.data/data/execsql2_extras/script_template.sql,sha256=1Estacb_vm1FgK41k_G9nuduP1yiA-fQ1Kn4Z4mv5Ao,11153
114
- execsql2-2.16.8.data/data/execsql2_extras/ss_compare.sql,sha256=TsVxWm3cEpR5-EiMYXNhtaY0arSNeKZhsJdHdLA7xeI,24833
115
- execsql2-2.16.8.data/data/execsql2_extras/ss_glossary.sql,sha256=cLM7nN8JOIu9ZVP9oY9qdSK3hrnWJiDcX6nZmQQbQWI,13065
116
- execsql2-2.16.8.data/data/execsql2_extras/ss_upsert.sql,sha256=BCqmBykXBF-BpCgOFeG1qhf2XfScKsxPD17wd1hYfHw,120647
117
- execsql2-2.16.8.dist-info/METADATA,sha256=Bqs0Wsv_KmnbyPSAsJbYHnlK-PAn7TX3LZUXgWHHwws,20920
118
- execsql2-2.16.8.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
119
- execsql2-2.16.8.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
120
- execsql2-2.16.8.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
121
- execsql2-2.16.8.dist-info/licenses/NOTICE,sha256=sqVrM73Ys9zfvWC_P797lHfTnoPW_ETeBSrUTFaob0A,339
122
- execsql2-2.16.8.dist-info/RECORD,,
102
+ execsql2-2.16.13.data/data/execsql2_extras/README.md,sha256=sxwVyU0ZahCfANv56LahkyuM505kFjrMhe-1SvWE69E,4845
103
+ execsql2-2.16.13.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
104
+ execsql2-2.16.13.data/data/execsql2_extras/example_config_prompt.sql,sha256=SY3Jxn1qcVm4kPW9xmmTfknHfvURXmeEYTbRjYrjGSw,7487
105
+ execsql2-2.16.13.data/data/execsql2_extras/execsql.conf,sha256=_45iJ-KWZnB8uMW_gEg067MM5pmGJ-dVl7VbAZMunAE,9530
106
+ execsql2-2.16.13.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
107
+ execsql2-2.16.13.data/data/execsql2_extras/md_compare.sql,sha256=B8Wd7LZ0vnMY2qvA139JIEBkPObgRH2i5xj6PejTQt8,24092
108
+ execsql2-2.16.13.data/data/execsql2_extras/md_glossary.sql,sha256=DJRHcU5NbFpxTTX-IwH3yRlsboj1q6BBGrUAHKn4Cuo,10796
109
+ execsql2-2.16.13.data/data/execsql2_extras/md_upsert.sql,sha256=v_7GbWh_N1mBTmw3gvTrkagOVp2q0KmXvM8hE-DlFxY,112524
110
+ execsql2-2.16.13.data/data/execsql2_extras/pg_compare.sql,sha256=9dWa8hnfy5dVJI-z2iGpd9JzQmI4j2ziMlEdpnr66ro,24352
111
+ execsql2-2.16.13.data/data/execsql2_extras/pg_glossary.sql,sha256=pKjIIDsROAgJq2H-1qNEcRMAWManivcZ_AEVHzUUlic,9908
112
+ execsql2-2.16.13.data/data/execsql2_extras/pg_upsert.sql,sha256=k7AFiGTLBy3nf-qO5QIaZrEYTAKvdxxU3JDLx9jqkzs,108315
113
+ execsql2-2.16.13.data/data/execsql2_extras/script_template.sql,sha256=1Estacb_vm1FgK41k_G9nuduP1yiA-fQ1Kn4Z4mv5Ao,11153
114
+ execsql2-2.16.13.data/data/execsql2_extras/ss_compare.sql,sha256=TsVxWm3cEpR5-EiMYXNhtaY0arSNeKZhsJdHdLA7xeI,24833
115
+ execsql2-2.16.13.data/data/execsql2_extras/ss_glossary.sql,sha256=cLM7nN8JOIu9ZVP9oY9qdSK3hrnWJiDcX6nZmQQbQWI,13065
116
+ execsql2-2.16.13.data/data/execsql2_extras/ss_upsert.sql,sha256=BCqmBykXBF-BpCgOFeG1qhf2XfScKsxPD17wd1hYfHw,120647
117
+ execsql2-2.16.13.dist-info/METADATA,sha256=XBstvMhu4_kEtw4SOYQxdhMPR5UeTa8pEJ3gEkecmFI,20921
118
+ execsql2-2.16.13.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
119
+ execsql2-2.16.13.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
120
+ execsql2-2.16.13.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
121
+ execsql2-2.16.13.dist-info/licenses/NOTICE,sha256=sqVrM73Ys9zfvWC_P797lHfTnoPW_ETeBSrUTFaob0A,339
122
+ execsql2-2.16.13.dist-info/RECORD,,