sqlakit 0.21.0__tar.gz → 0.22.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.
Files changed (32) hide show
  1. {sqlakit-0.21.0 → sqlakit-0.22.0}/PKG-INFO +1 -1
  2. {sqlakit-0.21.0 → sqlakit-0.22.0}/pyproject.toml +1 -1
  3. {sqlakit-0.21.0 → sqlakit-0.22.0}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_project.py +28 -8
  5. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_sql.py +0 -17
  6. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_sqruff.py +69 -6
  7. {sqlakit-0.21.0 → sqlakit-0.22.0}/LICENSE +0 -0
  8. {sqlakit-0.21.0 → sqlakit-0.22.0}/README.md +0 -0
  9. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/__init__.py +0 -0
  10. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_base.py +0 -0
  11. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_cli.py +0 -0
  12. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_db.py +0 -0
  13. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_discovery.py +0 -0
  14. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_model.py +0 -0
  15. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_pycharm.py +0 -0
  16. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_query.py +0 -0
  17. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_recording.py +0 -0
  18. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_registry.py +0 -0
  19. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_routing.py +0 -0
  20. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/_static.py +0 -0
  21. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/asyncio/__init__.py +0 -0
  22. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/asyncio/_db.py +0 -0
  23. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/asyncio/_registry.py +0 -0
  24. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/asyncio/orm.py +0 -0
  25. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/asyncio/sql.py +0 -0
  26. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/exceptions.py +0 -0
  27. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/orm.py +0 -0
  28. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/py.typed +0 -0
  29. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/pytest_plugin.py +0 -0
  30. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/sql.py +0 -0
  31. {sqlakit-0.21.0 → sqlakit-0.22.0}/sqlakit/testing.py +0 -0
  32. {sqlakit-0.21.0 → sqlakit-0.22.0}/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.0
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.0"
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.0"
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
 
@@ -212,7 +212,23 @@ class Project:
212
212
  if found.group("macro")
213
213
  ]
214
214
 
215
- def placeholder_values(self) -> dict[str, str]:
215
+ def parameter_names(self) -> set[str]:
216
+ """Return the name of every `:parameter` the templates and macros read."""
217
+ names: set[str] = set()
218
+ for path in self._linted_files():
219
+ text = path.read_text(encoding="utf-8", errors="replace")
220
+ names.update(
221
+ found.group("param")
222
+ for found in _PARAMETER_IN_TEXT.finditer(text)
223
+ if found.group("param")
224
+ )
225
+ return names
226
+
227
+ def _linted_files(self) -> set[Path]:
228
+ paths = [self.path_of(name) for name in self.templates.names()]
229
+ return {*(one for one in paths if one is not None), *self.macro_files()}
230
+
231
+ def placeholder_values(self, reserved: Iterable[str] = ()) -> dict[str, str]:
216
232
  """Return a value for each parameter a linter cannot read by its name.
217
233
 
218
234
  With no value, the `placeholder` templater writes a parameter's name in
@@ -221,12 +237,14 @@ class Project:
221
237
  writes to, a linter reads only `@stage/path`,
222
238
  and inside `SAMPLE (...)` only a number. A name SQL reserves reads as
223
239
  neither, `LIMIT :limit` reading `LIMIT limit`, so it gets `1`, or its
224
- name with `_` after it when a path follows, `:order.id`.
240
+ name with `_` after it when a path follows, `:order.id`. ``reserved``
241
+ adds the words a linter's dialect keeps beside those.
225
242
  """
226
243
  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)
244
+ words = RESERVED | {word.lower() for word in reserved}
245
+ for path in self._linted_files():
246
+ text = path.read_text(encoding="utf-8", errors="replace")
247
+ placeholders_of(text, values, words)
230
248
  return dict(sorted(values.items()))
231
249
 
232
250
  def macro_files(self) -> list[Path]:
@@ -258,7 +276,9 @@ class Project:
258
276
  return found
259
277
 
260
278
 
