sqlakit 0.22.2__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.
Files changed (34) hide show
  1. {sqlakit-0.22.2 → sqlakit-0.22.3}/PKG-INFO +1 -1
  2. {sqlakit-0.22.2 → sqlakit-0.22.3}/pyproject.toml +2 -1
  3. {sqlakit-0.22.2 → sqlakit-0.22.3}/pyproject.toml.orig +2 -1
  4. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_cli.py +50 -10
  5. sqlakit-0.22.3/sqlakit/_linters.py +258 -0
  6. sqlakit-0.22.3/sqlakit/_sql_formatter.py +91 -0
  7. sqlakit-0.22.2/sqlakit/_sqruff.py +0 -168
  8. {sqlakit-0.22.2 → sqlakit-0.22.3}/LICENSE +0 -0
  9. {sqlakit-0.22.2 → sqlakit-0.22.3}/README.md +0 -0
  10. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/__init__.py +0 -0
  11. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_base.py +0 -0
  12. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_db.py +0 -0
  13. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_discovery.py +0 -0
  14. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_model.py +0 -0
  15. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_project.py +0 -0
  16. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_pycharm.py +0 -0
  17. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_query.py +0 -0
  18. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_recording.py +0 -0
  19. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_registry.py +0 -0
  20. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_routing.py +0 -0
  21. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_sql.py +0 -0
  22. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/_static.py +0 -0
  23. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/asyncio/__init__.py +0 -0
  24. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/asyncio/_db.py +0 -0
  25. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/asyncio/_registry.py +0 -0
  26. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/asyncio/orm.py +0 -0
  27. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/asyncio/sql.py +0 -0
  28. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/exceptions.py +0 -0
  29. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/orm.py +0 -0
  30. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/py.typed +0 -0
  31. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/pytest_plugin.py +0 -0
  32. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/sql.py +0 -0
  33. {sqlakit-0.22.2 → sqlakit-0.22.3}/sqlakit/testing.py +0 -0
  34. {sqlakit-0.22.2 → 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.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.22.2"
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.2"
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=("sqruff", "pycharm"))
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
- export = _export_pycharm if arguments.tool == "pycharm" else _export_sqruff
78
- return export(
79
- Path(arguments.project), dialect=arguments.dialect, check=arguments.check
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 _export_sqruff(directory: Path, *, dialect: str | None, check: bool) -> int:
169
- """Write the `sqruff` settings that read the templates into `pyproject.toml`."""
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 sqruff`")
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