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.
- sqlfluff_tstring-0.1.0/PKG-INFO +108 -0
- sqlfluff_tstring-0.1.0/README.md +87 -0
- sqlfluff_tstring-0.1.0/pyproject.toml +49 -0
- sqlfluff_tstring-0.1.0/src/sqlfluff_tstring/__init__.py +0 -0
- sqlfluff_tstring-0.1.0/src/sqlfluff_tstring/__main__.py +8 -0
- sqlfluff_tstring-0.1.0/src/sqlfluff_tstring/cli.py +82 -0
- sqlfluff_tstring-0.1.0/src/sqlfluff_tstring/extractor.py +82 -0
- sqlfluff_tstring-0.1.0/src/sqlfluff_tstring/finder.py +35 -0
- sqlfluff_tstring-0.1.0/src/sqlfluff_tstring/formatter.py +21 -0
- sqlfluff_tstring-0.1.0/src/sqlfluff_tstring/pipeline.py +87 -0
- sqlfluff_tstring-0.1.0/src/sqlfluff_tstring/py.typed +0 -0
- sqlfluff_tstring-0.1.0/src/sqlfluff_tstring/rewriter.py +57 -0
|
@@ -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" ]
|
|
File without changes
|
|
@@ -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
|