sqlty 0.1.0__cp315-cp315-win_amd64.whl
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.
- sqlty/__init__.py +99 -0
- sqlty/__main__.py +5 -0
- sqlty/_breaking.py +191 -0
- sqlty/_check.py +880 -0
- sqlty/_cli.py +1219 -0
- sqlty/_config.py +666 -0
- sqlty/_core.cp315-win_amd64.pyd +0 -0
- sqlty/_core.pyi +108 -0
- sqlty/_decode.py +62 -0
- sqlty/_drivers.py +274 -0
- sqlty/_gitignore.py +58 -0
- sqlty/_helpers.py +613 -0
- sqlty/_json_shape.py +299 -0
- sqlty/_json_types.py +225 -0
- sqlty/_lint.py +350 -0
- sqlty/_params.py +177 -0
- sqlty/_psycopg.py +269 -0
- sqlty/_source.py +654 -0
- sqlty/_type_extractor/__init__.py +51 -0
- sqlty/_type_extractor/ast_fields.py +823 -0
- sqlty/_type_extractor/compat.py +252 -0
- sqlty/_type_extractor/extract.py +179 -0
- sqlty/_type_extractor/generic.py +95 -0
- sqlty/_type_extractor/info.py +161 -0
- sqlty/_type_extractor/sandbox.py +385 -0
- sqlty/_type_extractor/subtype.py +156 -0
- sqlty/_widening.py +89 -0
- sqlty/core.py +566 -0
- sqlty/drivers/psycopg.py +104 -0
- sqlty/magic.py +126 -0
- sqlty/py.typed +0 -0
- sqlty/rules/ambiguous-column.md +48 -0
- sqlty/rules/column-count-mismatch.md +30 -0
- sqlty/rules/column-name-mismatch.md +34 -0
- sqlty/rules/column-type-mismatch.md +75 -0
- sqlty/rules/fstring-skipped.md +23 -0
- sqlty/rules/inline-sql-comment.md +27 -0
- sqlty/rules/insert-missing-column.md +58 -0
- sqlty/rules/json-shape-mismatch.md +96 -0
- sqlty/rules/literal-mismatch.md +83 -0
- sqlty/rules/mysql-sql-marker.md +32 -0
- sqlty/rules/nullable-mismatch.md +76 -0
- sqlty/rules/param-count-mismatch.md +66 -0
- sqlty/rules/param-type-mismatch.md +73 -0
- sqlty/rules/redundant-literal.md +68 -0
- sqlty/rules/redundant-optional.md +100 -0
- sqlty/rules/select-star-no-schema.md +24 -0
- sqlty/rules/select-star.md +77 -0
- sqlty/rules/stale-rewrite.md +33 -0
- sqlty/rules/stale-type.md +50 -0
- sqlty/rules/tstring-skipped.md +68 -0
- sqlty/rules/typeddict-inheritance.md +35 -0
- sqlty/rules/typeddict-key-mismatch.md +38 -0
- sqlty/rules/unchecked-query.md +34 -0
- sqlty/rules/ungrouped-column.md +67 -0
- sqlty/rules/unknown-column.md +68 -0
- sqlty/rules/unknown-function.md +49 -0
- sqlty/rules/unknown-table.md +47 -0
- sqlty/rules/unknown-type.md +74 -0
- sqlty/rules/unparsed-query.md +46 -0
- sqlty/rules/unsupported-syntax.md +69 -0
- sqlty/rules/untyped-query.md +39 -0
- sqlty/rules/unused-ignore.md +36 -0
- sqlty/skill/SKILL.md +110 -0
- sqlty-0.1.0.dist-info/METADATA +210 -0
- sqlty-0.1.0.dist-info/RECORD +71 -0
- sqlty-0.1.0.dist-info/WHEEL +4 -0
- sqlty-0.1.0.dist-info/entry_points.txt +2 -0
- sqlty-0.1.0.dist-info/licenses/LICENSE +21 -0
- sqlty-0.1.0.dist-info/sboms/sqlty-core.cyclonedx.json +1891 -0
- sqlty_magic.pth +1 -0
sqlty/_config.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
"""Configuration loading from pyproject.toml."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import copy
|
|
5
|
+
import difflib
|
|
6
|
+
import glob
|
|
7
|
+
import logging
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, NamedTuple, TypedDict, cast
|
|
11
|
+
|
|
12
|
+
SchemaSpec = str | list[str]
|
|
13
|
+
|
|
14
|
+
TOOL_KEYS = (
|
|
15
|
+
"dialect",
|
|
16
|
+
"schema",
|
|
17
|
+
"rules",
|
|
18
|
+
"overrides",
|
|
19
|
+
"import-style",
|
|
20
|
+
"untyped",
|
|
21
|
+
"json-types",
|
|
22
|
+
"type-modules",
|
|
23
|
+
"functions",
|
|
24
|
+
"types",
|
|
25
|
+
"format-command",
|
|
26
|
+
)
|
|
27
|
+
"""Keys of ``[tool.sqlty]`` that are not file discovery; see also ``DISCOVERY_KEYS``."""
|
|
28
|
+
|
|
29
|
+
DISCOVERY_KEYS = ("exclude", "extend-exclude", "respect-gitignore")
|
|
30
|
+
"""``[tool.sqlty]`` keys that pick the files to process."""
|
|
31
|
+
|
|
32
|
+
FUNCTION_KEYS = ("import-path", "query-param", "return-type-param")
|
|
33
|
+
|
|
34
|
+
OVERRIDE_KEYS = ("include", "exclude", "rules")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class KeyDoc(NamedTuple):
|
|
38
|
+
"""Reference documentation of one configuration key.
|
|
39
|
+
|
|
40
|
+
``docs/reference/configuration.md`` renders its tables from these entries
|
|
41
|
+
(``make generate-docs``); ``tests/test_docs.py`` fails when the page drifts.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
type: str
|
|
45
|
+
default: str
|
|
46
|
+
used_by: str
|
|
47
|
+
summary: str
|
|
48
|
+
required: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
KEY_DOCS: dict[str, KeyDoc] = {
|
|
52
|
+
"dialect": KeyDoc(
|
|
53
|
+
'`"postgres"`, `"sqlite"`, `"mysql"`',
|
|
54
|
+
'`"postgres"`',
|
|
55
|
+
"`rewrite`, `check`",
|
|
56
|
+
"SQL dialect of queries and schema",
|
|
57
|
+
),
|
|
58
|
+
"schema": KeyDoc(
|
|
59
|
+
"string or list of strings",
|
|
60
|
+
"none",
|
|
61
|
+
"`rewrite`, `check`",
|
|
62
|
+
"Schema `.sql` files, directories or globs, relative to `pyproject.toml`",
|
|
63
|
+
),
|
|
64
|
+
"rules": KeyDoc(
|
|
65
|
+
"table of rule = severity",
|
|
66
|
+
"per-rule defaults",
|
|
67
|
+
"`check`",
|
|
68
|
+
'Severity per lint rule: `"error"`, `"warn"` or `"ignore"`',
|
|
69
|
+
),
|
|
70
|
+
"overrides": KeyDoc(
|
|
71
|
+
"array of tables",
|
|
72
|
+
"`[]`",
|
|
73
|
+
"`check`",
|
|
74
|
+
"Rule severities for the files `include` matches and `exclude` does not, on top of `rules`",
|
|
75
|
+
),
|
|
76
|
+
"import-style": KeyDoc(
|
|
77
|
+
"string",
|
|
78
|
+
'`"from sqlty import SQL"`',
|
|
79
|
+
"`rewrite`, `strip`, `check`, magic codec",
|
|
80
|
+
"How rewritten code imports and spells the wrapper",
|
|
81
|
+
),
|
|
82
|
+
"untyped": KeyDoc(
|
|
83
|
+
'`"skip"`, `"sentinel"`',
|
|
84
|
+
'`"skip"`',
|
|
85
|
+
"`rewrite`",
|
|
86
|
+
"Whether queries of unknown row type are wrapped as `SQL[UNTYPED]`",
|
|
87
|
+
),
|
|
88
|
+
"json-types": KeyDoc(
|
|
89
|
+
'`"typing.Any"`, `"typing.TypedDict"`',
|
|
90
|
+
'`"typing.Any"`',
|
|
91
|
+
"`rewrite`, `check --fix`",
|
|
92
|
+
"How a JSON column of known shape is typed: `typing.Any`, or a generated `TypedDict`",
|
|
93
|
+
),
|
|
94
|
+
"type-modules": KeyDoc(
|
|
95
|
+
"list of strings",
|
|
96
|
+
"`[]`",
|
|
97
|
+
"`check`",
|
|
98
|
+
"Modules `check` may import to resolve row type fields",
|
|
99
|
+
),
|
|
100
|
+
"functions": KeyDoc(
|
|
101
|
+
"array of tables",
|
|
102
|
+
"`[]`",
|
|
103
|
+
"`check`",
|
|
104
|
+
"Helper functions whose string argument is a query",
|
|
105
|
+
),
|
|
106
|
+
"types": KeyDoc(
|
|
107
|
+
"table of SQL type or `table.column` = Python type",
|
|
108
|
+
"`{}`",
|
|
109
|
+
"`rewrite`, `check`",
|
|
110
|
+
"Python types a converter above the driver gives a SQL type or a column",
|
|
111
|
+
),
|
|
112
|
+
"format-command": KeyDoc(
|
|
113
|
+
"list of strings",
|
|
114
|
+
"none",
|
|
115
|
+
"`rewrite`, `check --fix`",
|
|
116
|
+
"Formatter run on each file `rewrite` or `check --fix` changes, with the path appended",
|
|
117
|
+
),
|
|
118
|
+
"exclude": KeyDoc(
|
|
119
|
+
"list of globs",
|
|
120
|
+
"see [Default excludes](#exclude-and-extend-exclude)",
|
|
121
|
+
"`rewrite`, `strip`, `check`",
|
|
122
|
+
"Globs skipped while walking a directory; replaces the default list",
|
|
123
|
+
),
|
|
124
|
+
"extend-exclude": KeyDoc(
|
|
125
|
+
"list of globs",
|
|
126
|
+
"`[]`",
|
|
127
|
+
"`rewrite`, `strip`, `check`",
|
|
128
|
+
"Globs skipped in addition to `exclude` or the defaults",
|
|
129
|
+
),
|
|
130
|
+
"respect-gitignore": KeyDoc(
|
|
131
|
+
"boolean",
|
|
132
|
+
"`true`",
|
|
133
|
+
"`rewrite`, `strip`, `check`",
|
|
134
|
+
"Skip the files `.gitignore` ignores while walking a directory",
|
|
135
|
+
),
|
|
136
|
+
}
|
|
137
|
+
"""Reference docs per key of ``[tool.sqlty]``, in documentation order."""
|
|
138
|
+
|
|
139
|
+
FUNCTION_KEY_DOCS: dict[str, KeyDoc] = {
|
|
140
|
+
"import-path": KeyDoc(
|
|
141
|
+
"string",
|
|
142
|
+
"none",
|
|
143
|
+
"`check`",
|
|
144
|
+
'Module path and function name, for example `"app.db.sql"`; a method is `"pkg.db.Db.fetch_all"`. '
|
|
145
|
+
'Use `"__main__.sql"` or the file name for a script run directly',
|
|
146
|
+
required=True,
|
|
147
|
+
),
|
|
148
|
+
"query-param": KeyDoc(
|
|
149
|
+
"string",
|
|
150
|
+
"none",
|
|
151
|
+
"`check`",
|
|
152
|
+
"Name of the parameter that holds the query",
|
|
153
|
+
required=True,
|
|
154
|
+
),
|
|
155
|
+
"return-type-param": KeyDoc(
|
|
156
|
+
"string",
|
|
157
|
+
"none",
|
|
158
|
+
"`check`",
|
|
159
|
+
"Parameter whose argument, such as `return_tp=int`, is the row type of that call "
|
|
160
|
+
"instead of an inferred one; `check` compares it with the query columns",
|
|
161
|
+
),
|
|
162
|
+
}
|
|
163
|
+
"""Reference docs per key of a ``[[tool.sqlty.functions]]`` entry."""
|
|
164
|
+
|
|
165
|
+
DEFAULT_EXCLUDE = (
|
|
166
|
+
".bzr",
|
|
167
|
+
".direnv",
|
|
168
|
+
".eggs",
|
|
169
|
+
".git",
|
|
170
|
+
".git-rewrite",
|
|
171
|
+
".hg",
|
|
172
|
+
".ipynb_checkpoints",
|
|
173
|
+
".mypy_cache",
|
|
174
|
+
".nox",
|
|
175
|
+
".pants.d",
|
|
176
|
+
".pyenv",
|
|
177
|
+
".pytest_cache",
|
|
178
|
+
".pytype",
|
|
179
|
+
".ruff_cache",
|
|
180
|
+
".svn",
|
|
181
|
+
".tox",
|
|
182
|
+
".venv",
|
|
183
|
+
".vscode",
|
|
184
|
+
"__pypackages__",
|
|
185
|
+
"_build",
|
|
186
|
+
"buck-out",
|
|
187
|
+
"dist",
|
|
188
|
+
"node_modules",
|
|
189
|
+
"site-packages",
|
|
190
|
+
"venv",
|
|
191
|
+
)
|
|
192
|
+
"""Directories skipped when walking a directory, unless ``exclude`` replaces them."""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ConfigError(Exception):
|
|
196
|
+
"""A user error in configuration or command-line paths; the CLI prints it and exits 2."""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _suggest(name: str, choices: tuple[str, ...] | list[str]) -> str:
|
|
200
|
+
close = difflib.get_close_matches(name, choices, n=1)
|
|
201
|
+
return f" (did you mean {close[0]!r}?)" if close else ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def dialect_problem(dialect: object) -> str | None:
|
|
205
|
+
"""Why ``dialect`` is not a dialect name, or ``None`` when it is one (in any case).
|
|
206
|
+
|
|
207
|
+
Example:
|
|
208
|
+
>>> dialect_problem("PostgreSQL") is None
|
|
209
|
+
True
|
|
210
|
+
>>> dialect_problem("postgress") # doctest: +ELLIPSIS
|
|
211
|
+
"unknown dialect 'postgress' (did you mean 'postgres'?); expected one of: postgres, ..."
|
|
212
|
+
"""
|
|
213
|
+
from sqlty._core import dialect_names
|
|
214
|
+
|
|
215
|
+
names = dialect_names()
|
|
216
|
+
if isinstance(dialect, str) and dialect.lower() in (*names, "postgresql", "tsql"):
|
|
217
|
+
return None
|
|
218
|
+
return (
|
|
219
|
+
f"unknown dialect {dialect!r}{_suggest(str(dialect), names)}; "
|
|
220
|
+
f"expected one of: {', '.join(names)}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def validate_tool_table(table: dict[str, Any], source: str) -> None:
|
|
225
|
+
"""Raise ``ConfigError`` for an unknown key, rule or severity in a ``[tool.sqlty]`` table.
|
|
226
|
+
|
|
227
|
+
``source`` names the file in the message. Shared by the CLI and the mdtest runner.
|
|
228
|
+
|
|
229
|
+
Example:
|
|
230
|
+
>>> validate_tool_table({"dialect": "mysql"}, "pyproject.toml")
|
|
231
|
+
>>> validate_tool_table({"import_style": "x"}, "pyproject.toml") # doctest: +ELLIPSIS
|
|
232
|
+
Traceback (most recent call last):
|
|
233
|
+
sqlty._config.ConfigError: pyproject.toml: unknown key 'import_style' in [tool.sqlty] (did you mean 'import-style'?); ...
|
|
234
|
+
"""
|
|
235
|
+
from sqlty._lint import RULES, Severity
|
|
236
|
+
|
|
237
|
+
keys = (*TOOL_KEYS, *DISCOVERY_KEYS)
|
|
238
|
+
for key in table:
|
|
239
|
+
if key not in keys:
|
|
240
|
+
raise ConfigError(
|
|
241
|
+
f"{source}: unknown key {key!r} in [tool.sqlty]{_suggest(key, keys)}; "
|
|
242
|
+
f"expected one of: {', '.join(keys)}"
|
|
243
|
+
)
|
|
244
|
+
severities = [s.value for s in Severity]
|
|
245
|
+
|
|
246
|
+
def check_rules(rules: object, key: str, where: str) -> None:
|
|
247
|
+
if not isinstance(rules, dict):
|
|
248
|
+
raise ConfigError(f"{source}: {key} must be a table of rule = severity")
|
|
249
|
+
for rule, severity in rules.items():
|
|
250
|
+
if rule not in RULES:
|
|
251
|
+
raise ConfigError(
|
|
252
|
+
f"{source}: unknown rule {rule!r} in {where}"
|
|
253
|
+
f"{_suggest(rule, list(RULES))}; run `sqlty rule` for the list"
|
|
254
|
+
)
|
|
255
|
+
if severity not in severities:
|
|
256
|
+
raise ConfigError(
|
|
257
|
+
f"{source}: invalid severity {severity!r} for rule {rule!r} in "
|
|
258
|
+
f"{where}; expected one of: {', '.join(severities)}"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if "dialect" in table and (problem := dialect_problem(table["dialect"])):
|
|
262
|
+
raise ConfigError(f"{source}: [tool.sqlty] {problem}")
|
|
263
|
+
check_rules(table.get("rules", {}), "[tool.sqlty] rules", "[tool.sqlty.rules]")
|
|
264
|
+
overrides = table.get("overrides", [])
|
|
265
|
+
if not isinstance(overrides, list) or not all(isinstance(o, dict) for o in overrides):
|
|
266
|
+
raise ConfigError(f"{source}: [tool.sqlty] overrides must be an array of tables")
|
|
267
|
+
for override in overrides:
|
|
268
|
+
for key in override:
|
|
269
|
+
if key not in OVERRIDE_KEYS:
|
|
270
|
+
raise ConfigError(
|
|
271
|
+
f"{source}: unknown key {key!r} in [[tool.sqlty.overrides]]"
|
|
272
|
+
f"{_suggest(key, OVERRIDE_KEYS)}; expected one of: {', '.join(OVERRIDE_KEYS)}"
|
|
273
|
+
)
|
|
274
|
+
for key in ("include", "exclude"):
|
|
275
|
+
globs = override.get(key, [])
|
|
276
|
+
if not isinstance(globs, list) or not all(isinstance(g, str) for g in globs):
|
|
277
|
+
raise ConfigError(
|
|
278
|
+
f"{source}: [[tool.sqlty.overrides]] {key} must be a list of glob strings"
|
|
279
|
+
)
|
|
280
|
+
check_rules(
|
|
281
|
+
override.get("rules", {}),
|
|
282
|
+
"[[tool.sqlty.overrides]] rules",
|
|
283
|
+
"[tool.sqlty.overrides.rules]",
|
|
284
|
+
)
|
|
285
|
+
functions = table.get("functions", [])
|
|
286
|
+
if not isinstance(functions, list):
|
|
287
|
+
raise ConfigError(f"{source}: [tool.sqlty] functions must be an array of tables")
|
|
288
|
+
for function in functions:
|
|
289
|
+
if isinstance(function, dict):
|
|
290
|
+
for key in function:
|
|
291
|
+
if key not in FUNCTION_KEYS:
|
|
292
|
+
raise ConfigError(
|
|
293
|
+
f"{source}: unknown key {key!r} in [[tool.sqlty.functions]]"
|
|
294
|
+
f"{_suggest(key, FUNCTION_KEYS)}; expected one of: {', '.join(FUNCTION_KEYS)}"
|
|
295
|
+
)
|
|
296
|
+
types = table.get("types", {})
|
|
297
|
+
if not isinstance(types, dict) or not all(isinstance(v, str) for v in types.values()):
|
|
298
|
+
raise ConfigError(
|
|
299
|
+
f"{source}: [tool.sqlty] types must be a table of SQL type or table.column = "
|
|
300
|
+
'Python type, such as DATE = "datetime.date"'
|
|
301
|
+
)
|
|
302
|
+
for key, python in types.items():
|
|
303
|
+
try:
|
|
304
|
+
ast.parse(python, mode="eval")
|
|
305
|
+
except SyntaxError:
|
|
306
|
+
raise ConfigError(
|
|
307
|
+
f"{source}: [tool.sqlty.types] {key} = {python!r} is not a Python type expression"
|
|
308
|
+
) from None
|
|
309
|
+
command = table.get("format-command", [])
|
|
310
|
+
if not isinstance(command, list) or not all(isinstance(part, str) for part in command):
|
|
311
|
+
raise ConfigError(
|
|
312
|
+
f"{source}: [tool.sqlty] format-command must be a list of strings, "
|
|
313
|
+
'such as ["ruff", "format"]'
|
|
314
|
+
)
|
|
315
|
+
if not isinstance(table.get("respect-gitignore", True), bool):
|
|
316
|
+
raise ConfigError(f"{source}: [tool.sqlty] respect-gitignore must be true or false")
|
|
317
|
+
for key in ("exclude", "extend-exclude"):
|
|
318
|
+
value = table.get(key, [])
|
|
319
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
320
|
+
raise ConfigError(f"{source}: [tool.sqlty] {key} must be a list of glob strings")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class RuleOverride(TypedDict):
|
|
324
|
+
"""A ``[[tool.sqlty.overrides]]`` entry, as ty spells them: rule severities for the files
|
|
325
|
+
``include`` matches (every file when it is empty) and ``exclude`` does not.
|
|
326
|
+
|
|
327
|
+
Globs are relative to the ``pyproject.toml`` directory; a glob without a slash
|
|
328
|
+
matches a file or directory name at any depth, as in ``exclude``.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
include: list[str]
|
|
332
|
+
exclude: list[str]
|
|
333
|
+
rules: dict[str, str]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class FunctionConfigComplete(TypedDict, total=False):
|
|
337
|
+
"""A ``[[tool.sqlty.functions]]`` entry: a helper whose string argument is a query.
|
|
338
|
+
|
|
339
|
+
In TOML the keys are ``import-path`` and ``query-param`` (required) and
|
|
340
|
+
``return-type-param`` (optional); here they are the snake_case fields below.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
import_path: str # Full import path, e.g. "module.function_name" (required)
|
|
344
|
+
query_param: str # Parameter holding the SQL query (required)
|
|
345
|
+
return_type_param: str # Parameter holding the row type (optional)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class SQLTyConfig(TypedDict, total=False):
|
|
349
|
+
"""Configuration for SQLTy from pyproject.toml.
|
|
350
|
+
|
|
351
|
+
Corresponds to the ``[tool.sqlty]`` section:
|
|
352
|
+
|
|
353
|
+
.. code-block:: toml
|
|
354
|
+
|
|
355
|
+
[tool.sqlty]
|
|
356
|
+
dialect = "postgres"
|
|
357
|
+
schema = "schema.sql"
|
|
358
|
+
import-style = "from sqlty import SQL"
|
|
359
|
+
untyped = "sentinel"
|
|
360
|
+
type-modules = ["myapp.types"]
|
|
361
|
+
extend-exclude = ["migrations"]
|
|
362
|
+
|
|
363
|
+
[tool.sqlty.rules]
|
|
364
|
+
select-star = "ignore"
|
|
365
|
+
stale-type = "error"
|
|
366
|
+
|
|
367
|
+
[[tool.sqlty.functions]]
|
|
368
|
+
import-path = "db.query"
|
|
369
|
+
query-param = "sql"
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
dialect: str
|
|
373
|
+
schema: SchemaSpec # Schema file, directory or glob, or a list of them
|
|
374
|
+
rules: dict[str, str] # rule-name -> severity (error/warn/ignore)
|
|
375
|
+
overrides: list[RuleOverride] # rule severities for files matching globs
|
|
376
|
+
import_style: str # e.g., "from sqlty import SQL as Q" or "import sqlty"
|
|
377
|
+
untyped: str # "skip" (default) or "sentinel": SQL[UNTYPED] for uninferable queries
|
|
378
|
+
json_types: (
|
|
379
|
+
str # "typing.Any" (default) or "typing.TypedDict": generated TypedDicts for JSON columns
|
|
380
|
+
)
|
|
381
|
+
type_modules: list[str] # modules whose imports may run to resolve row types
|
|
382
|
+
exclude: list[str] # globs replacing the default excludes, relative to pyproject.toml
|
|
383
|
+
extend_exclude: list[str] # globs excluded in addition to the above
|
|
384
|
+
respect_gitignore: bool # skip what .gitignore ignores while walking
|
|
385
|
+
functions: list[FunctionConfigComplete] # List of functions to track and analyze
|
|
386
|
+
types: dict[str, str] # SQL type or table.column -> Python type a converter gives it
|
|
387
|
+
format_command: list[str] # formatter run on changed files, path appended
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _schema_spec(value: Any) -> SchemaSpec:
|
|
391
|
+
if isinstance(value, list):
|
|
392
|
+
return [str(v) for v in value]
|
|
393
|
+
return str(value)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def expand_schema(spec: SchemaSpec | Path, base: Path) -> list[Path]:
|
|
397
|
+
"""List the schema files named by ``spec``, in order.
|
|
398
|
+
|
|
399
|
+
``spec`` is a path or a list of paths; each entry is a file, a directory
|
|
400
|
+
(every ``*.sql`` under it, recursively) or a glob (``*``, ``?``, ``[``;
|
|
401
|
+
``**`` recurses), relative to ``base``. Directory and glob matches are sorted
|
|
402
|
+
by path; repeated files are listed once. A plain path is kept even if it
|
|
403
|
+
does not exist, so callers report it; a glob that matches nothing is logged.
|
|
404
|
+
"""
|
|
405
|
+
entries = [spec] if isinstance(spec, (str, Path)) else spec
|
|
406
|
+
files: dict[Path, None] = {}
|
|
407
|
+
for entry in entries:
|
|
408
|
+
path = base / entry
|
|
409
|
+
if glob.has_magic(str(entry)):
|
|
410
|
+
matches = [
|
|
411
|
+
base / m for m in sorted(glob.glob(str(entry), root_dir=base, recursive=True))
|
|
412
|
+
]
|
|
413
|
+
found = [m for m in matches if m.is_file()]
|
|
414
|
+
if not found:
|
|
415
|
+
logging.getLogger(__name__).warning("Schema pattern matches no files: %s", entry)
|
|
416
|
+
elif path.is_dir():
|
|
417
|
+
found = sorted(p for p in path.rglob("*.sql") if p.is_file())
|
|
418
|
+
else:
|
|
419
|
+
found = [path]
|
|
420
|
+
files.update(dict.fromkeys(found))
|
|
421
|
+
return list(files)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def load_config(project_root: Path | None = None) -> tuple[SQLTyConfig, Path | None]:
|
|
425
|
+
"""Load the ``[tool.sqlty]`` table of the nearest ``pyproject.toml`` that has one.
|
|
426
|
+
|
|
427
|
+
Walks up from ``project_root``, skipping ``pyproject.toml`` files without a
|
|
428
|
+
``[tool.sqlty]`` table (like ruff), so a package's packaging-only
|
|
429
|
+
``pyproject.toml`` does not hide the workspace config. The closest table
|
|
430
|
+
wins; tables are never merged.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
project_root: Directory to start the upward search from; cwd if None.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
Tuple of (configuration dictionary, directory containing that
|
|
437
|
+
pyproject.toml, or None if none was found or it failed to parse)
|
|
438
|
+
"""
|
|
439
|
+
if project_root is None:
|
|
440
|
+
project_root = Path.cwd()
|
|
441
|
+
|
|
442
|
+
# Search for pyproject.toml up the directory tree
|
|
443
|
+
current = project_root.resolve()
|
|
444
|
+
while current != current.parent:
|
|
445
|
+
pyproject_path = current / "pyproject.toml"
|
|
446
|
+
if pyproject_path.exists():
|
|
447
|
+
try:
|
|
448
|
+
config = _parse_pyproject_cached(pyproject_path, pyproject_path.stat().st_mtime_ns)
|
|
449
|
+
except ConfigError:
|
|
450
|
+
raise
|
|
451
|
+
except Exception as e:
|
|
452
|
+
logging.getLogger(__name__).warning("Failed to parse %s: %s", pyproject_path, e)
|
|
453
|
+
return {}, None
|
|
454
|
+
if config is not None:
|
|
455
|
+
return copy.deepcopy(config), current
|
|
456
|
+
current = current.parent
|
|
457
|
+
|
|
458
|
+
# No [tool.sqlty] table found
|
|
459
|
+
return {}, None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@lru_cache(maxsize=256)
|
|
463
|
+
def _parse_pyproject_cached(pyproject_path: Path, mtime_ns: int) -> SQLTyConfig | None:
|
|
464
|
+
"""Parse ``pyproject_path`` once per (path, mtime_ns); an edit changes the mtime and re-parses.
|
|
465
|
+
|
|
466
|
+
The mtime is part of the key so long-running processes see edits. Failures
|
|
467
|
+
are not cached.
|
|
468
|
+
"""
|
|
469
|
+
return _parse_pyproject(pyproject_path)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _parse_pyproject(pyproject_path: Path) -> SQLTyConfig | None:
|
|
473
|
+
"""Parse pyproject.toml and extract [tool.sqlty] section.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
pyproject_path: Path to pyproject.toml
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
Configuration dictionary, or None if there is no [tool.sqlty] table
|
|
480
|
+
"""
|
|
481
|
+
import tomllib
|
|
482
|
+
|
|
483
|
+
with open(pyproject_path, "rb") as f:
|
|
484
|
+
data: dict[str, Any] = tomllib.load(f)
|
|
485
|
+
|
|
486
|
+
# Extract [tool.sqlty] section
|
|
487
|
+
tool_config: dict[str, Any] | None = data.get("tool", {}).get("sqlty")
|
|
488
|
+
if tool_config is None:
|
|
489
|
+
return None
|
|
490
|
+
return _parse_table(tool_config, str(pyproject_path))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def load_config_file(path: Path) -> SQLTyConfig:
|
|
494
|
+
"""Load the configuration of a file named by ``--config``.
|
|
495
|
+
|
|
496
|
+
A file named ``pyproject.toml`` must have a ``[tool.sqlty]`` table; any other
|
|
497
|
+
file, such as ``sqlty.toml``, holds the same keys at the top level. Raises
|
|
498
|
+
``ConfigError`` when the file is missing, is not TOML, is a ``pyproject.toml``
|
|
499
|
+
without ``[tool.sqlty]`` or has an invalid key.
|
|
500
|
+
|
|
501
|
+
Example:
|
|
502
|
+
>>> load_config_file(Path("missing.toml")) # doctest: +ELLIPSIS
|
|
503
|
+
Traceback (most recent call last):
|
|
504
|
+
sqlty._config.ConfigError: config file does not exist: missing.toml
|
|
505
|
+
"""
|
|
506
|
+
import tomllib
|
|
507
|
+
|
|
508
|
+
if not path.exists():
|
|
509
|
+
raise ConfigError(f"config file does not exist: {path}")
|
|
510
|
+
if not path.is_file():
|
|
511
|
+
raise ConfigError(f"config path is not a file: {path}")
|
|
512
|
+
try:
|
|
513
|
+
with open(path, "rb") as f:
|
|
514
|
+
data: dict[str, Any] = tomllib.load(f)
|
|
515
|
+
except tomllib.TOMLDecodeError as exc:
|
|
516
|
+
raise ConfigError(f"{path}: {exc}") from None
|
|
517
|
+
if path.name == "pyproject.toml":
|
|
518
|
+
table = data.get("tool", {}).get("sqlty")
|
|
519
|
+
if table is None:
|
|
520
|
+
raise ConfigError(f"{path}: no [tool.sqlty] table")
|
|
521
|
+
data = table
|
|
522
|
+
return _parse_table(data, str(path))
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def parse_overrides(assignments: list[str]) -> SQLTyConfig:
|
|
526
|
+
"""Parse ``-c/--config`` TOML assignments into one configuration; later ones win.
|
|
527
|
+
|
|
528
|
+
Tables merge key by key, so ``rules.a = "ignore"`` and ``rules.b = "warn"``
|
|
529
|
+
both apply. Raises ``ConfigError`` for a value that is not a TOML assignment
|
|
530
|
+
or names an unknown key.
|
|
531
|
+
|
|
532
|
+
Example:
|
|
533
|
+
>>> parse_overrides(['dialect = "mysql"', 'rules.select-star = "ignore"'])
|
|
534
|
+
{'dialect': 'mysql', 'rules': {'select-star': 'ignore'}}
|
|
535
|
+
"""
|
|
536
|
+
import tomllib
|
|
537
|
+
|
|
538
|
+
merged: dict[str, Any] = {}
|
|
539
|
+
for assignment in assignments:
|
|
540
|
+
if "=" not in assignment:
|
|
541
|
+
raise ConfigError(
|
|
542
|
+
f"--config expects a TOML assignment such as 'dialect = \"mysql\"', got "
|
|
543
|
+
f"{assignment!r}; use --config-file for a file"
|
|
544
|
+
)
|
|
545
|
+
try:
|
|
546
|
+
_merge(merged, tomllib.loads(assignment))
|
|
547
|
+
except tomllib.TOMLDecodeError as exc:
|
|
548
|
+
raise ConfigError(
|
|
549
|
+
f"--config {assignment!r}: {exc}; quote strings, as in -c 'dialect = \"mysql\"', "
|
|
550
|
+
"or use --config-file for a file"
|
|
551
|
+
) from None
|
|
552
|
+
return _parse_table(merged, "--config")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _merge(target: dict[str, Any], update: dict[str, Any]) -> None:
|
|
556
|
+
for key, value in update.items():
|
|
557
|
+
if isinstance(value, dict) and isinstance(target.get(key), dict):
|
|
558
|
+
_merge(target[key], value)
|
|
559
|
+
else:
|
|
560
|
+
target[key] = value
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def validate_rule_severity(pairs: list[tuple[str, str]]) -> dict[str, str]:
|
|
564
|
+
"""Rule severities from ``--error``/``--warn``/``--ignore RULE``, the last flag per rule winning.
|
|
565
|
+
|
|
566
|
+
Example:
|
|
567
|
+
>>> validate_rule_severity([("select-star", "error"), ("select-star", "ignore")])
|
|
568
|
+
{'select-star': 'ignore'}
|
|
569
|
+
"""
|
|
570
|
+
from sqlty._lint import RULES
|
|
571
|
+
|
|
572
|
+
for rule, severity in pairs:
|
|
573
|
+
if rule not in RULES:
|
|
574
|
+
raise ConfigError(
|
|
575
|
+
f"--{severity}: unknown rule {rule!r}{_suggest(rule, list(RULES))}; "
|
|
576
|
+
"run `sqlty rule` for the list"
|
|
577
|
+
)
|
|
578
|
+
return dict(pairs)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _parse_table(tool_config: dict[str, Any], source: str) -> SQLTyConfig:
|
|
582
|
+
"""Validate a ``[tool.sqlty]`` table and convert it to ``SQLTyConfig``."""
|
|
583
|
+
validate_tool_table(tool_config, source)
|
|
584
|
+
config: SQLTyConfig = {}
|
|
585
|
+
|
|
586
|
+
if "exclude" in tool_config:
|
|
587
|
+
config["exclude"] = list(tool_config["exclude"])
|
|
588
|
+
if "extend-exclude" in tool_config:
|
|
589
|
+
config["extend_exclude"] = list(tool_config["extend-exclude"])
|
|
590
|
+
if "respect-gitignore" in tool_config:
|
|
591
|
+
config["respect_gitignore"] = tool_config["respect-gitignore"]
|
|
592
|
+
|
|
593
|
+
# Parse each configuration option
|
|
594
|
+
if "dialect" in tool_config:
|
|
595
|
+
config["dialect"] = str(tool_config["dialect"])
|
|
596
|
+
|
|
597
|
+
# Single schema path (top-level convenience key)
|
|
598
|
+
if "schema" in tool_config:
|
|
599
|
+
config["schema"] = _schema_spec(tool_config["schema"])
|
|
600
|
+
if "format-command" in tool_config:
|
|
601
|
+
config["format_command"] = list(tool_config["format-command"])
|
|
602
|
+
|
|
603
|
+
# Rule severity overrides: [tool.sqlty.rules]
|
|
604
|
+
if "rules" in tool_config and isinstance(tool_config["rules"], dict):
|
|
605
|
+
config["rules"] = {str(k): str(v) for k, v in tool_config["rules"].items()}
|
|
606
|
+
if "overrides" in tool_config:
|
|
607
|
+
config["overrides"] = [
|
|
608
|
+
{
|
|
609
|
+
"include": list(o.get("include", [])),
|
|
610
|
+
"exclude": list(o.get("exclude", [])),
|
|
611
|
+
"rules": dict(o.get("rules", {})),
|
|
612
|
+
}
|
|
613
|
+
for o in tool_config["overrides"]
|
|
614
|
+
]
|
|
615
|
+
|
|
616
|
+
# Allow hyphenated key in pyproject: import-style
|
|
617
|
+
if "import-style" in tool_config:
|
|
618
|
+
config["import_style"] = str(tool_config["import-style"])
|
|
619
|
+
|
|
620
|
+
if "untyped" in tool_config:
|
|
621
|
+
config["untyped"] = str(tool_config["untyped"])
|
|
622
|
+
|
|
623
|
+
if "json-types" in tool_config:
|
|
624
|
+
config["json_types"] = str(tool_config["json-types"])
|
|
625
|
+
|
|
626
|
+
if "types" in tool_config and isinstance(tool_config["types"], dict):
|
|
627
|
+
config["types"] = {str(k): str(v) for k, v in tool_config["types"].items()}
|
|
628
|
+
|
|
629
|
+
if "type-modules" in tool_config:
|
|
630
|
+
modules = tool_config["type-modules"]
|
|
631
|
+
config["type_modules"] = (
|
|
632
|
+
[str(m) for m in modules] if isinstance(modules, list) else [str(modules)]
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
# Parse function configurations
|
|
636
|
+
if "functions" in tool_config and isinstance(tool_config["functions"], list):
|
|
637
|
+
functions: list[FunctionConfigComplete] = []
|
|
638
|
+
for func_config in tool_config["functions"]:
|
|
639
|
+
if not isinstance(func_config, dict):
|
|
640
|
+
continue
|
|
641
|
+
|
|
642
|
+
# Validate required fields
|
|
643
|
+
if "import-path" not in func_config or "query-param" not in func_config:
|
|
644
|
+
logging.getLogger(__name__).warning(
|
|
645
|
+
"Skipping function config missing required fields: %s", func_config
|
|
646
|
+
)
|
|
647
|
+
continue
|
|
648
|
+
|
|
649
|
+
# Build function config with all fields
|
|
650
|
+
function_dict: dict[str, Any] = {
|
|
651
|
+
"import_path": str(func_config["import-path"]),
|
|
652
|
+
"query_param": str(func_config["query-param"]),
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
# Optional return-type-param field
|
|
656
|
+
if "return-type-param" in func_config:
|
|
657
|
+
function_dict["return_type_param"] = str(func_config["return-type-param"])
|
|
658
|
+
|
|
659
|
+
# Cast to FunctionConfigComplete after all fields are set
|
|
660
|
+
function = cast(FunctionConfigComplete, function_dict)
|
|
661
|
+
functions.append(function)
|
|
662
|
+
|
|
663
|
+
if functions:
|
|
664
|
+
config["functions"] = functions
|
|
665
|
+
|
|
666
|
+
return config
|
|
Binary file
|