sqlakit 0.21.0__tar.gz → 0.22.1__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 (32) hide show
  1. {sqlakit-0.21.0 → sqlakit-0.22.1}/PKG-INFO +1 -1
  2. {sqlakit-0.21.0 → sqlakit-0.22.1}/pyproject.toml +1 -1
  3. {sqlakit-0.21.0 → sqlakit-0.22.1}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_project.py +33 -11
  5. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_sql.py +0 -17
  6. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_sqruff.py +70 -8
  7. {sqlakit-0.21.0 → sqlakit-0.22.1}/LICENSE +0 -0
  8. {sqlakit-0.21.0 → sqlakit-0.22.1}/README.md +0 -0
  9. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/__init__.py +0 -0
  10. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_base.py +0 -0
  11. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_cli.py +0 -0
  12. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_db.py +0 -0
  13. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_discovery.py +0 -0
  14. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_model.py +0 -0
  15. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_pycharm.py +0 -0
  16. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_query.py +0 -0
  17. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_recording.py +0 -0
  18. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_registry.py +0 -0
  19. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_routing.py +0 -0
  20. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_static.py +0 -0
  21. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/asyncio/__init__.py +0 -0
  22. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/asyncio/_db.py +0 -0
  23. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/asyncio/_registry.py +0 -0
  24. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/asyncio/orm.py +0 -0
  25. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/asyncio/sql.py +0 -0
  26. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/exceptions.py +0 -0
  27. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/orm.py +0 -0
  28. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/py.typed +0 -0
  29. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/pytest_plugin.py +0 -0
  30. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/sql.py +0 -0
  31. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/testing.py +0 -0
  32. {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.21.0
3
+ Version: 0.22.1
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.21.0"
3
+ version = "0.22.1"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.21.0"
3
+ version = "0.22.1"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -48,7 +48,7 @@ from .exceptions import (
48
48
  )
49
49
 
50
50
  if TYPE_CHECKING:
51
- from collections.abc import Iterator
51
+ from collections.abc import Collection, Iterable, Iterator
52
52
 
53
53
  __all__ = ["Problem", "Project", "load_project"]
54
54
 
@@ -70,11 +70,13 @@ RESERVED = RESERVED_WORDS | POSTGRESQL_RESERVED_WORDS
70
70
  """Words a parameter cannot stand in for unquoted, on the databases linted most."""
71
71
  """Names SQL keeps for itself, which a linter cannot read a parameter as."""
72
72
 
73
- LINT_EXCLUDED = ("RF01", "AL05", "ST03")
73
+ LINT_EXCLUDED = ("RF01", "RF02", "RF03", "AL05", "ST03")
74
74
  """Rules a `tpl.` call trips without anything being wrong with the template.
75
75
 
76
- `RF01` reads `tpl.if_set` as a column of a table named `tpl`, and `AL05` and
77
- `ST03` miss an alias or a CTE used only inside a macro's argument.
76
+ `RF01` reads `tpl.if_set` as a column of a table named `tpl`. `RF02` and `RF03`,
77
+ outside `sqruff`'s default rules, read a table a macro takes, `tpl.paid(o)`, as
78
+ a column. `AL05` and `ST03` miss an alias or a CTE used only inside a macro's
79
+ argument.
78
80
  """
79
81
 
80
82
 
@@ -212,7 +214,23 @@ class Project:
212
214
  if found.group("macro")
213
215
  ]
214
216
 
215
- def placeholder_values(self) -> dict[str, str]:
217
+ def parameter_names(self) -> set[str]:
218
+ """Return the name of every `:parameter` the templates and macros read."""
219
+ names: set[str] = set()
220
+ for path in self._linted_files():
221
+ text = path.read_text(encoding="utf-8", errors="replace")
222
+ names.update(
223
+ found.group("param")
224
+ for found in _PARAMETER_IN_TEXT.finditer(text)
225
+ if found.group("param")
226
+ )
227
+ return names
228
+
229
+ def _linted_files(self) -> set[Path]:
230
+ paths = [self.path_of(name) for name in self.templates.names()]
231
+ return {*(one for one in paths if one is not None), *self.macro_files()}
232
+
233
+ def placeholder_values(self, reserved: Iterable[str] = ()) -> dict[str, str]:
216
234
  """Return a value for each parameter a linter cannot read by its name.
217
235
 
218
236
  With no value, the `placeholder` templater writes a parameter's name in
@@ -221,12 +239,14 @@ class Project:
221
239
  writes to, a linter reads only `@stage/path`,
222
240
  and inside `SAMPLE (...)` only a number. A name SQL reserves reads as
223
241
  neither, `LIMIT :limit` reading `LIMIT limit`, so it gets `1`, or its
224
- name with `_` after it when a path follows, `:order.id`.
242
+ name with `_` after it when a path follows, `:order.id`. ``reserved``
243
+ adds the words a linter's dialect keeps beside those.
225
244
  """
226
245
  values: dict[str, str] = {}
227
- paths = [self.path_of(name) for name in self.templates.names()]
228
- for path in {*(one for one in paths if one is not None), *self.macro_files()}:
229
- placeholders_of(path.read_text(encoding="utf-8", errors="replace"), values)
246
+ words = RESERVED | {word.lower() for word in reserved}
247
+ for path in self._linted_files():
248
+ text = path.read_text(encoding="utf-8", errors="replace")
249
+ placeholders_of(text, values, words)
230
250
  return dict(sorted(values.items()))
231
251
 
232
252
  def macro_files(self) -> list[Path]:
@@ -258,7 +278,9 @@ class Project:
258
278
  return found
259
279
 
260
280
 
