sqlakit 0.22.1__tar.gz → 0.22.3__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.
- {sqlakit-0.22.1 → sqlakit-0.22.3}/PKG-INFO +8 -1
- {sqlakit-0.22.1 → sqlakit-0.22.3}/README.md +7 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/pyproject.toml +2 -1
- {sqlakit-0.22.1 → sqlakit-0.22.3}/pyproject.toml.orig +2 -1
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_cli.py +50 -10
- sqlakit-0.22.3/sqlakit/_linters.py +258 -0
- sqlakit-0.22.3/sqlakit/_sql_formatter.py +91 -0
- sqlakit-0.22.1/sqlakit/_sqruff.py +0 -168
- {sqlakit-0.22.1 → sqlakit-0.22.3}/LICENSE +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/__init__.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_base.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_db.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_model.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_project.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_pycharm.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_query.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_recording.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_registry.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_routing.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_sql.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/_static.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/asyncio/orm.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/exceptions.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/orm.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/py.typed +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/pytest_plugin.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/sql.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/testing.py +0 -0
- {sqlakit-0.22.1 → sqlakit-0.22.3}/sqlakit/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlakit
|
|
3
|
-
Version: 0.22.
|
|
3
|
+
Version: 0.22.3
|
|
4
4
|
Summary: A toolkit for SQLAlchemy applications.
|
|
5
5
|
Keywords: sqlalchemy,database,orm,sql,asyncio
|
|
6
6
|
Author: Anton Ruhlov
|
|
@@ -133,6 +133,13 @@ shows the source file of each query right away.
|
|
|
133
133
|
that can't work, before the query runs. See [template
|
|
134
134
|
validation](docs/sql.md#template-validation).
|
|
135
135
|
|
|
136
|
+
The [VS Code
|
|
137
|
+
extension](https://marketplace.visualstudio.com/items?itemName=sqlakit.sqlakit)
|
|
138
|
+
completes the macros, links a template name in Python to its file, marks the
|
|
139
|
+
same problems as you type, and shows a template rendered as the database gets
|
|
140
|
+
it. It runs `sqlakit-lsp`, a language server any other editor can run too. See
|
|
141
|
+
[editor support](docs/sql.md#editor-support).
|
|
142
|
+
|
|
136
143
|
### From a string
|
|
137
144
|
|
|
138
145
|
```python
|
|
@@ -102,6 +102,13 @@ shows the source file of each query right away.
|
|
|
102
102
|
that can't work, before the query runs. See [template
|
|
103
103
|
validation](docs/sql.md#template-validation).
|
|
104
104
|
|
|
105
|
+
The [VS Code
|
|
106
|
+
extension](https://marketplace.visualstudio.com/items?itemName=sqlakit.sqlakit)
|
|
107
|
+
completes the macros, links a template name in Python to its file, marks the
|
|
108
|
+
same problems as you type, and shows a template rendered as the database gets
|
|
109
|
+
it. It runs `sqlakit-lsp`, a language server any other editor can run too. See
|
|
110
|
+
[editor support](docs/sql.md#editor-support).
|
|
111
|
+
|
|
105
112
|
### From a string
|
|
106
113
|
|
|
107
114
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlakit"
|
|
3
|
-
version = "0.22.
|
|
3
|
+
version = "0.22.3"
|
|
4
4
|
description = "A toolkit for SQLAlchemy applications."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -201,6 +201,7 @@ dev = [
|
|
|
201
201
|
"dirty-equals>=0.11",
|
|
202
202
|
"codespell>=2.4.3",
|
|
203
203
|
"sqruff>=0.40.0",
|
|
204
|
+
"sqlfluff>=4.3.0",
|
|
204
205
|
]
|
|
205
206
|
docs = [
|
|
206
207
|
"mkdocs>=1.6.1,<2",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlakit"
|
|
3
|
-
version = "0.22.
|
|
3
|
+
version = "0.22.3"
|
|
4
4
|
description = "A toolkit for SQLAlchemy applications."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -77,6 +77,7 @@ dev = [
|
|
|
77
77
|
"dirty-equals>=0.11",
|
|
78
78
|
"codespell>=2.4.3",
|
|
79
79
|
"sqruff>=0.40.0",
|
|
80
|
+
"sqlfluff>=4.3.0",
|
|
80
81
|
]
|
|
81
82
|
docs = [
|
|
82
83
|
"mkdocs>=1.6.1,<2",
|
|
@@ -8,10 +8,11 @@ import os
|
|
|
8
8
|
import sys
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
+
from . import _sql_formatter
|
|
12
|
+
from ._linters import LINTERS, settings, stale
|
|
11
13
|
from ._project import Problem, Project, load_project
|
|
12
14
|
from ._pycharm import DIALECTS, ddl, dialects
|
|
13
15
|
from ._sql import NAMESPACE, registered, signature_of
|
|
14
|
-
from ._sqruff import settings, stale
|
|
15
16
|
from .exceptions import MacroDefinitionError, ProjectConfigError, UnknownImportPathError
|
|
16
17
|
|
|
17
18
|
|
|
@@ -55,7 +56,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
55
56
|
export = commands.add_parser(
|
|
56
57
|
"export", help="write what another tool needs to read the templates"
|
|
57
58
|
)
|
|
58
|
-
export.add_argument("tool", choices=("
|
|
59
|
+
export.add_argument("tool", choices=(*LINTERS, "sql-formatter", "pycharm"))
|
|
59
60
|
export.add_argument(
|
|
60
61
|
"--project",
|
|
61
62
|
default=".",
|
|
@@ -74,9 +75,23 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
74
75
|
if arguments.command == "check":
|
|
75
76
|
return _check(Path(arguments.project), json_output=arguments.format == "json")
|
|
76
77
|
if arguments.command == "export":
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
if arguments.tool == "sql-formatter":
|
|
79
|
+
return _export_sql_formatter(
|
|
80
|
+
Path(arguments.project),
|
|
81
|
+
dialect=arguments.dialect,
|
|
82
|
+
check=arguments.check,
|
|
83
|
+
)
|
|
84
|
+
if arguments.tool == "pycharm":
|
|
85
|
+
return _export_pycharm(
|
|
86
|
+
Path(arguments.project),
|
|
87
|
+
dialect=arguments.dialect,
|
|
88
|
+
check=arguments.check,
|
|
89
|
+
)
|
|
90
|
+
return _export_linter(
|
|
91
|
+
arguments.tool,
|
|
92
|
+
Path(arguments.project),
|
|
93
|
+
dialect=arguments.dialect,
|
|
94
|
+
check=arguments.check,
|
|
80
95
|
)
|
|
81
96
|
if arguments.command == "macros":
|
|
82
97
|
return _macros(
|
|
@@ -165,21 +180,24 @@ def _check(directory: Path, *, json_output: bool) -> int:
|
|
|
165
180
|
return 1 if problems else 0
|
|
166
181
|
|
|
167
182
|
|
|
168
|
-
def
|
|
169
|
-
|
|
183
|
+
def _export_linter(
|
|
184
|
+
name: str, directory: Path, *, dialect: str | None, check: bool
|
|
185
|
+
) -> int:
|
|
186
|
+
"""Write the settings of `sqruff` or `sqlfluff` that read the templates."""
|
|
187
|
+
linter = LINTERS[name]
|
|
170
188
|
project = _load(directory)
|
|
171
189
|
if project is None:
|
|
172
190
|
return 2
|
|
173
191
|
pyproject = project.root / "pyproject.toml"
|
|
174
192
|
text = pyproject.read_text(encoding="utf-8") if pyproject.exists() else ""
|
|
175
193
|
if check:
|
|
176
|
-
tables = stale(project, text)
|
|
194
|
+
tables = stale(linter, project, text)
|
|
177
195
|
if tables:
|
|
178
|
-
_say(f"{', '.join(tables)} out of date: run `sqlakit export
|
|
196
|
+
_say(f"{', '.join(tables)} out of date: run `sqlakit export {name}`")
|
|
179
197
|
return 1
|
|
180
198
|
return 0
|
|
181
199
|
try:
|
|
182
|
-
written = settings(project, text, dialect)
|
|
200
|
+
written = settings(linter, project, text, dialect)
|
|
183
201
|
except ProjectConfigError as error:
|
|
184
202
|
_say(str(error))
|
|
185
203
|
return 2
|
|
@@ -188,6 +206,28 @@ def _export_sqruff(directory: Path, *, dialect: str | None, check: bool) -> int:
|
|
|
188
206
|
return 0
|
|
189
207
|
|
|
190
208
|
|
|
209
|
+
def _export_sql_formatter(directory: Path, *, dialect: str | None, check: bool) -> int:
|
|
210
|
+
"""Write the `.sql-formatter.json` that reads `:name` as a parameter."""
|
|
211
|
+
project = _load(directory)
|
|
212
|
+
if project is None:
|
|
213
|
+
return 2
|
|
214
|
+
path = project.root / _sql_formatter.FILE
|
|
215
|
+
written = path.read_text(encoding="utf-8") if path.exists() else None
|
|
216
|
+
if check:
|
|
217
|
+
if _sql_formatter.stale(written):
|
|
218
|
+
_say(f"{_relative(path)} out of date: run `sqlakit export sql-formatter`")
|
|
219
|
+
return 1
|
|
220
|
+
return 0
|
|
221
|
+
try:
|
|
222
|
+
text = _sql_formatter.config(project, written, dialect)
|
|
223
|
+
except ProjectConfigError as error:
|
|
224
|
+
_say(str(error))
|
|
225
|
+
return 2
|
|
226
|
+
path.write_text(text, encoding="utf-8")
|
|
227
|
+
_say(f"wrote {_relative(path)}")
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
191
231
|
def _export_pycharm(directory: Path, *, dialect: str | None, check: bool) -> int:
|
|
192
232
|
"""Write what PyCharm needs into `.idea/`: the macros as DDL, and the dialect."""
|
|
193
233
|
project = _load(directory)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""The settings in `pyproject.toml` that let `sqruff` or `sqlfluff` read templates.
|
|
2
|
+
|
|
3
|
+
The `placeholder` templater writes each `:name` as `name`. Where that reads as
|
|
4
|
+
something else, `settings` gives the parameter a value; see
|
|
5
|
+
`Project.placeholder_values`. Which names a dialect keeps for itself is asked
|
|
6
|
+
of the linter, when it is installed, since its dialects keep words SQLAlchemy's
|
|
7
|
+
do not: `exclude` and `row` on SQLite.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import tomllib
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from ._project import LINT_EXCLUDED
|
|
23
|
+
from .exceptions import ProjectConfigError
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
|
|
28
|
+
from ._project import Project
|
|
29
|
+
|
|
30
|
+
__all__ = ["LINTERS", "Linter", "settings", "stale"]
|
|
31
|
+
|
|
32
|
+
_NEXT_TABLE = re.compile(r"^\[", re.MULTILINE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Linter:
|
|
37
|
+
"""A linter that reads templates with its `placeholder` templater."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
"""The executable, and the key of its tables under `[tool]`."""
|
|
41
|
+
dialects: dict[str, str]
|
|
42
|
+
"""The linter's name for a dialect SQLAlchemy names another way."""
|
|
43
|
+
rules: tuple[str, ...]
|
|
44
|
+
"""The lines of `[tool.<name>.core]` that choose the rules."""
|
|
45
|
+
default_dialect: str | None
|
|
46
|
+
"""The dialect written when neither the call nor the project gives one."""
|
|
47
|
+
kept: Callable[[str, list[str], str | None], set[int]]
|
|
48
|
+
"""Given the executable, the names and the dialect, the index of each name it
|
|
49
|
+
cannot read as a column."""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def placeholder(self) -> str:
|
|
53
|
+
return f"[tool.{self.name}.templater.placeholder]"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def core(self) -> str:
|
|
57
|
+
return f"[tool.{self.name}.core]"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def stale(linter: Linter, project: Project, pyproject: str) -> list[str]:
|
|
61
|
+
"""Return the tables of `pyproject.toml` that no longer read the templates."""
|
|
62
|
+
tables = tomllib.loads(pyproject).get("tool", {}).get(linter.name, {})
|
|
63
|
+
written = tables.get("templater", {}).get("placeholder", {})
|
|
64
|
+
chosen = _dialect(linter, tables, None, project)
|
|
65
|
+
values = project.placeholder_values(_kept(linter, project, chosen))
|
|
66
|
+
checked = (
|
|
67
|
+
(linter.core, "core" in tables),
|
|
68
|
+
(
|
|
69
|
+
linter.placeholder,
|
|
70
|
+
all(str(written.get(name)) == str(value) for name, value in values.items()),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
return [table for table, fresh in checked if not fresh]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def settings(
|
|
77
|
+
linter: Linter, project: Project, pyproject: str, dialect: str | None
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Return `pyproject.toml` with the settings that read the templates.
|
|
80
|
+
|
|
81
|
+
The values the templates need are written again each time, and a value
|
|
82
|
+
added to the table by hand stays. The core table is written only when it
|
|
83
|
+
is missing, so the rules set there stay.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ProjectConfigError: if the table is written in a way this cannot rewrite.
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
tables = tomllib.loads(pyproject).get("tool", {}).get(linter.name, {})
|
|
90
|
+
written = dict(tables.get("templater", {}).get("placeholder", {}))
|
|
91
|
+
written.pop("param_style", None)
|
|
92
|
+
text = pyproject
|
|
93
|
+
chosen = _dialect(linter, tables, dialect, project)
|
|
94
|
+
if "core" not in tables:
|
|
95
|
+
core = [
|
|
96
|
+
linter.core,
|
|
97
|
+
*([f'dialect = "{chosen}"'] if chosen else []),
|
|
98
|
+
'templater = "placeholder"',
|
|
99
|
+
*linter.rules,
|
|
100
|
+
f'exclude_rules = "{",".join(LINT_EXCLUDED)}"',
|
|
101
|
+
]
|
|
102
|
+
text = text.rstrip("\n") + "\n\n" + "\n".join(core) + "\n"
|
|
103
|
+
values = project.placeholder_values(_kept(linter, project, chosen))
|
|
104
|
+
merged = dict(sorted({**written, **values}.items()))
|
|
105
|
+
table = "\n".join(
|
|
106
|
+
[
|
|
107
|
+
linter.placeholder,
|
|
108
|
+
'param_style = "colon"',
|
|
109
|
+
*(f"{name} = {_toml_value(value)}" for name, value in merged.items()),
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
header = re.compile(
|
|
113
|
+
rf"^{re.escape(linter.placeholder)}[ \t]*(?:#[^\n]*)?$", re.MULTILINE
|
|
114
|
+
)
|
|
115
|
+
if found := header.search(text):
|
|
116
|
+
following = _NEXT_TABLE.search(text, found.end())
|
|
117
|
+
end = following.start() if following else len(text)
|
|
118
|
+
after = "\n\n" if following else "\n"
|
|
119
|
+
written_out = text[: found.start()] + table + after + text[end:]
|
|
120
|
+
else:
|
|
121
|
+
written_out = text.rstrip("\n") + "\n\n" + table + "\n"
|
|
122
|
+
try:
|
|
123
|
+
tomllib.loads(written_out)
|
|
124
|
+
except tomllib.TOMLDecodeError as error:
|
|
125
|
+
problem = (
|
|
126
|
+
f"`{linter.placeholder}` is written in a way this "
|
|
127
|
+
f"cannot rewrite ({error}): give it a table of its own"
|
|
128
|
+
)
|
|
129
|
+
raise ProjectConfigError(problem) from error
|
|
130
|
+
return written_out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _dialect(
|
|
134
|
+
linter: Linter, tables: dict, given: str | None, project: Project
|
|
135
|
+
) -> str | None:
|
|
136
|
+
"""Return the dialect the linter reads the templates in, by its name."""
|
|
137
|
+
if "core" in tables:
|
|
138
|
+
# A table without a dialect is read as the linter reads it, and so here.
|
|
139
|
+
written = tables["core"].get("dialect")
|
|
140
|
+
return written if isinstance(written, str) else linter.default_dialect
|
|
141
|
+
chosen = given or project.dialect
|
|
142
|
+
if chosen is None:
|
|
143
|
+
return linter.default_dialect
|
|
144
|
+
return linter.dialects.get(chosen, chosen)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _kept(linter: Linter, project: Project, dialect: str | None) -> set[str]:
|
|
148
|
+
"""Return the parameter names the linter cannot read as a column in the dialect.
|
|
149
|
+
|
|
150
|
+
Without the linter installed, the dialects of SQLAlchemy decide alone.
|
|
151
|
+
"""
|
|
152
|
+
binary = shutil.which(linter.name)
|
|
153
|
+
names = sorted(project.parameter_names())
|
|
154
|
+
if binary is None or not names:
|
|
155
|
+
return set()
|
|
156
|
+
return {names[index] for index in linter.kept(binary, names, dialect)}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _sqruff_kept(binary: str, names: list[str], dialect: str | None) -> set[int]:
|
|
160
|
+
"""Lint each name as `SELECT <name>;`, one to a line, in one run of `sqruff`."""
|
|
161
|
+
# JSON, as the report of its own is another shape on GitHub Actions.
|
|
162
|
+
command = [binary, "lint", "--parsing-errors", "--format", "json", "-"]
|
|
163
|
+
if dialect:
|
|
164
|
+
command[2:2] = ["--dialect", dialect]
|
|
165
|
+
source = "".join(f"SELECT {name};\n" for name in names)
|
|
166
|
+
# Away from the project, so no setting of its own changes the reading.
|
|
167
|
+
with tempfile.TemporaryDirectory() as empty:
|
|
168
|
+
found = subprocess.run( # noqa: S603 - sqruff, found on the PATH
|
|
169
|
+
command,
|
|
170
|
+
input=source,
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
cwd=empty,
|
|
174
|
+
check=False,
|
|
175
|
+
)
|
|
176
|
+
lines = {
|
|
177
|
+
problem["range"]["start"]["line"]
|
|
178
|
+
for problems in _report(found.stdout, found.stderr, shape=dict).values()
|
|
179
|
+
for problem in problems
|
|
180
|
+
# The name starts after `SELECT `, at the eighth character.
|
|
181
|
+
if problem.get("message") == "Unparsable section"
|
|
182
|
+
and problem["range"]["start"]["character"] == len("SELECT ") + 1
|
|
183
|
+
}
|
|
184
|
+
return {index for index in range(len(names)) if index + 1 in lines}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _sqlfluff_kept(binary: str, names: list[str], dialect: str | None) -> set[int]:
|
|
188
|
+
"""Lint each name as `SELECT <name>;`, a file each, in one run of `sqlfluff`.
|
|
189
|
+
|
|
190
|
+
A file each, as `sqlfluff` reads the lines after one it cannot parse as
|
|
191
|
+
part of it.
|
|
192
|
+
"""
|
|
193
|
+
with tempfile.TemporaryDirectory() as empty:
|
|
194
|
+
folder = Path(empty)
|
|
195
|
+
for index, name in enumerate(names):
|
|
196
|
+
(folder / f"{index}.sql").write_text(f"SELECT {name};\n", encoding="utf-8")
|
|
197
|
+
command = [binary, "lint", "--format", "json", "--dialect", dialect or "ansi"]
|
|
198
|
+
found = subprocess.run( # noqa: S603 - sqlfluff, found on the PATH
|
|
199
|
+
[*command, "."],
|
|
200
|
+
capture_output=True,
|
|
201
|
+
text=True,
|
|
202
|
+
cwd=empty,
|
|
203
|
+
check=False,
|
|
204
|
+
)
|
|
205
|
+
return {
|
|
206
|
+
int(Path(report["filepath"]).stem)
|
|
207
|
+
for report in _report(found.stdout, found.stderr, shape=list)
|
|
208
|
+
for problem in report.get("violations", ())
|
|
209
|
+
# The name starts after `SELECT `, at the eighth character.
|
|
210
|
+
if problem.get("code") == "PRS"
|
|
211
|
+
and problem.get("start_line_no") == 1
|
|
212
|
+
and problem.get("start_line_pos") == len("SELECT ") + 1
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _report(*outputs: str, shape: type) -> Any: # noqa: ANN401 - either linter's JSON
|
|
217
|
+
"""Return the JSON report, from whichever stream the linter wrote it to."""
|
|
218
|
+
for output in outputs:
|
|
219
|
+
try:
|
|
220
|
+
report = json.loads(output)
|
|
221
|
+
except json.JSONDecodeError:
|
|
222
|
+
continue
|
|
223
|
+
if isinstance(report, shape):
|
|
224
|
+
return report
|
|
225
|
+
return shape()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _toml_value(value: object) -> str:
|
|
229
|
+
"""Return a value as TOML writes it: a string quoted, a flag in lower case."""
|
|
230
|
+
if isinstance(value, bool):
|
|
231
|
+
return str(value).lower()
|
|
232
|
+
if isinstance(value, int | float):
|
|
233
|
+
return str(value)
|
|
234
|
+
return json.dumps(str(value))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
SQRUFF = Linter(
|
|
238
|
+
name="sqruff",
|
|
239
|
+
dialects={"postgresql": "postgres", "mariadb": "mysql", "mssql": "tsql"},
|
|
240
|
+
# sqruff turns on a few rules unless told, and every one on `all`.
|
|
241
|
+
rules=('rules = "all"',),
|
|
242
|
+
# sqruff reads settings without a dialect as ANSI.
|
|
243
|
+
default_dialect=None,
|
|
244
|
+
kept=_sqruff_kept,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
SQLFLUFF = Linter(
|
|
248
|
+
name="sqlfluff",
|
|
249
|
+
dialects={"postgresql": "postgres", "mssql": "tsql"},
|
|
250
|
+
# sqlfluff turns on every rule unless told.
|
|
251
|
+
rules=(),
|
|
252
|
+
# sqlfluff reads no template without a dialect.
|
|
253
|
+
default_dialect="ansi",
|
|
254
|
+
kept=_sqlfluff_kept,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
LINTERS = {linter.name: linter for linter in (SQRUFF, SQLFLUFF)}
|
|
258
|
+
"""Each linter `sqlakit export` writes settings for, by its name."""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The `.sql-formatter.json` that lets `sql-formatter` format templates.
|
|
2
|
+
|
|
3
|
+
`sql-formatter` reads `:name` as a parameter only when `paramTypes` says so.
|
|
4
|
+
Without it, `c = :limit::int` on PostgreSQL comes back split over lines, with
|
|
5
|
+
the parameter gone. A macro call is a function call to it, and needs nothing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from .exceptions import ProjectConfigError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ._project import Project
|
|
17
|
+
|
|
18
|
+
__all__ = ["FILE", "config", "stale"]
|
|
19
|
+
|
|
20
|
+
FILE = ".sql-formatter.json"
|
|
21
|
+
"""Where `sql-formatter` looks for its settings, in the directory it runs in."""
|
|
22
|
+
|
|
23
|
+
LANGUAGES = {
|
|
24
|
+
"bigquery",
|
|
25
|
+
"clickhouse",
|
|
26
|
+
"db2",
|
|
27
|
+
"duckdb",
|
|
28
|
+
"hive",
|
|
29
|
+
"mariadb",
|
|
30
|
+
"mysql",
|
|
31
|
+
"postgresql",
|
|
32
|
+
"redshift",
|
|
33
|
+
"snowflake",
|
|
34
|
+
"sqlite",
|
|
35
|
+
"trino",
|
|
36
|
+
}
|
|
37
|
+
"""The dialects `sql-formatter` names as SQLAlchemy does."""
|
|
38
|
+
|
|
39
|
+
_RENAMED = {"oracle": "plsql", "mssql": "transactsql", "databricks": "spark"}
|
|
40
|
+
"""`sql-formatter`'s name for a dialect SQLAlchemy names another way."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def stale(written: str | None) -> bool:
|
|
44
|
+
"""Whether the file is missing, or does not read `:name` as a parameter."""
|
|
45
|
+
if written is None:
|
|
46
|
+
return True
|
|
47
|
+
try:
|
|
48
|
+
settings = _read(written)
|
|
49
|
+
except ProjectConfigError:
|
|
50
|
+
return True
|
|
51
|
+
named = settings.get("paramTypes", {}).get("named", [])
|
|
52
|
+
return "language" not in settings or ":" not in named
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def config(project: Project, written: str | None, dialect: str | None) -> str:
|
|
56
|
+
"""Return the file, with what `sql-formatter` needs to format the templates.
|
|
57
|
+
|
|
58
|
+
`:` is added to the named parameters, and the language is written when the
|
|
59
|
+
file has none. Every other setting stays as it is.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ProjectConfigError: if the file is not a JSON object.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
settings = _read(written) if written is not None else {}
|
|
66
|
+
if "language" not in settings:
|
|
67
|
+
chosen = (dialect or project.dialect or "").lower()
|
|
68
|
+
settings["language"] = _RENAMED.get(chosen) or (
|
|
69
|
+
chosen if chosen in LANGUAGES else "sql"
|
|
70
|
+
)
|
|
71
|
+
types = settings.setdefault("paramTypes", {})
|
|
72
|
+
if not isinstance(types, dict):
|
|
73
|
+
problem = f"`paramTypes` in {FILE} is not an object: make it one, or remove it"
|
|
74
|
+
raise ProjectConfigError(problem)
|
|
75
|
+
named = types.setdefault("named", [])
|
|
76
|
+
if ":" not in named:
|
|
77
|
+
named.append(":")
|
|
78
|
+
return json.dumps(settings, indent=2) + "\n"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read(written: str) -> dict[str, Any]:
|
|
82
|
+
"""Return the settings a file holds."""
|
|
83
|
+
try:
|
|
84
|
+
settings = json.loads(written)
|
|
85
|
+
except json.JSONDecodeError as error:
|
|
86
|
+
problem = f"{FILE} is not JSON ({error}): fix it, or remove it"
|
|
87
|
+
raise ProjectConfigError(problem) from error
|
|
88
|
+
if not isinstance(settings, dict):
|
|
89
|
+
problem = f"{FILE} is not a JSON object: fix it, or remove it"
|
|
90
|
+
raise ProjectConfigError(problem)
|
|
91
|
+
return settings
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
"""The `sqruff` settings in `pyproject.toml` that read templates as SQL.
|
|
2
|
-
|
|
3
|
-
The `placeholder` templater writes each `:name` as `name`. Where that reads as
|
|
4
|
-
something else, `settings` gives the parameter a value; see
|
|
5
|
-
`Project.placeholder_values`. Which names a dialect keeps for itself is asked
|
|
6
|
-
of `sqruff`, when it is installed, since its dialects keep words SQLAlchemy's
|
|
7
|
-
do not: `exclude` and `row` on SQLite.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import json
|
|
13
|
-
import re
|
|
14
|
-
import shutil
|
|
15
|
-
import subprocess
|
|
16
|
-
import tempfile
|
|
17
|
-
import tomllib
|
|
18
|
-
from typing import TYPE_CHECKING, Any
|
|
19
|
-
|
|
20
|
-
from ._project import LINT_EXCLUDED
|
|
21
|
-
from .exceptions import ProjectConfigError
|
|
22
|
-
|
|
23
|
-
if TYPE_CHECKING:
|
|
24
|
-
from ._project import Project
|
|
25
|
-
|
|
26
|
-
__all__ = ["settings", "stale"]
|
|
27
|
-
|
|
28
|
-
_HEADER = re.compile(
|
|
29
|
-
r"^\[tool\.sqruff\.templater\.placeholder\][ \t]*(?:#[^\n]*)?$", re.MULTILINE
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
_DIALECTS = {"postgresql": "postgres", "mariadb": "mysql", "mssql": "tsql"}
|
|
33
|
-
"""sqruff's name for a dialect SQLAlchemy names another way."""
|
|
34
|
-
_NEXT_TABLE = re.compile(r"^\[", re.MULTILINE)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def stale(project: Project, pyproject: str) -> list[str]:
|
|
38
|
-
"""Return the tables of `pyproject.toml` that no longer read the templates."""
|
|
39
|
-
sqruff = tomllib.loads(pyproject).get("tool", {}).get("sqruff", {})
|
|
40
|
-
written = sqruff.get("templater", {}).get("placeholder", {})
|
|
41
|
-
values = project.placeholder_values(_kept(project, _dialect(sqruff, None, project)))
|
|
42
|
-
checked = (
|
|
43
|
-
("[tool.sqruff.core]", "core" in sqruff),
|
|
44
|
-
(
|
|
45
|
-
"[tool.sqruff.templater.placeholder]",
|
|
46
|
-
all(str(written.get(name)) == str(value) for name, value in values.items()),
|
|
47
|
-
),
|
|
48
|
-
)
|
|
49
|
-
return [table for table, fresh in checked if not fresh]
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def settings(project: Project, pyproject: str, dialect: str | None) -> str:
|
|
53
|
-
"""Return `pyproject.toml` with the settings that read the templates.
|
|
54
|
-
|
|
55
|
-
The values the templates need are written again each time, and a value
|
|
56
|
-
added to the table by hand stays. `[tool.sqruff.core]` is written only when
|
|
57
|
-
it is missing, so the rules set there stay.
|
|
58
|
-
|
|
59
|
-
Raises:
|
|
60
|
-
ProjectConfigError: if the table is written in a way this cannot rewrite.
|
|
61
|
-
|
|
62
|
-
"""
|
|
63
|
-
sqruff = tomllib.loads(pyproject).get("tool", {}).get("sqruff", {})
|
|
64
|
-
written = dict(sqruff.get("templater", {}).get("placeholder", {}))
|
|
65
|
-
written.pop("param_style", None)
|
|
66
|
-
text = pyproject
|
|
67
|
-
chosen = _dialect(sqruff, dialect, project)
|
|
68
|
-
if "core" not in sqruff:
|
|
69
|
-
core = [
|
|
70
|
-
"[tool.sqruff.core]",
|
|
71
|
-
*([f'dialect = "{chosen}"'] if chosen else []),
|
|
72
|
-
'templater = "placeholder"',
|
|
73
|
-
'rules = "all"',
|
|
74
|
-
f'exclude_rules = "{",".join(LINT_EXCLUDED)}"',
|
|
75
|
-
]
|
|
76
|
-
text = text.rstrip("\n") + "\n\n" + "\n".join(core) + "\n"
|
|
77
|
-
values = project.placeholder_values(_kept(project, chosen))
|
|
78
|
-
merged = dict(sorted({**written, **values}.items()))
|
|
79
|
-
table = "\n".join(
|
|
80
|
-
[
|
|
81
|
-
"[tool.sqruff.templater.placeholder]",
|
|
82
|
-
'param_style = "colon"',
|
|
83
|
-
*(f"{name} = {_toml_value(value)}" for name, value in merged.items()),
|
|
84
|
-
]
|
|
85
|
-
)
|
|
86
|
-
if found := _HEADER.search(text):
|
|
87
|
-
following = _NEXT_TABLE.search(text, found.end())
|
|
88
|
-
end = following.start() if following else len(text)
|
|
89
|
-
after = "\n\n" if following else "\n"
|
|
90
|
-
written_out = text[: found.start()] + table + after + text[end:]
|
|
91
|
-
else:
|
|
92
|
-
written_out = text.rstrip("\n") + "\n\n" + table + "\n"
|
|
93
|
-
try:
|
|
94
|
-
tomllib.loads(written_out)
|
|
95
|
-
except tomllib.TOMLDecodeError as error:
|
|
96
|
-
problem = (
|
|
97
|
-
"`[tool.sqruff.templater.placeholder]` is written in a way this "
|
|
98
|
-
f"cannot rewrite ({error}): give it a table of its own"
|
|
99
|
-
)
|
|
100
|
-
raise ProjectConfigError(problem) from error
|
|
101
|
-
return written_out
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _dialect(sqruff: dict, given: str | None, project: Project) -> str | None:
|
|
105
|
-
"""Return the dialect `sqruff` reads the templates in, by its name."""
|
|
106
|
-
if "core" in sqruff:
|
|
107
|
-
# sqruff reads a table without a dialect as ANSI, and so does this.
|
|
108
|
-
written = sqruff["core"].get("dialect")
|
|
109
|
-
return written if isinstance(written, str) else None
|
|
110
|
-
chosen = given or project.dialect
|
|
111
|
-
return _DIALECTS.get(chosen or "", chosen)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _kept(project: Project, dialect: str | None) -> set[str]:
|
|
115
|
-
"""Return the parameter names `sqruff` cannot read as a column in the dialect.
|
|
116
|
-
|
|
117
|
-
Each name is linted as `SELECT <name>;`, one to a line, in one run. Without
|
|
118
|
-
`sqruff` installed, the dialects of SQLAlchemy decide alone.
|
|
119
|
-
"""
|
|
120
|
-
binary = shutil.which("sqruff")
|
|
121
|
-
names = sorted(project.parameter_names())
|
|
122
|
-
if binary is None or not names:
|
|
123
|
-
return set()
|
|
124
|
-
# JSON, as the report of its own is another shape on GitHub Actions.
|
|
125
|
-
command = [binary, "lint", "--parsing-errors", "--format", "json", "-"]
|
|
126
|
-
if dialect:
|
|
127
|
-
command[2:2] = ["--dialect", dialect]
|
|
128
|
-
source = "".join(f"SELECT {name};\n" for name in names)
|
|
129
|
-
# Away from the project, so no setting of its own changes the reading.
|
|
130
|
-
with tempfile.TemporaryDirectory() as empty:
|
|
131
|
-
found = subprocess.run( # noqa: S603 - sqruff, found on the PATH
|
|
132
|
-
command,
|
|
133
|
-
input=source,
|
|
134
|
-
capture_output=True,
|
|
135
|
-
text=True,
|
|
136
|
-
cwd=empty,
|
|
137
|
-
check=False,
|
|
138
|
-
)
|
|
139
|
-
lines = {
|
|
140
|
-
problem["range"]["start"]["line"]
|
|
141
|
-
for problems in _report(found.stdout, found.stderr).values()
|
|
142
|
-
for problem in problems
|
|
143
|
-
# The name starts after `SELECT `, at the eighth character.
|
|
144
|
-
if problem.get("message") == "Unparsable section"
|
|
145
|
-
and problem["range"]["start"]["character"] == len("SELECT ") + 1
|
|
146
|
-
}
|
|
147
|
-
return {name for number, name in enumerate(names, 1) if number in lines}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def _report(*outputs: str) -> dict[str, list[dict[str, Any]]]:
|
|
151
|
-
"""Return the JSON report, from whichever stream `sqruff` wrote it to."""
|
|
152
|
-
for output in outputs:
|
|
153
|
-
try:
|
|
154
|
-
report = json.loads(output)
|
|
155
|
-
except json.JSONDecodeError:
|
|
156
|
-
continue
|
|
157
|
-
if isinstance(report, dict):
|
|
158
|
-
return report
|
|
159
|
-
return {}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _toml_value(value: object) -> str:
|
|
163
|
-
"""Return a value as TOML writes it: a string quoted, a flag in lower case."""
|
|
164
|
-
if isinstance(value, bool):
|
|
165
|
-
return str(value).lower()
|
|
166
|
-
if isinstance(value, int | float):
|
|
167
|
-
return str(value)
|
|
168
|
-
return json.dumps(str(value))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|