execsql2 2.22.5__py3-none-any.whl → 2.22.7__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 (21) hide show
  1. execsql/format.py +120 -13
  2. {execsql2-2.22.5.dist-info → execsql2-2.22.7.dist-info}/METADATA +2 -2
  3. {execsql2-2.22.5.dist-info → execsql2-2.22.7.dist-info}/RECORD +21 -21
  4. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/README.md +0 -0
  5. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  6. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  7. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/make_config_db.sql +0 -0
  8. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/md_compare.sql +0 -0
  9. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/md_glossary.sql +0 -0
  10. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/md_upsert.sql +0 -0
  11. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/pg_compare.sql +0 -0
  12. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  13. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  14. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/script_template.sql +0 -0
  15. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/ss_compare.sql +0 -0
  16. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  17. {execsql2-2.22.5.data → execsql2-2.22.7.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  18. {execsql2-2.22.5.dist-info → execsql2-2.22.7.dist-info}/WHEEL +0 -0
  19. {execsql2-2.22.5.dist-info → execsql2-2.22.7.dist-info}/entry_points.txt +0 -0
  20. {execsql2-2.22.5.dist-info → execsql2-2.22.7.dist-info}/licenses/LICENSE.txt +0 -0
  21. {execsql2-2.22.5.dist-info → execsql2-2.22.7.dist-info}/licenses/NOTICE +0 -0
execsql/format.py CHANGED
@@ -278,6 +278,64 @@ def _sql_literal_texts(sql: str) -> list[str]:
278
278
  return [sql[start:end] for start, end, _ in _iter_sql_literals(sql)]
279
279
 
280
280
 
281
+ def _literal_line_starts(lines: list[str]) -> list[int]:
282
+ """Return the character offset at which each of *lines* starts once joined."""
283
+ starts: list[int] = []
284
+ offset = 0
285
+ for line in lines:
286
+ starts.append(offset)
287
+ offset += len(line) + 1 # +1 for the newline joined between lines
288
+ return starts
289
+
290
+
291
+ def _literal_interior_lines(lines: list[str]) -> set[int]:
292
+ """Return indices of *lines* that begin inside a multi-line string literal.
293
+
294
+ The line that *opens* a literal is ordinary SQL up to the delimiter, so it
295
+ is indented with the statement as usual. Every later line of that literal
296
+ — through and including the one holding the closing delimiter — begins
297
+ inside the string, which makes its leading whitespace part of the stored
298
+ value rather than layout. Re-indenting those lines changes what the
299
+ database receives, so callers must emit them verbatim.
300
+
301
+ This covers every literal kind the scanner reports, not just dollar-quoted
302
+ bodies: an ordinary ``'…'`` string spanning lines carries its newlines and
303
+ indentation exactly the same way.
304
+ """
305
+ if len(lines) < 2:
306
+ return set()
307
+ text = "\n".join(lines)
308
+ starts = _literal_line_starts(lines)
309
+
310
+ interior: set[int] = set()
311
+ for start, end, _kind in _iter_sql_literals(text):
312
+ interior.update(i for i, line_start in enumerate(starts) if start < line_start < end)
313
+ return interior
314
+
315
+
316
+ def _literal_spanning_lines(lines: list[str]) -> set[int]:
317
+ """Return indices of every line that any multi-line literal touches.
318
+
319
+ Unlike :func:`_literal_interior_lines` this also includes the line that
320
+ *opens* the literal. Passes that move text between lines — rather than
321
+ only adjusting leading whitespace — must leave the whole span alone, since
322
+ the opening line holds literal content after its delimiter.
323
+ """
324
+ if len(lines) < 2:
325
+ return set()
326
+ text = "\n".join(lines)
327
+ starts = _literal_line_starts(lines)
328
+
329
+ spanning: set[int] = set()
330
+ for start, end, _kind in _iter_sql_literals(text):
331
+ touched = [i for i, line_start in enumerate(starts) if start < line_start < end]
332
+ if not touched:
333
+ continue # single-line literal — nothing spans
334
+ spanning.update(touched)
335
+ spanning.add(min(touched) - 1) # the line that opens the literal
336
+ return spanning
337
+
338
+
281
339
  # Placeholder standing in for a masked E'...' literal. It is a plain literal
282
340
  # so that sqlglot sees a string where a string was, and lays the statement out
283
341
  # the same way it would have for the real one.
@@ -398,7 +456,21 @@ def _sqlglot_format(
398
456
 
399
457
  try:
400
458
  with contextlib.redirect_stderr(io.StringIO()):
401
- ast = sqlglot.parse(protected, read="postgres", error_level=sqlglot_errors.ErrorLevel.IGNORE)
459
+ # Parse with RAISE, not IGNORE. Under IGNORE sqlglot answers a
460
+ # statement it cannot parse with a *partial* AST — the tokens it
461
+ # failed on are simply absent — and regenerating from that AST
462
+ # writes back SQL that no longer means what the author wrote.
463
+ # ``INSERT ... CROSS JOIN LATERAL (VALUES ...) AS g(a, b) ON
464
+ # CONFLICT (a) DO NOTHING`` came back with ``DO NOTHING`` deleted:
465
+ # still one statement, still 88% of the input's alphanumeric
466
+ # characters, no string literals to compare, so every guard below
467
+ # passed it through. The guards are quantitative and this loss is
468
+ # qualitative — a dropped ``DISTINCT``, ``CASCADE``, ``DESC`` or
469
+ # ``NOT`` is smaller still, and no threshold separates those from
470
+ # ordinary reformatting. Only the parser knows whether it
471
+ # understood the whole statement, so ask it: input that does not
472
+ # parse is left exactly as written.
473
+ ast = sqlglot.parse(protected, read="postgres", error_level=sqlglot_errors.ErrorLevel.RAISE)
402
474
  statements: list[str] = []
403
475
  for node in ast:
404
476
  if node is None:
@@ -466,6 +538,10 @@ def _sqlglot_format(
466
538
  if restored is None:
467
539
  return sql_lines
468
540
  return _restore_variables(restored, replacements).split("\n")
541
+ except sqlglot_errors.ParseError:
542
+ # Unparsable input: anything generated from a partial parse is
543
+ # untrustworthy, so emit the original lines untouched.
544
+ return sql_lines
469
545
  except Exception:
470
546
  return sql_lines
471
547
 
@@ -503,6 +579,14 @@ def _has_mid_statement_comments(lines: list[str]) -> bool:
503
579
 
504
580
  _CMT_MARKER = "EXECSQL_CMTMARKER_"
505
581
  _CMT_MARKER_RE = re.compile(rf"/\*\s*({re.escape(_CMT_MARKER)}\d+)\s*\*/")
582
+ # A whole run of adjacent markers plus the whitespace injected around them.
583
+ # Markers are written as ``/* marker */ line`` and several consecutive source
584
+ # comments attach several markers to one line, so removing them one at a time
585
+ # leaves a separator space behind for each. When sqlglot moves the run into
586
+ # the middle of an expression that residue shows up as ``AND RTRIM(`` and is
587
+ # only normalised on the following pass, which costs an extra pass to
588
+ # converge. Matching the run as a unit removes it cleanly. See issue #35.
589
+ _CMT_MARKER_RUN_RE = re.compile(rf"\s*(?:/\*\s*{re.escape(_CMT_MARKER)}\d+\s*\*/\s*)+")
506
590
 
507
591
 
508
592
  def _format_preserving_comments(
@@ -570,12 +654,16 @@ def _format_preserving_comments(
570
654
  markers_here = _CMT_MARKER_RE.findall(fline)
571
655
  if markers_here:
572
656
  # Strip markers to get the underlying SQL line and its indent
573
- cleaned = _CMT_MARKER_RE.sub("", fline).strip()
574
- # Determine indent: use the SQL line's indent from sqlglot
575
- line_indent = ""
576
- if cleaned:
577
- raw_cleaned = _CMT_MARKER_RE.sub("", fline)
578
- line_indent = raw_cleaned[: len(raw_cleaned) - len(raw_cleaned.lstrip())]
657
+ cleaned = _CMT_MARKER_RUN_RE.sub(" ", fline).strip()
658
+ # Indent comes from the line's own leading whitespace, never from
659
+ # what is left after removing the marker. Markers are injected as
660
+ # ``/* marker */ line`` — with a separating space — so measuring
661
+ # after removal counts that space as indentation. Whenever
662
+ # sqlglot leaves the line untouched (an unparsable statement falls
663
+ # back verbatim) the extra space is re-measured and re-added on the
664
+ # next run, growing the indent by one on every pass and never
665
+ # converging. See issue #35.
666
+ line_indent = fline[: len(fline) - len(fline.lstrip())]
579
667
  for m in markers_here:
580
668
  if m in comment_store:
581
669
  orig = comment_store[m]
@@ -671,12 +759,24 @@ def format_sql_block(
671
759
  if not non_empty:
672
760
  return [""] * len(lines)
673
761
 
674
- base = min(len(line) - len(line.lstrip()) for line in non_empty)
762
+ # Lines that begin inside a multi-line literal carry content, not layout —
763
+ # dedenting or indenting them changes the value the database stores.
764
+ # Exclude them from the indent arithmetic and emit them verbatim.
765
+ interior = _literal_interior_lines(lines)
766
+ if interior:
767
+ # format_file already withholds dollar-quoted blocks from sqlglot; keep
768
+ # that true here so the line indices below stay aligned with the output.
769
+ use_sql = False
770
+
771
+ layout_lines = [line for i, line in enumerate(lines) if line.strip() and i not in interior]
772
+ base = min((len(line) - len(line.lstrip()) for line in layout_lines), default=0)
675
773
  target_prefix = " " * (depth * indent)
676
- rebased = [line[base:] if line.strip() else "" for line in lines]
774
+ rebased = [line if i in interior else (line[base:] if line.strip() else "") for i, line in enumerate(lines)]
677
775
 
678
776
  if not use_sql:
679
- return [target_prefix + line if line.strip() else "" for line in rebased]
777
+ return [
778
+ line if i in interior else (target_prefix + line if line.strip() else "") for i, line in enumerate(rebased)
779
+ ]
680
780
 
681
781
  # When comments appear mid-statement, use the marker-based approach
682
782
  # which preserves both comments AND sqlglot formatting. When all
@@ -747,6 +847,9 @@ def _normalize_to_trailing_comma(text: str) -> str:
747
847
  every invocation. Comments between the two SQL lines stay in place.
748
848
  """
749
849
  lines = text.split("\n")
850
+ # Symmetric with _apply_leading_comma: a leading comma inside a multi-line
851
+ # literal is data, not layout, so the whole span is left alone.
852
+ protected = _literal_spanning_lines(lines)
750
853
 
751
854
  def find_prev_sql_line(idx: int) -> int:
752
855
  k = idx - 1
@@ -756,7 +859,7 @@ def _normalize_to_trailing_comma(text: str) -> str:
756
859
 
757
860
  for i, line in enumerate(lines):
758
861
  stripped = line.lstrip()
759
- if not stripped.startswith(",") or _is_comment_only(line):
862
+ if not stripped.startswith(",") or _is_comment_only(line) or i in protected:
760
863
  continue
761
864
  # Don't try to rewrite leading-comma inside a comment-only line.
762
865
  # Move the comma onto the previous SQL line as a trailing `,`.
@@ -792,15 +895,19 @@ def _apply_leading_comma(text: str) -> str:
792
895
  TestIdempotency).
793
896
  """
794
897
  lines = text.split("\n")
898
+ # A comma inside a multi-line string literal is data. Moving it to the next
899
+ # line would rewrite the stored value, so leave every line such a literal
900
+ # touches — including the one that opens it — exactly as it is.
901
+ protected = _literal_spanning_lines(lines)
795
902
  i = 0
796
903
  n = len(lines)
797
904
  while i < n:
798
905
  rstripped = lines[i].rstrip()
799
- if rstripped.endswith(",") and not _is_comment_only(lines[i]):
906
+ if rstripped.endswith(",") and not _is_comment_only(lines[i]) and i not in protected:
800
907
  j = i + 1
801
908
  while j < n and (not lines[j].strip() or _is_comment_only(lines[j])):
802
909
  j += 1
803
- if j < n:
910
+ if j < n and j not in protected:
804
911
  stripped = lines[i].rstrip()
805
912
  comma_idx = stripped.rfind(",")
806
913
  lines[i] = stripped[:comma_idx] + stripped[comma_idx + 1 :]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: execsql2
3
- Version: 2.22.5
3
+ Version: 2.22.7
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.5
387
+ rev: v2.22.7
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=tfg0m3EbDVRlAR3ZhwV8BjgMFckL3u3IdldyCONrutU,41400
6
+ execsql/format.py,sha256=oJvAxDaWi-TQAnj_enwuEH-wtjZylKf67QWwHrSNEUU,47254
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
@@ -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.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,,
104
+ execsql2-2.22.7.data/data/execsql2_extras/README.md,sha256=vX4NTL095dUoA_hesyRMGYBorEZ_Y_tJ9qrd-MVV09I,5032
105
+ execsql2-2.22.7.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
106
+ execsql2-2.22.7.data/data/execsql2_extras/example_config_prompt.sql,sha256=2e8KzzVWhho8KxYVHETSVmZdhW7wodioDsqBLSL6m4s,7487
107
+ execsql2-2.22.7.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
108
+ execsql2-2.22.7.data/data/execsql2_extras/md_compare.sql,sha256=qYYVAjSeHZzjszxV3Bv6bg8Ckbq2kMHl87_gh4sywMU,24140
109
+ execsql2-2.22.7.data/data/execsql2_extras/md_glossary.sql,sha256=hkZ2Onn57LAKKsuXxzhR8tPtcWXkmWEQkwPE58-Tm2k,10796
110
+ execsql2-2.22.7.data/data/execsql2_extras/md_upsert.sql,sha256=_CAK4BzEboRXTNy03SJR-oOjcEdSNMuRBPL6noWUptY,112560
111
+ execsql2-2.22.7.data/data/execsql2_extras/pg_compare.sql,sha256=1zJd4hVUKHR0tncc2qTBC9B4qVV4Us2ITkJpsjN3tMw,24352
112
+ execsql2-2.22.7.data/data/execsql2_extras/pg_glossary.sql,sha256=IKuwna-_8b20ljSkXZruuiQigrCpo7ueQdUqd1MXiuI,9908
113
+ execsql2-2.22.7.data/data/execsql2_extras/pg_upsert.sql,sha256=HpPJtTHvpEjQy03j-3iPxDEOHMRkudOg7O4D4YR38UI,108315
114
+ execsql2-2.22.7.data/data/execsql2_extras/script_template.sql,sha256=2J35ddZPguJ-vwTsz83wErv0jiWUyJcdW_JM0mNKDXA,11155
115
+ execsql2-2.22.7.data/data/execsql2_extras/ss_compare.sql,sha256=j1qVNUPXQsEU7-DoVgDJCGcE0EuIl7whLBT3fgeiMAo,24833
116
+ execsql2-2.22.7.data/data/execsql2_extras/ss_glossary.sql,sha256=2gLxv34xzKt0vy7hSzJH7a9JiMC3ETrv9MofxQwAibU,13065
117
+ execsql2-2.22.7.data/data/execsql2_extras/ss_upsert.sql,sha256=G_8rQ0VzuKIZHWs24O_WrfzpC5S27R1JsL-bFBR3SUQ,117730
118
+ execsql2-2.22.7.dist-info/METADATA,sha256=DbIBWCsTnJIRQx1h0UKA9kB5kXFMybxn7U9j-xWG8P4,20382
119
+ execsql2-2.22.7.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
120
+ execsql2-2.22.7.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
121
+ execsql2-2.22.7.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
122
+ execsql2-2.22.7.dist-info/licenses/NOTICE,sha256=McYzgxYav3U1OaVsY4Su1sfBrfmplpRdA9b6-gCDQCg,342
123
+ execsql2-2.22.7.dist-info/RECORD,,