sqlfluff-tstring 0.1.0__tar.gz

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.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlfluff-tstring
3
+ Version: 0.1.0
4
+ Summary: Auto-format SQL inside Python t-strings using sqlfluff
5
+ Keywords: formatter,linter,sql,sqlfluff,t-string
6
+ Author: Krzysztof Żuraw
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Software Development :: Quality Assurance
15
+ Requires-Dist: sqlfluff>=4.0.4
16
+ Requires-Python: >=3.14
17
+ Project-URL: Homepage, https://github.com/kzuraw/sqlfluff-tstring
18
+ Project-URL: Issues, https://github.com/kzuraw/sqlfluff-tstring/issues
19
+ Project-URL: Repository, https://github.com/kzuraw/sqlfluff-tstring
20
+ Description-Content-Type: text/markdown
21
+
22
+ # sqlfluff-tstring
23
+
24
+ Auto-format SQL inside Python t-strings using [sqlfluff](https://sqlfluff.com/).
25
+
26
+ Finds `sql(t"...")` calls in `.py` files and formats the embedded SQL, preserving interpolations and respecting your `.sqlfluff` config.
27
+
28
+ Requires Python 3.14+ (PEP 750 t-strings).
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install sqlfluff-tstring
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ # Format files in-place
40
+ sqlfluff-tstring src/
41
+
42
+ # Check mode (exit 1 if changes needed, for CI)
43
+ sqlfluff-tstring --check src/
44
+
45
+ # Show diff without writing
46
+ sqlfluff-tstring --diff src/
47
+
48
+ # Override SQL dialect
49
+ sqlfluff-tstring --dialect postgres src/
50
+
51
+ # Use a specific .sqlfluff config file
52
+ sqlfluff-tstring --config path/to/.sqlfluff src/
53
+ ```
54
+
55
+ ## What it does
56
+
57
+ Given a file like:
58
+
59
+ ```python
60
+ from sql_tstring import sql
61
+
62
+ query = sql(t"select * from users where id = {uid} and name = {name}")
63
+ ```
64
+
65
+ Running `sqlfluff-tstring` produces:
66
+
67
+ ```python
68
+ from sql_tstring import sql
69
+
70
+ query = sql(t"""
71
+ select * from users
72
+ where id = {uid} and name = {name}
73
+ """)
74
+ ```
75
+
76
+ - Interpolations (`{uid}`, `{name!r}`, `{val:.2f}`) are preserved through formatting
77
+ - Single quotes auto-upgrade to triple quotes when sqlfluff introduces newlines
78
+ - Multiline content in triple-quoted t-strings is wrapped with leading/trailing newlines
79
+ - Supports `sql(t"...")` and `obj.sql(t"...")` call patterns
80
+ - Respects your `.sqlfluff` configuration for dialect and rules
81
+
82
+ ## CLI options
83
+
84
+ ```
85
+ sqlfluff-tstring [OPTIONS] [PATHS...]
86
+
87
+ positional arguments:
88
+ paths Files or directories to format (default: .)
89
+
90
+ options:
91
+ --check Exit 1 if changes needed (CI mode)
92
+ --diff Show diff, don't write changes
93
+ --config PATH Path to .sqlfluff config file
94
+ --dialect DIALECT Override SQL dialect
95
+ -v, --verbose Show unchanged files
96
+ -q, --quiet Suppress all output
97
+ ```
98
+
99
+ Exit codes: `0` = success/no changes, `1` = changes needed (check mode).
100
+
101
+ ## Development
102
+
103
+ ```bash
104
+ uv sync
105
+ uv run pytest
106
+ uv run ruff check src/ tests/
107
+ uv run ty check src/
108
+ ```
@@ -0,0 +1,87 @@
1
+ # sqlfluff-tstring
2
+
3
+ Auto-format SQL inside Python t-strings using [sqlfluff](https://sqlfluff.com/).
4
+
5
+ Finds `sql(t"...")` calls in `.py` files and formats the embedded SQL, preserving interpolations and respecting your `.sqlfluff` config.
6
+
7
+ Requires Python 3.14+ (PEP 750 t-strings).
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install sqlfluff-tstring
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ # Format files in-place
19
+ sqlfluff-tstring src/
20
+
21
+ # Check mode (exit 1 if changes needed, for CI)
22
+ sqlfluff-tstring --check src/
23
+
24
+ # Show diff without writing
25
+ sqlfluff-tstring --diff src/
26
+
27
+ # Override SQL dialect
28
+ sqlfluff-tstring --dialect postgres src/
29
+
30
+ # Use a specific .sqlfluff config file
31
+ sqlfluff-tstring --config path/to/.sqlfluff src/
32
+ ```
33
+
34
+ ## What it does
35
+
36
+ Given a file like:
37
+
38
+ ```python
39
+ from sql_tstring import sql
40
+
41
+ query = sql(t"select * from users where id = {uid} and name = {name}")
42
+ ```
43
+
44
+ Running `sqlfluff-tstring` produces:
45
+
46
+ ```python
47
+ from sql_tstring import sql
48
+
49
+ query = sql(t"""
50
+ select * from users
51
+ where id = {uid} and name = {name}
52
+ """)
53
+ ```
54
+
55
+ - Interpolations (`{uid}`, `{name!r}`, `{val:.2f}`) are preserved through formatting
56
+ - Single quotes auto-upgrade to triple quotes when sqlfluff introduces newlines
57
+ - Multiline content in triple-quoted t-strings is wrapped with leading/trailing newlines
58
+ - Supports `sql(t"...")` and `obj.sql(t"...")` call patterns
59
+ - Respects your `.sqlfluff` configuration for dialect and rules
60
+
61
+ ## CLI options
62
+
63
+ ```
64
+ sqlfluff-tstring [OPTIONS] [PATHS...]
65
+
66
+ positional arguments:
67
+ paths Files or directories to format (default: .)
68
+
69
+ options:
70
+ --check Exit 1 if changes needed (CI mode)
71
+ --diff Show diff, don't write changes
72
+ --config PATH Path to .sqlfluff config file
73
+ --dialect DIALECT Override SQL dialect
74
+ -v, --verbose Show unchanged files
75
+ -q, --quiet Suppress all output
76
+ ```
77
+
78
+ Exit codes: `0` = success/no changes, `1` = changes needed (check mode).
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ uv sync
84
+ uv run pytest
85
+ uv run ruff check src/ tests/
86
+ uv run ty check src/
87
+ ```
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ build-backend = "uv_build"
3
+ requires = [ "uv-build>=0.7.5,<0.8" ]
4
+
5
+ [project]
6
+ name = "sqlfluff-tstring"
7
+ version = "0.1.0"
8
+ description = "Auto-format SQL inside Python t-strings using sqlfluff"
9
+ readme = "README.md"
10
+ keywords = [ "formatter", "linter", "sql", "sqlfluff", "t-string" ]
11
+ license = "MIT"
12
+ authors = [ { name = "Krzysztof Żuraw" } ]
13
+ requires-python = ">=3.14"
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Software Development :: Quality Assurance",
22
+ ]
23
+ dependencies = [ "sqlfluff>=4.0.4" ]
24
+
25
+ urls.Homepage = "https://github.com/kzuraw/sqlfluff-tstring"
26
+ urls.Issues = "https://github.com/kzuraw/sqlfluff-tstring/issues"
27
+ urls.Repository = "https://github.com/kzuraw/sqlfluff-tstring"
28
+ scripts.sqlfluff-tstring = "sqlfluff_tstring.cli:main"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "pre-commit>=4.5.1",
33
+ "pytest>=9.0.2",
34
+ "ruff>=0.15.5",
35
+ "syrupy>=5.1",
36
+ "ty>=0.0.21",
37
+ ]
38
+
39
+ [tool.uv]
40
+ required-version = ">=0.10.7"
41
+
42
+ [tool.uv.build-backend]
43
+ module-name = "sqlfluff_tstring"
44
+
45
+ [tool.ruff]
46
+ extend-exclude = [ "tests/fixtures" ]
47
+
48
+ [tool.ty.src]
49
+ exclude = [ "tests/fixtures" ]
@@ -0,0 +1,8 @@
1
+ import sys
2
+
3
+ from sqlfluff_tstring.cli import main
4
+
5
+ try:
6
+ main()
7
+ except KeyboardInterrupt:
8
+ sys.exit(130)
@@ -0,0 +1,82 @@
1
+ import argparse
2
+ import difflib
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from sqlfluff_tstring.pipeline import process_file
7
+
8
+ SKIP_DIRS = {"__pycache__", ".venv", ".git", "node_modules", ".tox", ".mypy_cache"}
9
+
10
+
11
+ def _collect_files(paths: list[Path]) -> list[Path]:
12
+ files: list[Path] = []
13
+ for path in paths:
14
+ if not path.exists():
15
+ print(f"Error: path not found: {path}", file=sys.stderr)
16
+ sys.exit(2)
17
+ if path.is_file():
18
+ if path.suffix == ".py":
19
+ files.append(path)
20
+ else:
21
+ print(f"Warning: skipping non-Python file: {path}", file=sys.stderr)
22
+ elif path.is_dir():
23
+ for py_file in sorted(path.rglob("*.py")):
24
+ if not any(part in SKIP_DIRS for part in py_file.parts):
25
+ files.append(py_file)
26
+ return files
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> None:
30
+ parser = argparse.ArgumentParser(
31
+ prog="sqlfluff-tstring",
32
+ description="Format SQL inside Python t-strings using sqlfluff",
33
+ )
34
+ parser.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
35
+ parser.add_argument(
36
+ "--check", action="store_true", help="Exit 1 if changes needed (CI mode)"
37
+ )
38
+ parser.add_argument(
39
+ "--diff", action="store_true", help="Show diff, don't write changes"
40
+ )
41
+ parser.add_argument("--config", help="Path to .sqlfluff config file")
42
+ parser.add_argument("--dialect", help="Override SQL dialect")
43
+ parser.add_argument("-v", "--verbose", action="store_true")
44
+ parser.add_argument("-q", "--quiet", action="store_true")
45
+
46
+ args = parser.parse_args(argv)
47
+ check_only = args.check or args.diff
48
+
49
+ files = _collect_files(args.paths)
50
+ if not files and not args.quiet:
51
+ print("No Python files found.", file=sys.stderr)
52
+ sys.exit(0)
53
+
54
+ any_changed = False
55
+ for path in files:
56
+ result = process_file(
57
+ path, check_only=check_only, dialect=args.dialect, config_path=args.config
58
+ )
59
+
60
+ if result.changed:
61
+ any_changed = True
62
+ if not args.quiet:
63
+ print(f"{'Would reformat' if check_only else 'Reformatted'}: {path}")
64
+
65
+ if args.diff:
66
+ diff = difflib.unified_diff(
67
+ result.original.splitlines(keepends=True),
68
+ result.formatted.splitlines(keepends=True),
69
+ fromfile=str(path),
70
+ tofile=str(path),
71
+ )
72
+ sys.stdout.writelines(diff)
73
+
74
+ elif args.verbose and not args.quiet:
75
+ print(f"Unchanged: {path}")
76
+
77
+ for error in result.errors:
78
+ if not args.quiet:
79
+ print(f"Warning: {error}", file=sys.stderr)
80
+
81
+ if args.check and any_changed:
82
+ sys.exit(1)
@@ -0,0 +1,82 @@
1
+ import ast
2
+ from dataclasses import dataclass
3
+ from typing import cast
4
+
5
+
6
+ @dataclass
7
+ class PlaceholderMapping:
8
+ index: int
9
+ placeholder: str
10
+ original_expr: str
11
+ conversion: int
12
+ format_spec: str | None
13
+
14
+
15
+ def extract_sql(
16
+ tstring_node: ast.TemplateStr,
17
+ ) -> tuple[str, list[PlaceholderMapping]]:
18
+ sql_parts: list[str] = []
19
+ mappings: list[PlaceholderMapping] = []
20
+ placeholder_index = 0
21
+
22
+ for value in tstring_node.values:
23
+ if isinstance(value, ast.Constant):
24
+ sql_parts.append(cast(str, value.value))
25
+ elif isinstance(value, ast.Interpolation):
26
+ placeholder = f"{{_var{placeholder_index}}}"
27
+ sql_parts.append(placeholder)
28
+
29
+ format_spec: str | None = None
30
+ if value.format_spec is not None:
31
+ # format_spec is a JoinedStr with Constant values
32
+ spec_parts: list[str] = []
33
+ for part in cast(ast.JoinedStr, value.format_spec).values:
34
+ if isinstance(part, ast.Constant):
35
+ spec_parts.append(cast(str, part.value))
36
+ format_spec = "".join(spec_parts)
37
+
38
+ mappings.append(
39
+ PlaceholderMapping(
40
+ index=placeholder_index,
41
+ placeholder=placeholder,
42
+ original_expr=value.str,
43
+ conversion=value.conversion,
44
+ format_spec=format_spec,
45
+ )
46
+ )
47
+ placeholder_index += 1
48
+
49
+ return "".join(sql_parts), mappings
50
+
51
+
52
+ def build_context(mappings: list[PlaceholderMapping]) -> dict[str, str]:
53
+ return {f"_var{m.index}": f"SQLFLUFF_VAR_{m.index}" for m in mappings}
54
+
55
+
56
+ def restore_interpolations(
57
+ formatted_sql: str, mappings: list[PlaceholderMapping]
58
+ ) -> str:
59
+ result = formatted_sql
60
+ for mapping in mappings:
61
+ expr = mapping.original_expr
62
+ suffix = ""
63
+ if mapping.conversion != -1:
64
+ suffix += f"!{chr(mapping.conversion)}"
65
+ if mapping.format_spec is not None:
66
+ suffix += f":{mapping.format_spec}"
67
+ replacement = "{" + expr + suffix + "}"
68
+ count = result.count(mapping.placeholder)
69
+ if count == 0:
70
+ raise ValueError(
71
+ f"Placeholder {mapping.placeholder} not found in formatted SQL. "
72
+ f"sqlfluff may have removed or restructured the placeholder. "
73
+ f"Original expression: {mapping.original_expr}"
74
+ )
75
+ if count > 1:
76
+ raise ValueError(
77
+ f"Placeholder {mapping.placeholder} appears {count} times in formatted SQL. "
78
+ f"sqlfluff may have duplicated the placeholder. "
79
+ f"Original expression: {mapping.original_expr}"
80
+ )
81
+ result = result.replace(mapping.placeholder, replacement)
82
+ return result
@@ -0,0 +1,35 @@
1
+ import ast
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class SqlTStringMatch:
7
+ tstring_node: ast.TemplateStr
8
+ call_node: ast.Call
9
+
10
+
11
+ class SqlTStringFinder(ast.NodeVisitor):
12
+ def __init__(self) -> None:
13
+ self.matches: list[SqlTStringMatch] = []
14
+
15
+ def visit_Call(self, node: ast.Call) -> None:
16
+ if self._is_sql_call(node) and node.args:
17
+ first_arg = node.args[0]
18
+ if isinstance(first_arg, ast.TemplateStr):
19
+ self.matches.append(SqlTStringMatch(first_arg, node))
20
+ self.generic_visit(node)
21
+
22
+ def _is_sql_call(self, node: ast.Call) -> bool:
23
+ func = node.func
24
+ if isinstance(func, ast.Name) and func.id == "sql":
25
+ return True
26
+ if isinstance(func, ast.Attribute) and func.attr == "sql":
27
+ return True
28
+ return False
29
+
30
+
31
+ def find_sql_tstrings(source: str) -> list[SqlTStringMatch]:
32
+ tree = ast.parse(source)
33
+ finder = SqlTStringFinder()
34
+ finder.visit(tree)
35
+ return finder.matches
@@ -0,0 +1,21 @@
1
+ import sqlfluff
2
+ from sqlfluff.core import FluffConfig
3
+
4
+
5
+ def format_sql(
6
+ sql: str,
7
+ config_path: str | None = None,
8
+ dialect: str | None = None,
9
+ context: dict[str, str] | None = None,
10
+ ) -> str:
11
+ overrides = {"templater": "python", "dialect": dialect or "ansi"}
12
+
13
+ config = FluffConfig(
14
+ extra_config_path=config_path,
15
+ overrides=overrides,
16
+ configs={"templater": {"python": {"context": context or {}}}},
17
+ require_dialect=False,
18
+ )
19
+
20
+ result = sqlfluff.fix(sql, config=config)
21
+ return result.rstrip("\n")
@@ -0,0 +1,87 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+
4
+ from sqlfluff.core.errors import SQLBaseError, SQLFluffSkipFile
5
+
6
+ from sqlfluff_tstring.extractor import (
7
+ build_context,
8
+ extract_sql,
9
+ restore_interpolations,
10
+ )
11
+ from sqlfluff_tstring.finder import find_sql_tstrings
12
+ from sqlfluff_tstring.formatter import format_sql
13
+ from sqlfluff_tstring.rewriter import Replacement, apply_replacements
14
+
15
+
16
+ @dataclass
17
+ class FileResult:
18
+ path: Path
19
+ original: str = ""
20
+ formatted: str = ""
21
+ errors: list[str] = field(default_factory=list)
22
+
23
+ @property
24
+ def changed(self) -> bool:
25
+ return self.original != self.formatted
26
+
27
+
28
+ def process_file(
29
+ path: Path,
30
+ check_only: bool = False,
31
+ dialect: str | None = None,
32
+ config_path: str | None = None,
33
+ ) -> FileResult:
34
+ try:
35
+ source = path.read_text(encoding="utf-8")
36
+ except (OSError, UnicodeDecodeError) as e:
37
+ return FileResult(path=path, errors=[f"Could not read {path}: {e}"])
38
+
39
+ result = FileResult(path=path, original=source, formatted=source)
40
+
41
+ try:
42
+ matches = find_sql_tstrings(source)
43
+ except SyntaxError as e:
44
+ result.errors.append(f"Syntax error: {e}")
45
+ return result
46
+
47
+ if not matches:
48
+ return result
49
+
50
+ replacements: list[Replacement] = []
51
+ for match in matches:
52
+ sql, mappings = extract_sql(match.tstring_node)
53
+ if not sql.strip():
54
+ continue
55
+
56
+ context = build_context(mappings)
57
+ try:
58
+ formatted = format_sql(
59
+ sql, dialect=dialect, config_path=config_path, context=context
60
+ )
61
+ except (SQLBaseError, SQLFluffSkipFile) as e:
62
+ result.errors.append(
63
+ f"sqlfluff error in {path}:{match.tstring_node.lineno}: {e}"
64
+ )
65
+ continue
66
+
67
+ try:
68
+ restored = restore_interpolations(formatted, mappings)
69
+ except ValueError as e:
70
+ result.errors.append(
71
+ f"Restore error in {path}:{match.tstring_node.lineno}: {e}"
72
+ )
73
+ continue
74
+ replacements.append(Replacement(match.tstring_node, restored))
75
+
76
+ if replacements:
77
+ new_source = apply_replacements(source, replacements)
78
+ if new_source != source:
79
+ result.formatted = new_source
80
+ if not check_only:
81
+ try:
82
+ path.write_text(new_source, encoding="utf-8")
83
+ except OSError as e:
84
+ result.formatted = source
85
+ result.errors.append(f"Could not write {path}: {e}")
86
+
87
+ return result
File without changes
@@ -0,0 +1,57 @@
1
+ import ast
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class Replacement:
7
+ tstring_node: ast.TemplateStr
8
+ new_content: str
9
+
10
+
11
+ def _detect_quote_style(source: str, tstring_node: ast.TemplateStr) -> str:
12
+ lines = source.splitlines(keepends=True)
13
+ line = lines[tstring_node.lineno - 1]
14
+ # Skip past any prefix characters and the 't' to find the quote
15
+ col = tstring_node.col_offset
16
+ while col < len(line) and line[col] in "rRbBtT":
17
+ col += 1
18
+ if line[col : col + 3] in ('"""', "'''"):
19
+ return line[col : col + 3]
20
+ return line[col]
21
+
22
+
23
+ def _get_source_range(source: str, node: ast.TemplateStr) -> tuple[int, int]:
24
+ """Return (start_offset, end_offset) as character positions in source."""
25
+ lines = source.splitlines(keepends=True)
26
+ start = sum(len(lines[i]) for i in range(node.lineno - 1)) + node.col_offset
27
+ if node.end_lineno is None or node.end_col_offset is None:
28
+ raise ValueError(
29
+ f"AST node at line {node.lineno} is missing end position metadata"
30
+ )
31
+ end = sum(len(lines[i]) for i in range(node.end_lineno - 1)) + node.end_col_offset
32
+ return start, end
33
+
34
+
35
+ def apply_replacements(source: str, replacements: list[Replacement]) -> str:
36
+ # Sort by position in reverse order so earlier replacements don't shift offsets
37
+ sorted_replacements = sorted(
38
+ replacements,
39
+ key=lambda r: (r.tstring_node.lineno, r.tstring_node.col_offset),
40
+ reverse=True,
41
+ )
42
+
43
+ result = source
44
+ for replacement in sorted_replacements:
45
+ start, end = _get_source_range(result, replacement.tstring_node)
46
+ quote = _detect_quote_style(result, replacement.tstring_node)
47
+ # Upgrade to triple quotes if content has newlines
48
+ if "\n" in replacement.new_content and len(quote) == 1:
49
+ quote = quote * 3
50
+ # Wrap multiline content with leading/trailing newlines in triple-quoted strings
51
+ content = replacement.new_content
52
+ if len(quote) == 3 and "\n" in content:
53
+ content = f"\n{content}\n"
54
+ new_tstring = f"t{quote}{content}{quote}"
55
+ result = result[:start] + new_tstring + result[end:]
56
+
57
+ return result