261
- def placeholders_of(text: str, values: dict[str, str]) -> dict[str, str]:
279
+ def placeholders_of(
280
+ text: str, values: dict[str, str], reserved: Collection[str] = RESERVED
281
+ ) -> dict[str, str]:
262
282
  """Add to ``values`` what a linter needs for each parameter of the text.
263
283
 
264
284
  See `Project.placeholder_values`, which reads every template with it.
@@ -276,7 +296,7 @@ def placeholders_of(text: str, values: dict[str, str]) -> dict[str, str]:
276
296
  values[param] = "@stage/path"
277
297
  elif SAMPLE_AFTER.search(before):
278
298
  values[param] = "10"
279
- elif param.lower() in RESERVED:
299
+ elif param.lower() in reserved:
280
300
  # `COPY INTO :table` needs a name, and other positions a value.
281
301
  named = dotted or inline_position(before)
282
302
  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,9 +64,8 @@ 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 []),
@@ -69,7 +73,8 @@ def settings(project: Project, pyproject: str, dialect: str | None) -> str:
69
73
  f'exclude_rules = "{",".join(LINT_EXCLUDED)}"',
70
74
  ]
71
75
  text = text.rstrip("\n") + "\n\n" + "\n".join(core) + "\n"
72
- merged = dict(sorted({**written, **project.placeholder_values()}.items()))
76
+ values = project.placeholder_values(_kept(project, chosen))
77
+ merged = dict(sorted({**written, **values}.items()))
73
78
  table = "\n".join(
74
79
  [
75
80
  "[tool.sqruff.templater.placeholder]",
@@ -97,6 +102,64 @@ def settings(project: Project, pyproject: str, dialect: str | None) -> str:
97
102
  return written_out
98
103
 
99
104
 
105
+ def _dialect(sqruff: dict, given: str | None, project: Project) -> str | None:
106
+ """Return the dialect `sqruff` reads the templates in, by its name."""
107
+ if "core" in sqruff:
108
+ # sqruff reads a table without a dialect as ANSI, and so does this.
109
+ written = sqruff["core"].get("dialect")
110
+ return written if isinstance(written, str) else None
111
+ chosen = given or project.dialect
112
+ return _DIALECTS.get(chosen or "", chosen)
113
+
114
+
115
+ def _kept(project: Project, dialect: str | None) -> set[str]:
116
+ """Return the parameter names `sqruff` cannot read as a column in the dialect.
117
+
118
+ Each name is linted as `SELECT <name>;`, one to a line, in one run. Without
119
+ `sqruff` installed, the dialects of SQLAlchemy decide alone.
120
+ """
121
+ binary = shutil.which("sqruff")
122
+ names = sorted(project.parameter_names())
123
+ if binary is None or not names:
124
+ return set()
125
+ # JSON, as the report of its own is another shape on GitHub Actions.
126
+ command = [binary, "lint", "--parsing-errors", "--format", "json", "-"]
127
+ if dialect:
128
+ command[2:2] = ["--dialect", dialect]
129
+ source = "".join(f"SELECT {name};\n" for name in names)
130
+ # Away from the project, so no setting of its own changes the reading.
131
+ with tempfile.TemporaryDirectory() as empty:
132
+ found = subprocess.run( # noqa: S603 - sqruff, found on the PATH
133
+ command,
134
+ input=source,
135
+ capture_output=True,
136
+ text=True,
137
+ cwd=empty,
138
+ check=False,
139
+ )
140
+ lines = {
141
+ problem["range"]["start"]["line"]
142
+ for problems in _report(found.stdout, found.stderr).values()
143
+ for problem in problems
144
+ # The name starts after `SELECT `, at the eighth character.
145
+ if problem.get("message") == "Unparsable section"
146
+ and problem["range"]["start"]["character"] == len("SELECT ") + 1
147
+ }
148
+ return {name for number, name in enumerate(names, 1) if number in lines}
149
+
150
+
151
+ def _report(*outputs: str) -> dict[str, list[dict[str, Any]]]:
152
+ """Return the JSON report, from whichever stream `sqruff` wrote it to."""
153
+ for output in outputs:
154
+ try:
155
+ report = json.loads(output)
156
+ except json.JSONDecodeError:
157
+ continue
158
+ if isinstance(report, dict):
159
+ return report
160
+ return {}
161
+
162
+
100
163
  def _toml_value(value: object) -> str:
101
164
  """Return a value as TOML writes it: a string quoted, a flag in lower case."""
102
165
  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