261
- def placeholders_of(text: str, values: dict[str, str]) -> dict[str, str]:
281
+ def placeholders_of(
282
+ text: str, values: dict[str, str], reserved: Collection[str] = RESERVED
283
+ ) -> dict[str, str]:
262
284
  """Add to ``values`` what a linter needs for each parameter of the text.
263
285
 
264
286
  See `Project.placeholder_values`, which reads every template with it.
@@ -276,7 +298,7 @@ def placeholders_of(text: str, values: dict[str, str]) -> dict[str, str]:
276
298
  values[param] = "@stage/path"
277
299
  elif SAMPLE_AFTER.search(before):
278
300
  values[param] = "10"
279
- elif param.lower() in RESERVED:
301
+ elif param.lower() in reserved:
280
302
  # `COPY INTO :table` needs a name, and other positions a value.
281
303
  named = dotted or inline_position(before)
282
304
  values.setdefault(param, f"{param}_" if named else "1")
@@ -1954,22 +1954,6 @@ def unless_set(value: Param, expr: Sql, otherwise: Sql = Sql("TRUE")) -> str: #
1954
1954
  return _grouped(str(otherwise if _is_set(value.value) else expr))
1955
1955
 
1956
1956
 
1957
- @sql_macro(optional=True, lazy=True)
1958
- def when(value: Param, sql: Sql, *more: Sql) -> str:
1959
- """Write `sql` when the parameter holds a value, and nothing when it does not.
1960
-
1961
- For a clause that is there or not, rather than a condition that is true or
1962
- not: `tpl.when(:team, JOIN teams AS t ON t.id = u.team_id)`. `if_set`
1963
- writes `TRUE` in its place, which only a condition can stand.
1964
-
1965
- Everything after the parameter is the clause, commas and all:
1966
- `tpl.when(:limit, ORDER BY score DESC, created_at DESC LIMIT :limit)`.
1967
- """
1968
- if not _is_set(value.value):
1969
- return ""
1970
- return ", ".join(str(part) for part in (sql, *more))
1971
-
1972
-
1973
1957
  @sql_macro(optional=True)
1974
1958
  def array(ctx: Context, values: Param, type_name: Sql = Sql("")) -> str: # noqa: B008
1975
1959
  """Write a list as an array, one parameter per value.
@@ -2580,7 +2564,6 @@ BUILTIN_MACROS: Mapping[str, Macro] = {
2580
2564
  for macro in (
2581
2565
  if_set,
2582
2566
  unless_set,
2583
- when,
2584
2567
  order_by,
2585
2568
  icontains,
2586
2569
  icollate,
@@ -2,15 +2,20 @@
2
2
 
3
3
  The `placeholder` templater writes each `:name` as `name`. Where that reads as
4
4
  something else, `settings` gives the parameter a value; see
5
- `Project.placeholder_values`.
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.
6
8
  """
7
9
 
8
10
  from __future__ import annotations
9
11
 
10
12
  import json
11
13
  import re
14
+ import shutil
15
+ import subprocess
16
+ import tempfile
12
17
  import tomllib
13
- from typing import TYPE_CHECKING
18
+ from typing import TYPE_CHECKING, Any
14
19
 
15
20
  from ._project import LINT_EXCLUDED
16
21
  from .exceptions import ProjectConfigError
@@ -33,7 +38,7 @@ def stale(project: Project, pyproject: str) -> list[str]:
33
38
  """Return the tables of `pyproject.toml` that no longer read the templates."""
34
39
  sqruff = tomllib.loads(pyproject).get("tool", {}).get("sqruff", {})
35
40
  written = sqruff.get("templater", {}).get("placeholder", {})
36
- values = project.placeholder_values()
41
+ values = project.placeholder_values(_kept(project, _dialect(sqruff, None, project)))
37
42
  checked = (
38
43
  ("[tool.sqruff.core]", "core" in sqruff),
39
44
  (
@@ -59,22 +64,21 @@ def settings(project: Project, pyproject: str, dialect: str | None) -> str:
59
64
  written = dict(sqruff.get("templater", {}).get("placeholder", {}))
60
65
  written.pop("param_style", None)
61
66
  text = pyproject
67
+ chosen = _dialect(sqruff, dialect, project)
62
68
  if "core" not in sqruff:
63
- chosen = dialect or project.dialect
64
- chosen = _DIALECTS.get(chosen or "", chosen)
65
69
  core = [
66
70
  "[tool.sqruff.core]",
67
71
  *([f'dialect = "{chosen}"'] if chosen else []),
68
72
  'templater = "placeholder"',
73
+ 'rules = "all"',
69
74
  f'exclude_rules = "{",".join(LINT_EXCLUDED)}"',
70
75
  ]
71
76
  text = text.rstrip("\n") + "\n\n" + "\n".join(core) + "\n"
72
- merged = dict(sorted({**written, **project.placeholder_values()}.items()))
77
+ values = project.placeholder_values(_kept(project, chosen))
78
+ merged = dict(sorted({**written, **values}.items()))
73
79
  table = "\n".join(
74
80
  [
75
81
  "[tool.sqruff.templater.placeholder]",
76
- "# `sqlakit export sqruff` writes what the templates need, and keeps",
77
- "# what you add.",
78
82
  'param_style = "colon"',
79
83
  *(f"{name} = {_toml_value(value)}" for name, value in merged.items()),
80
84
  ]
@@ -97,6 +101,64 @@ def settings(project: Project, pyproject: str, dialect: str | None) -> str:
97
101
  return written_out
98
102
 
99
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
+
100
162
  def _toml_value(value: object) -> str:
101
163
  """Return a value as TOML writes it: a string quoted, a flag in lower case."""
102
164
  if isinstance(value, bool):
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