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/_cli.py
ADDED
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
"""CLI entry point — ruff-style subcommand router.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
sqlty rewrite [OPTIONS] [PATH...] Rewrite source files
|
|
5
|
+
sqlty strip [OPTIONS] [PATH...] Strip SQL[T](...) wrappers
|
|
6
|
+
sqlty check [OPTIONS] [PATH...] Lint SQL type annotations
|
|
7
|
+
sqlty rule [NAME] Explain a lint rule, or list them
|
|
8
|
+
sqlty version Print version
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import fnmatch
|
|
14
|
+
import functools
|
|
15
|
+
import gc
|
|
16
|
+
import glob
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass, replace
|
|
23
|
+
from pathlib import Path, PurePosixPath
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
import argparse
|
|
28
|
+
from collections import Counter
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
|
|
31
|
+
from sqlty._config import RuleOverride, SchemaSpec, SQLTyConfig
|
|
32
|
+
from sqlty._core import Schema
|
|
33
|
+
from sqlty._lint import Diagnostic
|
|
34
|
+
from sqlty._source import ParsedSource
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
EXIT_FOUND = 1
|
|
39
|
+
"""``check`` reported an error, or ``--check``/``--diff`` found files to change."""
|
|
40
|
+
EXIT_ERROR = 2
|
|
41
|
+
"""Bad usage or configuration, a missing path, or an internal error."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: list[str] | None = None) -> None:
|
|
45
|
+
"""CLI entry point."""
|
|
46
|
+
parser = build_parser()
|
|
47
|
+
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
|
48
|
+
|
|
49
|
+
if args.version:
|
|
50
|
+
_cmd_version()
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
if args.command is None:
|
|
54
|
+
parser.print_help()
|
|
55
|
+
sys.exit(0)
|
|
56
|
+
|
|
57
|
+
from sqlty._config import ConfigError
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
match args.command:
|
|
61
|
+
case "rewrite":
|
|
62
|
+
_cmd_rewrite(args)
|
|
63
|
+
case "strip":
|
|
64
|
+
_cmd_strip(args)
|
|
65
|
+
case "check":
|
|
66
|
+
_cmd_check(args)
|
|
67
|
+
case "breaking":
|
|
68
|
+
from sqlty._breaking import main as breaking
|
|
69
|
+
|
|
70
|
+
breaking(args)
|
|
71
|
+
case "rule":
|
|
72
|
+
_cmd_rule(args.name)
|
|
73
|
+
case "skill":
|
|
74
|
+
_cmd_skill(args.install)
|
|
75
|
+
case "version":
|
|
76
|
+
_cmd_version()
|
|
77
|
+
case _:
|
|
78
|
+
parser.print_help()
|
|
79
|
+
except ConfigError as exc:
|
|
80
|
+
print(f"sqlty: error: {exc}", file=sys.stderr)
|
|
81
|
+
sys.exit(EXIT_ERROR)
|
|
82
|
+
except Exception:
|
|
83
|
+
import traceback
|
|
84
|
+
|
|
85
|
+
traceback.print_exc()
|
|
86
|
+
print("sqlty: internal error; please report it with the traceback above", file=sys.stderr)
|
|
87
|
+
sys.exit(EXIT_ERROR)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
91
|
+
"""The ``sqlty`` argument parser; ``docs/reference/cli.md`` is rendered from it."""
|
|
92
|
+
import argparse
|
|
93
|
+
|
|
94
|
+
parser = argparse.ArgumentParser(
|
|
95
|
+
prog="sqlty",
|
|
96
|
+
description="Type-safe SQL queries for Python.",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument("--version", action="store_true", help="Print version and exit")
|
|
99
|
+
|
|
100
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
101
|
+
|
|
102
|
+
# --- rewrite ---
|
|
103
|
+
rewrite_parser = subparsers.add_parser(
|
|
104
|
+
"rewrite",
|
|
105
|
+
help='Rewrite "--sql\\n..." strings into SQL[T]("--sql\\n...") calls',
|
|
106
|
+
)
|
|
107
|
+
_add_common_args(rewrite_parser)
|
|
108
|
+
rewrite_parser.add_argument(
|
|
109
|
+
"--check",
|
|
110
|
+
action="store_true",
|
|
111
|
+
help="Do not modify files; print `Would rewrite <file>` and exit 1 if any file needs "
|
|
112
|
+
"rewriting",
|
|
113
|
+
)
|
|
114
|
+
rewrite_parser.add_argument(
|
|
115
|
+
"--diff",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="Print a unified diff instead of modifying files; exit 1 if any file would change",
|
|
118
|
+
)
|
|
119
|
+
rewrite_parser.add_argument(
|
|
120
|
+
"--untyped",
|
|
121
|
+
choices=["skip", "sentinel"],
|
|
122
|
+
help="Row-returning queries of unknown row type: leave unwrapped (skip, default) "
|
|
123
|
+
"or wrap as `SQL[UNTYPED](...)` (sentinel). Config: untyped in [tool.sqlty]",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# --- strip ---
|
|
127
|
+
strip_parser = subparsers.add_parser(
|
|
128
|
+
"strip",
|
|
129
|
+
help='Strip SQL[T]("--sql\\n...") wrappers back to plain strings',
|
|
130
|
+
)
|
|
131
|
+
_add_common_args(strip_parser)
|
|
132
|
+
strip_parser.add_argument(
|
|
133
|
+
"--check",
|
|
134
|
+
action="store_true",
|
|
135
|
+
help="Do not modify files; print `Would strip <file>` and exit 1 if any file would change",
|
|
136
|
+
)
|
|
137
|
+
strip_parser.add_argument(
|
|
138
|
+
"--diff",
|
|
139
|
+
action="store_true",
|
|
140
|
+
help="Print a unified diff instead of modifying files; exit 1 if any file would change",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# --- check ---
|
|
144
|
+
check_parser = subparsers.add_parser(
|
|
145
|
+
"check",
|
|
146
|
+
help="Lint: validate SQL type annotations and detect issues",
|
|
147
|
+
)
|
|
148
|
+
_add_common_args(check_parser)
|
|
149
|
+
check_parser.add_argument(
|
|
150
|
+
"--fix",
|
|
151
|
+
action="store_true",
|
|
152
|
+
help="Apply the fixable rules (see `sqlty rule`), write the files, then report what remains",
|
|
153
|
+
)
|
|
154
|
+
check_parser.add_argument(
|
|
155
|
+
"--exit-zero",
|
|
156
|
+
action="store_true",
|
|
157
|
+
help="Exit 0 even when an error-severity diagnostic is reported",
|
|
158
|
+
)
|
|
159
|
+
check_parser.add_argument(
|
|
160
|
+
"--exit-zero-on-warning",
|
|
161
|
+
action="store_true",
|
|
162
|
+
help="Exit 0 when no diagnostic has severity error (the default); cannot be combined "
|
|
163
|
+
"with --error-on-warning",
|
|
164
|
+
)
|
|
165
|
+
check_parser.add_argument(
|
|
166
|
+
"--error-on-warning",
|
|
167
|
+
action="store_true",
|
|
168
|
+
help="Exit 1 when any diagnostic has severity warn; cannot be combined with --exit-zero "
|
|
169
|
+
"or --exit-zero-on-warning",
|
|
170
|
+
)
|
|
171
|
+
check_parser.add_argument(
|
|
172
|
+
"--statistics",
|
|
173
|
+
action="store_true",
|
|
174
|
+
help="After the summary, print how many queries were typed, partially typed, unparsed "
|
|
175
|
+
"and skipped",
|
|
176
|
+
)
|
|
177
|
+
check_parser.add_argument(
|
|
178
|
+
"--explain",
|
|
179
|
+
action="store_true",
|
|
180
|
+
help="Under a type diagnostic, print the inference rule (id, reference, reasoning) "
|
|
181
|
+
"behind each column's type and nullability",
|
|
182
|
+
)
|
|
183
|
+
check_parser.add_argument(
|
|
184
|
+
"--output-format",
|
|
185
|
+
choices=["text", "json", "github"],
|
|
186
|
+
default="text",
|
|
187
|
+
help="How diagnostics are printed: text (file:line: [rule] message, the default), json "
|
|
188
|
+
"(an array of objects with file, line, rule, severity and message) or github (GitHub "
|
|
189
|
+
"Actions annotations). Summaries, --statistics and --explain are text only",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# --- breaking ---
|
|
193
|
+
breaking_parser = subparsers.add_parser(
|
|
194
|
+
"breaking",
|
|
195
|
+
help="Check the queries and schema of two git revisions across, as during a rolling "
|
|
196
|
+
"deploy: sqlty breaking --against origin/main",
|
|
197
|
+
)
|
|
198
|
+
breaking_parser.add_argument(
|
|
199
|
+
"paths",
|
|
200
|
+
nargs="*",
|
|
201
|
+
type=Path,
|
|
202
|
+
help="Files or directories to check, in both revisions (default: current directory)",
|
|
203
|
+
)
|
|
204
|
+
breaking_parser.add_argument(
|
|
205
|
+
"--against",
|
|
206
|
+
required=True,
|
|
207
|
+
metavar="REF",
|
|
208
|
+
help="The baseline revision: a branch, tag, SHA or HEAD~1",
|
|
209
|
+
)
|
|
210
|
+
breaking_parser.add_argument(
|
|
211
|
+
"--head",
|
|
212
|
+
metavar="REF",
|
|
213
|
+
help="The new revision (default: the working tree, uncommitted changes included)",
|
|
214
|
+
)
|
|
215
|
+
breaking_parser.add_argument(
|
|
216
|
+
"--direction",
|
|
217
|
+
choices=["backward", "forward", "both"],
|
|
218
|
+
default="both",
|
|
219
|
+
help="backward: queries of --against on the schema of --head (migrate first); forward: "
|
|
220
|
+
"queries of --head on the schema of --against (deploy first)",
|
|
221
|
+
)
|
|
222
|
+
breaking_parser.add_argument(
|
|
223
|
+
"-q", "--quiet", action="store_true", help="Print nothing; only the exit code tells"
|
|
224
|
+
)
|
|
225
|
+
breaking_parser.add_argument(
|
|
226
|
+
"--output-format",
|
|
227
|
+
choices=["text", "json", "github"],
|
|
228
|
+
default="text",
|
|
229
|
+
help="How findings are printed: text (the default), json (an array of objects with "
|
|
230
|
+
"file, line, rule, severity, message and direction) or github (GitHub Actions error "
|
|
231
|
+
"annotations)",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# --- rule ---
|
|
235
|
+
rule_parser = subparsers.add_parser(
|
|
236
|
+
"rule",
|
|
237
|
+
help="Explain a lint rule (sqlty rule stale-type), or list all rules",
|
|
238
|
+
)
|
|
239
|
+
rule_parser.add_argument("name", nargs="?", help="Rule name, for example select-star")
|
|
240
|
+
|
|
241
|
+
# --- skill ---
|
|
242
|
+
skill_parser = subparsers.add_parser(
|
|
243
|
+
"skill",
|
|
244
|
+
help="Print the Agent Skill for coding agents, or install it (sqlty skill --install .claude/skills)",
|
|
245
|
+
)
|
|
246
|
+
skill_parser.add_argument(
|
|
247
|
+
"--install",
|
|
248
|
+
metavar="DIR",
|
|
249
|
+
type=Path,
|
|
250
|
+
help="Write the skill to DIR/sqlty/SKILL.md instead of printing it",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# --- version ---
|
|
254
|
+
subparsers.add_parser("version", help="Print version and exit")
|
|
255
|
+
return parser
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _rule_severity(severity: str, rule: str) -> tuple[str, str]:
|
|
259
|
+
return rule, severity
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _dialect(name: str) -> str:
|
|
263
|
+
"""``--dialect``: a dialect name, in any case."""
|
|
264
|
+
import argparse
|
|
265
|
+
|
|
266
|
+
from sqlty._config import dialect_problem
|
|
267
|
+
|
|
268
|
+
if problem := dialect_problem(name):
|
|
269
|
+
raise argparse.ArgumentTypeError(problem)
|
|
270
|
+
return name.lower()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _add_common_args(parser: argparse.ArgumentParser) -> None:
|
|
274
|
+
"""Add arguments shared across rewrite/strip/check."""
|
|
275
|
+
import argparse
|
|
276
|
+
|
|
277
|
+
parser.add_argument(
|
|
278
|
+
"paths",
|
|
279
|
+
nargs="*",
|
|
280
|
+
type=Path,
|
|
281
|
+
default=[Path(".")],
|
|
282
|
+
help="Files or directories (default: .); each file uses its nearest pyproject.toml",
|
|
283
|
+
)
|
|
284
|
+
parser.add_argument(
|
|
285
|
+
"--dialect",
|
|
286
|
+
type=_dialect,
|
|
287
|
+
metavar="DIALECT",
|
|
288
|
+
help="SQL dialect: postgres, mysql, sqlite, or another sqlparser dialect such as "
|
|
289
|
+
"duckdb (default: [tool.sqlty] dialect, else postgres)",
|
|
290
|
+
)
|
|
291
|
+
parser.add_argument(
|
|
292
|
+
"--schema",
|
|
293
|
+
action="append",
|
|
294
|
+
help="Schema .sql file, directory or glob for every path; repeat for several "
|
|
295
|
+
"(default: [tool.sqlty] schema per project)",
|
|
296
|
+
)
|
|
297
|
+
source = parser.add_mutually_exclusive_group()
|
|
298
|
+
source.add_argument(
|
|
299
|
+
"--config-file",
|
|
300
|
+
type=Path,
|
|
301
|
+
metavar="PATH",
|
|
302
|
+
help="Use this file for every path instead of the nearest pyproject.toml: [tool.sqlty] "
|
|
303
|
+
"of a pyproject.toml, or top-level keys of another file such as sqlty.toml; its "
|
|
304
|
+
"relative paths are relative to the current directory. Env: SQLTY_CONFIG_FILE",
|
|
305
|
+
)
|
|
306
|
+
source.add_argument(
|
|
307
|
+
"--isolated",
|
|
308
|
+
action="store_true",
|
|
309
|
+
help="Ignore every configuration file; use the defaults and command-line flags",
|
|
310
|
+
)
|
|
311
|
+
parser.add_argument(
|
|
312
|
+
"-c",
|
|
313
|
+
"--config",
|
|
314
|
+
action="append",
|
|
315
|
+
metavar="KEY=VALUE",
|
|
316
|
+
help="Override a configuration key with a TOML assignment, for example "
|
|
317
|
+
"`-c 'dialect = \"mysql\"'` or `-c 'rules.select-star = \"ignore\"'`; repeat for "
|
|
318
|
+
"several. Paths are relative to the current directory",
|
|
319
|
+
)
|
|
320
|
+
for severity in ("error", "warn", "ignore"):
|
|
321
|
+
parser.add_argument(
|
|
322
|
+
f"--{severity}",
|
|
323
|
+
action="append",
|
|
324
|
+
dest="rule_severity",
|
|
325
|
+
metavar="RULE",
|
|
326
|
+
type=functools.partial(_rule_severity, severity),
|
|
327
|
+
help=f"Treat RULE as severity {severity}; repeat for several, the last flag for a rule wins",
|
|
328
|
+
)
|
|
329
|
+
parser.add_argument(
|
|
330
|
+
"--exclude",
|
|
331
|
+
action="append",
|
|
332
|
+
metavar="GLOB",
|
|
333
|
+
help="Skip this glob while walking directories, replacing [tool.sqlty] exclude and the "
|
|
334
|
+
"default excludes; repeat for several. Relative to the current directory",
|
|
335
|
+
)
|
|
336
|
+
parser.add_argument(
|
|
337
|
+
"--extend-exclude",
|
|
338
|
+
action="append",
|
|
339
|
+
metavar="GLOB",
|
|
340
|
+
help="Skip this glob in addition to the other excludes; repeat for several. Relative to "
|
|
341
|
+
"the current directory",
|
|
342
|
+
)
|
|
343
|
+
parser.add_argument(
|
|
344
|
+
"--force-exclude",
|
|
345
|
+
action="store_true",
|
|
346
|
+
help="Apply the excludes to files and directories named on the command line too",
|
|
347
|
+
)
|
|
348
|
+
parser.add_argument(
|
|
349
|
+
"--respect-gitignore",
|
|
350
|
+
action=argparse.BooleanOptionalAction,
|
|
351
|
+
default=None,
|
|
352
|
+
help="Skip the files .gitignore ignores while walking directories (default: "
|
|
353
|
+
"[tool.sqlty] respect-gitignore, else true)",
|
|
354
|
+
)
|
|
355
|
+
parser.add_argument(
|
|
356
|
+
"-v",
|
|
357
|
+
"--verbose",
|
|
358
|
+
action="count",
|
|
359
|
+
default=0,
|
|
360
|
+
help="Print each file that rewrite, strip or check --fix changes",
|
|
361
|
+
)
|
|
362
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="Suppress non-error output")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# --- Command implementations ---
|
|
366
|
+
|
|
367
|
+
_SKIPPED_STRING_RULES = ("fstring-skipped", "tstring-skipped", "unchecked-query")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _cmd_rewrite(args: argparse.Namespace) -> None:
|
|
371
|
+
"""Rewrite command: transform --sql strings into SQL[T](...) calls.
|
|
372
|
+
|
|
373
|
+
f- and t-strings starting with ``--sql`` are never rewritten; they are
|
|
374
|
+
reported on stderr (``fstring-skipped``, ``tstring-skipped``) unless ``-q``.
|
|
375
|
+
"""
|
|
376
|
+
from sqlty._lint import RULES
|
|
377
|
+
from sqlty.core import check_source, rewrite_source
|
|
378
|
+
|
|
379
|
+
def transform(path: Path, original: str, settings: Settings) -> str:
|
|
380
|
+
rewritten = rewrite_source(
|
|
381
|
+
original,
|
|
382
|
+
dialect=settings.dialect,
|
|
383
|
+
schema_sql=settings.schema,
|
|
384
|
+
import_style=settings.import_style,
|
|
385
|
+
untyped=settings.untyped,
|
|
386
|
+
json_types=settings.json_types,
|
|
387
|
+
)
|
|
388
|
+
if not args.quiet:
|
|
389
|
+
skipped_only = dict.fromkeys(RULES, "ignore") | {
|
|
390
|
+
rule: (settings.rules or {}).get(rule, RULES[rule].default_severity.value)
|
|
391
|
+
for rule in _SKIPPED_STRING_RULES
|
|
392
|
+
}
|
|
393
|
+
for diag in check_source(
|
|
394
|
+
original, import_style=settings.import_style, rules=skipped_only
|
|
395
|
+
):
|
|
396
|
+
print(f"{path}:{diag['line']}: [{diag['rule']}] {diag['message']}", file=sys.stderr)
|
|
397
|
+
return rewritten
|
|
398
|
+
|
|
399
|
+
_transform_files(args, transform, "rewrite", "Rewrote", "rewritten")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _cmd_strip(args: argparse.Namespace) -> None:
|
|
403
|
+
"""Strip command: remove SQL[T](...) wrappers."""
|
|
404
|
+
from sqlty.core import strip_source
|
|
405
|
+
|
|
406
|
+
def transform(path: Path, original: str, settings: Settings) -> str:
|
|
407
|
+
return strip_source(original, import_style=settings.import_style)
|
|
408
|
+
|
|
409
|
+
_transform_files(args, transform, "strip", "Stripped", "stripped")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _transform_files(
|
|
413
|
+
args: argparse.Namespace,
|
|
414
|
+
transform: Callable[[Path, str, Settings], str],
|
|
415
|
+
verb: str,
|
|
416
|
+
done: str,
|
|
417
|
+
participle: str,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Apply ``transform`` to every collected file for ``rewrite`` and ``strip``.
|
|
420
|
+
|
|
421
|
+
Writes the result back. With ``--check`` or ``--diff`` it leaves the files
|
|
422
|
+
alone and exits 1 when any would change.
|
|
423
|
+
"""
|
|
424
|
+
resolver = SettingsResolver(args)
|
|
425
|
+
files = _collect_python_files(args.paths, resolver.source)
|
|
426
|
+
|
|
427
|
+
if not files and not args.quiet:
|
|
428
|
+
logger.warning("No Python files found")
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
changed = 0
|
|
432
|
+
unreadable = 0
|
|
433
|
+
for path in files:
|
|
434
|
+
original = read_source(path)
|
|
435
|
+
if original is None:
|
|
436
|
+
unreadable += 1
|
|
437
|
+
continue
|
|
438
|
+
result = transform(path, original, resolver.for_file(path))
|
|
439
|
+
if result == original:
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
changed += 1
|
|
443
|
+
if args.check:
|
|
444
|
+
if not args.quiet:
|
|
445
|
+
print(f"Would {verb} {path}")
|
|
446
|
+
elif args.diff:
|
|
447
|
+
_print_diff(path, original, result)
|
|
448
|
+
else:
|
|
449
|
+
path.write_text(result, encoding="utf-8")
|
|
450
|
+
if verb == "rewrite":
|
|
451
|
+
_format(path, resolver.for_file(path).format_command)
|
|
452
|
+
if args.verbose:
|
|
453
|
+
print(f"{done} {path}")
|
|
454
|
+
|
|
455
|
+
if args.check or args.diff:
|
|
456
|
+
if changed > 0:
|
|
457
|
+
if args.check and not args.quiet:
|
|
458
|
+
print(f"{changed} file(s) would be {participle}")
|
|
459
|
+
sys.exit(EXIT_FOUND)
|
|
460
|
+
elif args.check and not args.quiet and not unreadable:
|
|
461
|
+
print("All files up to date")
|
|
462
|
+
if unreadable:
|
|
463
|
+
sys.exit(EXIT_ERROR)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def read_source(path: Path) -> str | None:
|
|
467
|
+
"""The text of the Python file ``path``, or ``None``, logged, when it is not UTF-8.
|
|
468
|
+
|
|
469
|
+
sqlty reads and writes sources as UTF-8, Python's default; a file in another
|
|
470
|
+
encoding is skipped rather than stopping the run.
|
|
471
|
+
"""
|
|
472
|
+
try:
|
|
473
|
+
return path.read_text(encoding="utf-8")
|
|
474
|
+
except UnicodeDecodeError as exc:
|
|
475
|
+
logger.error("%s: not UTF-8 (%s at byte %d); skipped", path, exc.reason, exc.start)
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _format(path: Path, command: tuple[str, ...]) -> bool:
|
|
480
|
+
"""Run the configured formatter on ``path``; whether it ran and succeeded."""
|
|
481
|
+
import subprocess
|
|
482
|
+
|
|
483
|
+
if not command:
|
|
484
|
+
return False
|
|
485
|
+
try:
|
|
486
|
+
result = subprocess.run([*command, str(path)], capture_output=True, text=True, check=False)
|
|
487
|
+
except OSError as exc:
|
|
488
|
+
logger.warning("format-command %s failed on %s: %s", command[0], path, exc)
|
|
489
|
+
return False
|
|
490
|
+
if result.returncode != 0:
|
|
491
|
+
logger.warning(
|
|
492
|
+
"format-command %s failed on %s: %s", command[0], path, result.stderr.strip()
|
|
493
|
+
)
|
|
494
|
+
return False
|
|
495
|
+
return True
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _cmd_check(args: argparse.Namespace) -> None:
|
|
499
|
+
"""Check command: lint SQL type annotations."""
|
|
500
|
+
from collections import Counter
|
|
501
|
+
|
|
502
|
+
from sqlty._config import ConfigError
|
|
503
|
+
from sqlty._source import ParsedSource
|
|
504
|
+
from sqlty.core import HelperIndex, RowUsage, check_source, fix_source, query_statistics
|
|
505
|
+
|
|
506
|
+
if args.error_on_warning and (args.exit_zero or args.exit_zero_on_warning):
|
|
507
|
+
other = "--exit-zero" if args.exit_zero else "--exit-zero-on-warning"
|
|
508
|
+
raise ConfigError(f"--error-on-warning cannot be used with {other}")
|
|
509
|
+
# Parsed trees stay alive for cross-module lookups: collect young objects only, as mypy does.
|
|
510
|
+
gc.set_threshold(200_000, 30, 30)
|
|
511
|
+
resolver = SettingsResolver(args)
|
|
512
|
+
files = _collect_python_files(args.paths, resolver.source)
|
|
513
|
+
|
|
514
|
+
if not files and not args.quiet:
|
|
515
|
+
logger.warning("No Python files found")
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
texts = {path: read_source(path) for path in files}
|
|
519
|
+
unreadable = [path for path, text in texts.items() if text is None]
|
|
520
|
+
files = [path for path in files if texts[path] is not None]
|
|
521
|
+
modules = {path: _module_names(resolver.resolve(path), args.paths) for path in files}
|
|
522
|
+
sources = {
|
|
523
|
+
path: ParsedSource(
|
|
524
|
+
source,
|
|
525
|
+
modules[path][0] + (".__init__" if path.name == "__init__.py" else ""),
|
|
526
|
+
)
|
|
527
|
+
for path in files
|
|
528
|
+
if (source := texts[path]) is not None
|
|
529
|
+
}
|
|
530
|
+
helpers = HelperIndex()
|
|
531
|
+
for path, parsed in sources.items():
|
|
532
|
+
for name in modules[path]:
|
|
533
|
+
helpers.add(name, parsed)
|
|
534
|
+
configured: dict[tuple[tuple[str, str, str | None], ...], HelperIndex] = {}
|
|
535
|
+
|
|
536
|
+
def helpers_for(settings: Settings) -> HelperIndex:
|
|
537
|
+
"""The index for files governed by ``settings``: its own ``functions`` only."""
|
|
538
|
+
if not settings.functions:
|
|
539
|
+
return helpers
|
|
540
|
+
if settings.functions not in configured:
|
|
541
|
+
configured[settings.functions] = index = helpers.fork()
|
|
542
|
+
for import_path, query_param, return_param in settings.functions:
|
|
543
|
+
index.register(import_path, query_param, return_param)
|
|
544
|
+
return configured[settings.functions]
|
|
545
|
+
|
|
546
|
+
total_errors = 0
|
|
547
|
+
total_warnings = 0
|
|
548
|
+
total_fixed = 0
|
|
549
|
+
statistics: Counter[str] = Counter()
|
|
550
|
+
# A row type shared by files is judged by all of its queries: report once every file is read.
|
|
551
|
+
row_usage = RowUsage()
|
|
552
|
+
reports: list[tuple[Path, Diagnostic, ParsedSource]] = []
|
|
553
|
+
|
|
554
|
+
for path in files:
|
|
555
|
+
parsed = sources[path]
|
|
556
|
+
settings = resolver.for_file(path)
|
|
557
|
+
file_helpers = helpers_for(settings)
|
|
558
|
+
if args.fix:
|
|
559
|
+
fixed = fix_source(
|
|
560
|
+
parsed,
|
|
561
|
+
dialect=settings.dialect,
|
|
562
|
+
schema_sql=settings.schema,
|
|
563
|
+
import_style=settings.import_style,
|
|
564
|
+
untyped=settings.untyped,
|
|
565
|
+
rules=settings.rules,
|
|
566
|
+
json_types=settings.json_types,
|
|
567
|
+
)
|
|
568
|
+
if fixed != parsed.text:
|
|
569
|
+
path.write_text(fixed, encoding="utf-8")
|
|
570
|
+
if _format(path, settings.format_command):
|
|
571
|
+
fixed = path.read_text(encoding="utf-8")
|
|
572
|
+
parsed = ParsedSource(fixed, parsed.module)
|
|
573
|
+
total_fixed += 1
|
|
574
|
+
if args.verbose:
|
|
575
|
+
# JSON and annotations own stdout; progress goes to stderr then.
|
|
576
|
+
text = args.output_format == "text"
|
|
577
|
+
print(f"Fixed {path}", file=sys.stdout if text else sys.stderr)
|
|
578
|
+
diagnostics = check_source(
|
|
579
|
+
parsed,
|
|
580
|
+
dialect=settings.dialect,
|
|
581
|
+
schema_sql=settings.schema,
|
|
582
|
+
rules=settings.rules,
|
|
583
|
+
import_style=settings.import_style,
|
|
584
|
+
untyped=settings.untyped,
|
|
585
|
+
helpers=file_helpers,
|
|
586
|
+
type_modules=settings.type_modules,
|
|
587
|
+
row_usage=row_usage,
|
|
588
|
+
)
|
|
589
|
+
if args.statistics:
|
|
590
|
+
statistics += query_statistics(
|
|
591
|
+
parsed,
|
|
592
|
+
dialect=settings.dialect,
|
|
593
|
+
schema_sql=settings.schema,
|
|
594
|
+
import_style=settings.import_style,
|
|
595
|
+
helpers=file_helpers,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
reports.extend((path, diag, parsed) for diag in diagnostics)
|
|
599
|
+
|
|
600
|
+
kept = {id(diag) for diag in row_usage.filter([diag for _, diag, _ in reports])}
|
|
601
|
+
shown: list[dict[str, object]] = []
|
|
602
|
+
for path, diag, parsed in reports:
|
|
603
|
+
if id(diag) not in kept:
|
|
604
|
+
continue
|
|
605
|
+
severity = diag["severity"]
|
|
606
|
+
if severity == "error":
|
|
607
|
+
total_errors += 1
|
|
608
|
+
elif severity == "warn":
|
|
609
|
+
total_warnings += 1
|
|
610
|
+
if args.output_format == "json":
|
|
611
|
+
shown.append({"file": str(path), **diag})
|
|
612
|
+
elif args.output_format == "github":
|
|
613
|
+
print(github_annotation(path, diag))
|
|
614
|
+
elif not args.quiet:
|
|
615
|
+
print(f"{path}:{diag.get('line', '')}: [{diag['rule']}] {diag['message']}")
|
|
616
|
+
if args.explain and diag["rule"] in _EXPLAINED_RULES and "line" in diag:
|
|
617
|
+
settings = resolver.for_file(path)
|
|
618
|
+
for line in _explain(parsed, diag["line"], settings.dialect, settings.schema):
|
|
619
|
+
print(line)
|
|
620
|
+
|
|
621
|
+
if args.output_format == "json":
|
|
622
|
+
print(json.dumps(shown, indent=2))
|
|
623
|
+
text = args.output_format == "text" and not args.quiet
|
|
624
|
+
if total_fixed and text:
|
|
625
|
+
print(f"Fixed {total_fixed} file(s)")
|
|
626
|
+
if text and (total_errors or total_warnings):
|
|
627
|
+
parts = []
|
|
628
|
+
if total_errors:
|
|
629
|
+
parts.append(f"{total_errors} error(s)")
|
|
630
|
+
if total_warnings:
|
|
631
|
+
parts.append(f"{total_warnings} warning(s)")
|
|
632
|
+
print(f"Found {', '.join(parts)}")
|
|
633
|
+
if args.statistics and text:
|
|
634
|
+
print(_format_statistics(statistics))
|
|
635
|
+
|
|
636
|
+
if not args.exit_zero and (total_errors or (args.error_on_warning and total_warnings)):
|
|
637
|
+
sys.exit(EXIT_FOUND)
|
|
638
|
+
if unreadable:
|
|
639
|
+
sys.exit(EXIT_ERROR)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def github_annotation(path: Path, diag: Diagnostic) -> str:
|
|
643
|
+
"""``diag`` as a GitHub Actions workflow command, which annotates the line in the diff."""
|
|
644
|
+
|
|
645
|
+
def escape(text: str, *, property: bool = False) -> str:
|
|
646
|
+
text = text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
647
|
+
return text.replace(":", "%3A").replace(",", "%2C") if property else text
|
|
648
|
+
|
|
649
|
+
level = "error" if diag["severity"] == "error" else "warning"
|
|
650
|
+
where = f"file={escape(str(path), property=True)}"
|
|
651
|
+
if "line" in diag:
|
|
652
|
+
where += f",line={diag['line']}"
|
|
653
|
+
return f"::{level} {where},title={escape('sqlty ' + diag['rule'], property=True)}::{escape(diag['message'])}"
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _cmd_skill(install: Path | None) -> None:
|
|
657
|
+
"""Skill command: print the Agent Skill, or write it under ``install``."""
|
|
658
|
+
from importlib.resources import files
|
|
659
|
+
|
|
660
|
+
skill = files("sqlty").joinpath("skill", "SKILL.md").read_text(encoding="utf-8")
|
|
661
|
+
if install is None:
|
|
662
|
+
print(skill, end="")
|
|
663
|
+
return
|
|
664
|
+
target = install / "sqlty" / "SKILL.md"
|
|
665
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
666
|
+
target.write_text(skill, encoding="utf-8")
|
|
667
|
+
print(f"Wrote {target}")
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# Diagnostics about the type of a query's rows, which `--explain` traces to inference rules.
|
|
671
|
+
_EXPLAINED_RULES = frozenset(
|
|
672
|
+
{
|
|
673
|
+
"stale-type",
|
|
674
|
+
"column-type-mismatch",
|
|
675
|
+
"nullable-mismatch",
|
|
676
|
+
"redundant-optional",
|
|
677
|
+
"literal-mismatch",
|
|
678
|
+
"redundant-literal",
|
|
679
|
+
"json-shape-mismatch",
|
|
680
|
+
}
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _explain(
|
|
685
|
+
parsed: ParsedSource, line: int, dialect: str, schema: str | Schema | None
|
|
686
|
+
) -> list[str]:
|
|
687
|
+
"""The inference rules behind the columns of the query on ``line``, indented."""
|
|
688
|
+
from sqlty._core import query_sites
|
|
689
|
+
from sqlty.core import explain_select
|
|
690
|
+
|
|
691
|
+
sites = [(start, text) for start, kind, text in query_sites(parsed.text) if kind == "query"]
|
|
692
|
+
query = next((text for start, text in reversed(sites) if start <= line), None)
|
|
693
|
+
lines: list[str] = []
|
|
694
|
+
for column in (
|
|
695
|
+
explain_select(query, dialect=dialect, schema_sql=schema) if query else None
|
|
696
|
+
) or []:
|
|
697
|
+
rules = {rule for rule in (column.type_rule, column.nullability_rule) if rule is not None}
|
|
698
|
+
for rule in sorted(rules, key=lambda rule: rule.id):
|
|
699
|
+
parts = [
|
|
700
|
+
part
|
|
701
|
+
for part, by in (
|
|
702
|
+
("type", column.type_rule),
|
|
703
|
+
("nullability", column.nullability_rule),
|
|
704
|
+
)
|
|
705
|
+
if by == rule
|
|
706
|
+
]
|
|
707
|
+
lines.append(f" {column.name}: {' and '.join(parts)} by inference rule {rule.id}")
|
|
708
|
+
lines.append(f" {rule.reasoning}")
|
|
709
|
+
lines.append(f" Reference: {rule.reference}")
|
|
710
|
+
return lines
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _format_statistics(counts: Counter[str]) -> str:
|
|
714
|
+
"""One line of query counts; skipped strings are broken down by rule."""
|
|
715
|
+
skipped = {rule: counts[rule] for rule in _SKIPPED_STRING_RULES if counts[rule]}
|
|
716
|
+
detail = ", ".join(f"{rule}: {count}" for rule, count in skipped.items())
|
|
717
|
+
return (
|
|
718
|
+
f"Queries: {counts['typed']} typed, {counts['partially typed']} partially typed, "
|
|
719
|
+
f"{counts['unparsed']} unparsed, {sum(skipped.values())} skipped"
|
|
720
|
+
+ (f" ({detail})" if detail else "")
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _cmd_rule(name: str | None) -> None:
|
|
725
|
+
"""Rule command: print a rule's documentation, or the rule table."""
|
|
726
|
+
from sqlty._lint import RULES, rule_doc
|
|
727
|
+
|
|
728
|
+
if name is None:
|
|
729
|
+
width = max(map(len, RULES))
|
|
730
|
+
for rule in RULES.values():
|
|
731
|
+
print(f"{rule.name:<{width}} {rule.default_severity.value:<5} {rule.description}")
|
|
732
|
+
return
|
|
733
|
+
if name not in RULES:
|
|
734
|
+
logger.error("Unknown rule %r; run `sqlty rule` for the list", name)
|
|
735
|
+
sys.exit(EXIT_ERROR)
|
|
736
|
+
print(rule_doc(name), end="")
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _cmd_version() -> None:
|
|
740
|
+
"""Print version."""
|
|
741
|
+
from sqlty import __version__
|
|
742
|
+
|
|
743
|
+
print(f"sqlty {__version__}")
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
# --- File helpers ---
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _collect_python_files(paths: list[Path], source: ConfigSource | None = None) -> list[Path]:
|
|
750
|
+
"""Collect .py files from paths (files and directories).
|
|
751
|
+
|
|
752
|
+
Files named on the command line are collected unless ``source`` has
|
|
753
|
+
``force_exclude`` and excludes them. Walking a directory skips the excludes
|
|
754
|
+
``source`` gives it (by default those of the nearest config, or ``.git``,
|
|
755
|
+
``.venv``, ``node_modules``, ...). A path that does not exist raises ``ConfigError``.
|
|
756
|
+
"""
|
|
757
|
+
from sqlty._config import ConfigError
|
|
758
|
+
|
|
759
|
+
source = source or ConfigSource()
|
|
760
|
+
files: list[Path] = []
|
|
761
|
+
for path in paths:
|
|
762
|
+
if path.exists() and source.force_exclude and source.is_excluded(path):
|
|
763
|
+
continue
|
|
764
|
+
if path.is_file():
|
|
765
|
+
if path.suffix == ".py":
|
|
766
|
+
files.append(path)
|
|
767
|
+
elif path.is_dir():
|
|
768
|
+
files.extend(_walk(path, source))
|
|
769
|
+
else:
|
|
770
|
+
raise ConfigError(f"path does not exist: {path}")
|
|
771
|
+
return files
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _config_file(args: argparse.Namespace) -> Path | None:
|
|
775
|
+
"""``--config-file``, else ``SQLTY_CONFIG_FILE`` unless ``--isolated`` (like ty's ``TY_CONFIG_FILE``)."""
|
|
776
|
+
if getattr(args, "config_file", None) is not None:
|
|
777
|
+
return Path(args.config_file)
|
|
778
|
+
env = os.environ.get("SQLTY_CONFIG_FILE")
|
|
779
|
+
return Path(env) if env and not getattr(args, "isolated", False) else None
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
class ConfigSource:
|
|
783
|
+
"""Each directory's ``[tool.sqlty]`` after ``--config-file``, ``--isolated``,
|
|
784
|
+
``-c/--config``, ``--error/--warn/--ignore`` and the exclude flags.
|
|
785
|
+
|
|
786
|
+
By default it is the nearest ``pyproject.toml`` with a table. ``config_file``
|
|
787
|
+
fixes one file for every directory, with relative paths resolved against
|
|
788
|
+
the current directory (like ruff); ``isolated`` uses no file at all.
|
|
789
|
+
``overrides`` are TOML assignments applied on top, then ``rule_severity``
|
|
790
|
+
``(rule, severity)`` pairs in order.
|
|
791
|
+
|
|
792
|
+
Example:
|
|
793
|
+
>>> ConfigSource(isolated=True, extend_exclude=["gen"]).exclusions(Path("."))[-1][1]
|
|
794
|
+
('gen',)
|
|
795
|
+
"""
|
|
796
|
+
|
|
797
|
+
def __init__(
|
|
798
|
+
self,
|
|
799
|
+
config_file: Path | None = None,
|
|
800
|
+
*,
|
|
801
|
+
isolated: bool = False,
|
|
802
|
+
overrides: list[str] | None = None,
|
|
803
|
+
rule_severity: list[tuple[str, str]] | None = None,
|
|
804
|
+
exclude: list[str] | None = None,
|
|
805
|
+
extend_exclude: list[str] | None = None,
|
|
806
|
+
force_exclude: bool = False,
|
|
807
|
+
respect_gitignore: bool | None = None,
|
|
808
|
+
) -> None:
|
|
809
|
+
from sqlty._config import load_config_file, parse_overrides, validate_rule_severity
|
|
810
|
+
from sqlty._gitignore import GitIgnore
|
|
811
|
+
|
|
812
|
+
self._isolated = isolated
|
|
813
|
+
self._fixed = None if config_file is None else load_config_file(config_file)
|
|
814
|
+
self._label = str(config_file) if config_file is not None else None
|
|
815
|
+
self._cwd = Path.cwd().resolve()
|
|
816
|
+
inline = parse_overrides(overrides or [])
|
|
817
|
+
self.schema = inline.pop("schema", None)
|
|
818
|
+
if exclude is None and "exclude" in inline:
|
|
819
|
+
exclude = inline["exclude"]
|
|
820
|
+
inline.pop("exclude", None)
|
|
821
|
+
extend_exclude = [*inline.pop("extend_exclude", []), *(extend_exclude or ())]
|
|
822
|
+
self._overrides = inline
|
|
823
|
+
self._rule_severity = validate_rule_severity(rule_severity or [])
|
|
824
|
+
self._exclude = None if exclude is None else self._relative_globs(exclude)
|
|
825
|
+
self._extend_exclude = self._relative_globs(extend_exclude)
|
|
826
|
+
self.force_exclude = force_exclude
|
|
827
|
+
self._respect_gitignore = inline.pop("respect_gitignore", respect_gitignore)
|
|
828
|
+
self.gitignore = GitIgnore()
|
|
829
|
+
|
|
830
|
+
def _relative_globs(self, patterns: list[str] | tuple[str, ...]) -> tuple[str, ...]:
|
|
831
|
+
"""Command-line globs relative to the current directory: ``./gen`` and ``$PWD/gen`` are ``gen``."""
|
|
832
|
+
globs: list[str] = []
|
|
833
|
+
for pattern in patterns:
|
|
834
|
+
path = Path(pattern)
|
|
835
|
+
if path.is_absolute() and path.is_relative_to(self._cwd):
|
|
836
|
+
pattern = path.relative_to(self._cwd).as_posix() or "."
|
|
837
|
+
while pattern.startswith("./"):
|
|
838
|
+
pattern = pattern[2:]
|
|
839
|
+
globs.append(pattern)
|
|
840
|
+
return tuple(globs)
|
|
841
|
+
|
|
842
|
+
@classmethod
|
|
843
|
+
def from_args(cls, args: argparse.Namespace) -> ConfigSource:
|
|
844
|
+
return cls(
|
|
845
|
+
_config_file(args),
|
|
846
|
+
isolated=getattr(args, "isolated", False),
|
|
847
|
+
overrides=getattr(args, "config", None),
|
|
848
|
+
rule_severity=getattr(args, "rule_severity", None),
|
|
849
|
+
exclude=getattr(args, "exclude", None),
|
|
850
|
+
extend_exclude=getattr(args, "extend_exclude", None),
|
|
851
|
+
force_exclude=getattr(args, "force_exclude", False),
|
|
852
|
+
respect_gitignore=getattr(args, "respect_gitignore", None),
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
def load(self, directory: Path) -> tuple[SQLTyConfig, Path | None, str]:
|
|
856
|
+
"""Config governing ``directory``, the directory its relative paths start from, and its
|
|
857
|
+
file name. Inline overrides and rule flags are applied; ``schema`` and the excludes
|
|
858
|
+
given inline are not (``self.schema``, ``exclusions``)."""
|
|
859
|
+
from sqlty._config import load_config
|
|
860
|
+
|
|
861
|
+
if self._isolated:
|
|
862
|
+
config: SQLTyConfig = {}
|
|
863
|
+
config_dir, label = None, "--isolated"
|
|
864
|
+
elif self._fixed is not None:
|
|
865
|
+
config, config_dir, label = self._fixed, self._cwd, str(self._label)
|
|
866
|
+
else:
|
|
867
|
+
config, config_dir = load_config(directory)
|
|
868
|
+
label = f"{config_dir}/pyproject.toml"
|
|
869
|
+
if self._overrides or self._rule_severity:
|
|
870
|
+
rules = {**config.get("rules", {}), **self._overrides.get("rules", {})}
|
|
871
|
+
rules.update(self._rule_severity)
|
|
872
|
+
config = {**config, **self._overrides}
|
|
873
|
+
if rules:
|
|
874
|
+
config["rules"] = rules
|
|
875
|
+
return config, config_dir, label
|
|
876
|
+
|
|
877
|
+
@property
|
|
878
|
+
def cli_rules(self) -> dict[str, str]:
|
|
879
|
+
"""Rule severities from ``-c rules.X`` and ``--error/--warn/--ignore``, which win over every file."""
|
|
880
|
+
return {**self._overrides.get("rules", {}), **dict(self._rule_severity)}
|
|
881
|
+
|
|
882
|
+
def origin(self, key: str, label: str) -> str:
|
|
883
|
+
"""Where ``key`` of the config ``load`` returned with ``label`` came from, for error messages."""
|
|
884
|
+
return "--config" if key in self._overrides else label
|
|
885
|
+
|
|
886
|
+
def exclusions(self, directory: Path) -> list[tuple[Path, tuple[str, ...]]]:
|
|
887
|
+
"""``(base directory, globs)`` pairs excluding entries of ``directory``; empty sets left out."""
|
|
888
|
+
from sqlty._config import DEFAULT_EXCLUDE, load_config
|
|
889
|
+
|
|
890
|
+
if self._fixed is not None:
|
|
891
|
+
config, base = self._fixed, self._cwd
|
|
892
|
+
else:
|
|
893
|
+
config, config_dir = ({}, None) if self._isolated else load_config(directory)
|
|
894
|
+
# No config: the default globs are names, so the base only matters to ``is_excluded``.
|
|
895
|
+
resolved = directory.resolve()
|
|
896
|
+
base = config_dir or (self._cwd if resolved.is_relative_to(self._cwd) else resolved)
|
|
897
|
+
if self._exclude is None:
|
|
898
|
+
rules = [
|
|
899
|
+
(base, (*config.get("exclude", DEFAULT_EXCLUDE), *config.get("extend_exclude", ())))
|
|
900
|
+
]
|
|
901
|
+
else:
|
|
902
|
+
rules = [(self._cwd, self._exclude), (base, tuple(config.get("extend_exclude", ())))]
|
|
903
|
+
rules.append((self._cwd, self._extend_exclude))
|
|
904
|
+
return [(b, patterns) for b, patterns in rules if patterns]
|
|
905
|
+
|
|
906
|
+
def respects_gitignore(self, directory: Path) -> bool:
|
|
907
|
+
"""Whether walking ``directory`` skips what ``.gitignore`` ignores."""
|
|
908
|
+
from sqlty._config import load_config
|
|
909
|
+
|
|
910
|
+
if self._respect_gitignore is not None:
|
|
911
|
+
return bool(self._respect_gitignore)
|
|
912
|
+
if self._fixed is not None:
|
|
913
|
+
config = self._fixed
|
|
914
|
+
else:
|
|
915
|
+
config = {} if self._isolated else load_config(directory)[0]
|
|
916
|
+
return config.get("respect_gitignore", True)
|
|
917
|
+
|
|
918
|
+
def is_excluded(self, path: Path) -> bool:
|
|
919
|
+
"""Whether ``path`` or one of its parents up to a glob's base directory is excluded."""
|
|
920
|
+
resolved = path.resolve()
|
|
921
|
+
for base, patterns in self.exclusions(resolved.parent):
|
|
922
|
+
if not resolved.is_relative_to(base):
|
|
923
|
+
if _is_excluded_entry(resolved.name, None, patterns):
|
|
924
|
+
return True
|
|
925
|
+
continue
|
|
926
|
+
parts = PurePosixPath(resolved.relative_to(base).as_posix()).parts
|
|
927
|
+
if any(
|
|
928
|
+
_is_excluded_entry(part, PurePosixPath(*parts[:i]), patterns)
|
|
929
|
+
for i, part in enumerate(parts)
|
|
930
|
+
):
|
|
931
|
+
return True
|
|
932
|
+
return False
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
@functools.cache
|
|
936
|
+
def _exclude_matchers(patterns: tuple[str, ...]) -> tuple[re.Pattern[str], re.Pattern[str]]:
|
|
937
|
+
"""Compiled ``(name, relative path)`` matchers for exclude globs; built once per pattern set.
|
|
938
|
+
|
|
939
|
+
A glob without a slash matches a file or directory name at any depth;
|
|
940
|
+
every glob also matches the path relative to the config directory.
|
|
941
|
+
"""
|
|
942
|
+
names: list[str] = []
|
|
943
|
+
paths: list[str] = []
|
|
944
|
+
for pattern in patterns:
|
|
945
|
+
pattern = pattern.strip("/")
|
|
946
|
+
if "/" not in pattern:
|
|
947
|
+
names.append(fnmatch.translate(pattern))
|
|
948
|
+
paths.append(glob.translate(pattern, recursive=True, include_hidden=True))
|
|
949
|
+
never = r"(?!)"
|
|
950
|
+
return (
|
|
951
|
+
re.compile("|".join(names) or never, re.DOTALL),
|
|
952
|
+
re.compile("|".join(paths) or never, re.DOTALL),
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _is_excluded_entry(
|
|
957
|
+
name: str, relative_dir: PurePosixPath | None, patterns: tuple[str, ...]
|
|
958
|
+
) -> bool:
|
|
959
|
+
"""Whether the entry ``name`` of ``relative_dir`` matches an exclude glob.
|
|
960
|
+
|
|
961
|
+
``relative_dir`` is the resolved parent relative to the glob's base directory,
|
|
962
|
+
or ``None`` when it lies outside it (only globs without a slash match then).
|
|
963
|
+
"""
|
|
964
|
+
by_name, by_path = _exclude_matchers(patterns)
|
|
965
|
+
if by_name.match(name):
|
|
966
|
+
return True
|
|
967
|
+
return relative_dir is not None and bool(by_path.match((relative_dir / name).as_posix()))
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _is_excluded(path: Path, base: Path, patterns: tuple[str, ...]) -> bool:
|
|
971
|
+
"""Whether ``path`` matches an exclude glob, relative to ``base`` (``_walk`` batches this per directory)."""
|
|
972
|
+
resolved = path.resolve()
|
|
973
|
+
if not resolved.is_relative_to(base):
|
|
974
|
+
return _is_excluded_entry(resolved.name, None, patterns)
|
|
975
|
+
relative = PurePosixPath(resolved.relative_to(base).as_posix())
|
|
976
|
+
return _is_excluded_entry(relative.name, relative.parent, patterns)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _is_included(path: Path, base: Path, patterns: tuple[str, ...]) -> bool:
|
|
980
|
+
"""Whether ``path`` or a directory holding it matches a glob relative to ``base``, as excludes do."""
|
|
981
|
+
resolved = path.resolve()
|
|
982
|
+
if not resolved.is_relative_to(base):
|
|
983
|
+
return False
|
|
984
|
+
relative = PurePosixPath(resolved.relative_to(base).as_posix())
|
|
985
|
+
return any(
|
|
986
|
+
_is_excluded_entry(entry.name, entry.parent, patterns)
|
|
987
|
+
for entry in [relative, *relative.parents[:-1]]
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _excluded(name: str, rules: list[tuple[PurePosixPath | None, tuple[str, ...]]]) -> bool:
|
|
992
|
+
return any(_is_excluded_entry(name, relative, patterns) for relative, patterns in rules)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _walk(root: Path, source: ConfigSource) -> list[Path]:
|
|
996
|
+
"""The ``.py`` files under ``root``, sorted, without excluded directories and files."""
|
|
997
|
+
found: list[Path] = []
|
|
998
|
+
for directory, dirnames, filenames in os.walk(root):
|
|
999
|
+
current = Path(directory)
|
|
1000
|
+
resolved = current.resolve()
|
|
1001
|
+
rules = [
|
|
1002
|
+
(
|
|
1003
|
+
PurePosixPath(resolved.relative_to(base).as_posix())
|
|
1004
|
+
if resolved.is_relative_to(base)
|
|
1005
|
+
else None,
|
|
1006
|
+
patterns,
|
|
1007
|
+
)
|
|
1008
|
+
for base, patterns in source.exclusions(current)
|
|
1009
|
+
]
|
|
1010
|
+
ignore = source.gitignore if source.respects_gitignore(current) else None
|
|
1011
|
+
dirnames[:] = sorted(
|
|
1012
|
+
d
|
|
1013
|
+
for d in dirnames
|
|
1014
|
+
if not _excluded(d, rules) and not (ignore and ignore.ignored(resolved, d, is_dir=True))
|
|
1015
|
+
)
|
|
1016
|
+
found.extend(
|
|
1017
|
+
current / name
|
|
1018
|
+
for name in sorted(filenames)
|
|
1019
|
+
if name.endswith(".py")
|
|
1020
|
+
and not _excluded(name, rules)
|
|
1021
|
+
and not (ignore and ignore.ignored(resolved, name, is_dir=False))
|
|
1022
|
+
)
|
|
1023
|
+
return sorted(found)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
@functools.cache
|
|
1027
|
+
def _is_package(directory: Path) -> bool:
|
|
1028
|
+
return (directory / "__init__.py").exists()
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
@functools.cache
|
|
1032
|
+
def _package_path(directory: Path) -> tuple[str, ...]:
|
|
1033
|
+
"""Names of ``directory`` and its parents up through the ``__init__.py`` ones."""
|
|
1034
|
+
if directory == directory.parent or not _is_package(directory):
|
|
1035
|
+
return ()
|
|
1036
|
+
return (*_package_path(directory.parent), directory.name)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
@functools.cache
|
|
1040
|
+
def _resolved_root(root: Path) -> Path | None:
|
|
1041
|
+
"""``root`` resolved, or ``None`` when it is not a directory."""
|
|
1042
|
+
root = root.resolve()
|
|
1043
|
+
return root if root.is_dir() else None
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _module_names(path: Path, roots: list[Path]) -> list[str]:
|
|
1047
|
+
"""Dotted names the resolved ``path`` can be imported by, the likeliest first.
|
|
1048
|
+
|
|
1049
|
+
The package path (up through the ``__init__.py`` directories) comes first,
|
|
1050
|
+
then the path from each checked directory, with and without its own name.
|
|
1051
|
+
``pkg/__init__.py`` is ``pkg``.
|
|
1052
|
+
|
|
1053
|
+
Example:
|
|
1054
|
+
>>> _module_names(Path("missing/pkg/mod.py"), [])
|
|
1055
|
+
['mod']
|
|
1056
|
+
"""
|
|
1057
|
+
parts = [*_package_path(path.parent), *([] if path.stem == "__init__" else [path.stem])]
|
|
1058
|
+
names = [".".join(parts)]
|
|
1059
|
+
for root in map(_resolved_root, roots):
|
|
1060
|
+
if root is not None and path.is_relative_to(root):
|
|
1061
|
+
relative = list(path.relative_to(root).with_suffix("").parts)
|
|
1062
|
+
if relative[-1] == "__init__":
|
|
1063
|
+
relative.pop()
|
|
1064
|
+
names += [".".join(relative), ".".join([root.name, *relative])]
|
|
1065
|
+
return list(dict.fromkeys(name for name in names if name)) or [path.stem]
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
@dataclass(frozen=True)
|
|
1069
|
+
class Settings:
|
|
1070
|
+
"""``[tool.sqlty]`` of the nearest ``pyproject.toml``, with CLI flags on top."""
|
|
1071
|
+
|
|
1072
|
+
dialect: str = "postgres"
|
|
1073
|
+
schema: Schema | None = None
|
|
1074
|
+
import_style: str | None = None
|
|
1075
|
+
untyped: bool = False
|
|
1076
|
+
json_types: str = "typing.Any"
|
|
1077
|
+
rules: dict[str, str] | None = None
|
|
1078
|
+
type_modules: tuple[str, ...] = ()
|
|
1079
|
+
functions: tuple[tuple[str, str, str | None], ...] = ()
|
|
1080
|
+
format_command: tuple[str, ...] = ()
|
|
1081
|
+
overrides: tuple[RuleOverride, ...] = ()
|
|
1082
|
+
config_dir: Path | None = None
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
class SettingsResolver:
|
|
1086
|
+
"""Resolve settings per file, so each project directory uses its own config.
|
|
1087
|
+
|
|
1088
|
+
Example:
|
|
1089
|
+
>>> import argparse
|
|
1090
|
+
>>> args = argparse.Namespace(dialect="sqlite", schema=None, untyped=None)
|
|
1091
|
+
>>> SettingsResolver(args).for_file(Path("missing/dir/q.py")).dialect
|
|
1092
|
+
'sqlite'
|
|
1093
|
+
"""
|
|
1094
|
+
|
|
1095
|
+
def __init__(self, args: argparse.Namespace) -> None:
|
|
1096
|
+
self._args = args
|
|
1097
|
+
self.source = ConfigSource.from_args(args)
|
|
1098
|
+
schema = args.schema or self.source.schema
|
|
1099
|
+
self._schema_sql = _load_schema(schema, Path.cwd()) if schema else None
|
|
1100
|
+
self._by_dir: dict[Path, Settings] = {}
|
|
1101
|
+
self._resolved: dict[Path, Path] = {}
|
|
1102
|
+
self._schemas: dict[tuple[str, str, tuple[tuple[str, str], ...]], Schema] = {}
|
|
1103
|
+
|
|
1104
|
+
def _schema(self, schema_sql: str, dialect: str, types: dict[str, str] | None = None) -> Schema:
|
|
1105
|
+
"""``schema_sql`` parsed once per run, warning about the objects it could not parse."""
|
|
1106
|
+
from sqlty._config import ConfigError
|
|
1107
|
+
from sqlty.core import load_schema, schema_skipped_objects
|
|
1108
|
+
|
|
1109
|
+
key = (schema_sql, dialect, tuple(sorted((types or {}).items())))
|
|
1110
|
+
if (schema := self._schemas.get(key)) is None:
|
|
1111
|
+
try:
|
|
1112
|
+
schema = self._schemas[key] = load_schema(schema_sql, dialect=dialect, types=types)
|
|
1113
|
+
except ValueError as exc:
|
|
1114
|
+
raise ConfigError(f"[tool.sqlty.types]: {exc}") from None
|
|
1115
|
+
for kind, name in schema_skipped_objects(schema, dialect=dialect):
|
|
1116
|
+
logger.warning(
|
|
1117
|
+
"Schema %s %s could not be parsed; its columns are unknown", kind, name
|
|
1118
|
+
)
|
|
1119
|
+
return schema
|
|
1120
|
+
|
|
1121
|
+
def resolve(self, path: Path) -> Path:
|
|
1122
|
+
"""``path`` resolved; each directory is resolved once, as its files share it."""
|
|
1123
|
+
if path.is_symlink():
|
|
1124
|
+
return path.resolve()
|
|
1125
|
+
return self._directory(path.parent) / path.name
|
|
1126
|
+
|
|
1127
|
+
def _directory(self, directory: Path) -> Path:
|
|
1128
|
+
if (resolved := self._resolved.get(directory)) is None:
|
|
1129
|
+
resolved = self._resolved[directory] = directory.resolve()
|
|
1130
|
+
return resolved
|
|
1131
|
+
|
|
1132
|
+
def for_file(self, path: Path) -> Settings:
|
|
1133
|
+
directory = self._directory(path.parent)
|
|
1134
|
+
if directory not in self._by_dir:
|
|
1135
|
+
self._by_dir[directory] = self._resolve(directory)
|
|
1136
|
+
settings = self._by_dir[directory]
|
|
1137
|
+
if not settings.overrides or settings.config_dir is None:
|
|
1138
|
+
return settings
|
|
1139
|
+
rules = dict(settings.rules or {})
|
|
1140
|
+
for override in settings.overrides:
|
|
1141
|
+
include, exclude = tuple(override["include"]), tuple(override["exclude"])
|
|
1142
|
+
if (not include or _is_included(path, settings.config_dir, include)) and not (
|
|
1143
|
+
exclude and _is_included(path, settings.config_dir, exclude)
|
|
1144
|
+
):
|
|
1145
|
+
rules |= override["rules"]
|
|
1146
|
+
return replace(settings, rules=rules | self.source.cli_rules)
|
|
1147
|
+
|
|
1148
|
+
def _resolve(self, directory: Path) -> Settings:
|
|
1149
|
+
from sqlty._config import ConfigError
|
|
1150
|
+
from sqlty.core import strip_source
|
|
1151
|
+
|
|
1152
|
+
config, config_dir, label = self.source.load(directory)
|
|
1153
|
+
args = self._args
|
|
1154
|
+
schema_sql = self._schema_sql
|
|
1155
|
+
if schema_sql is None and "schema" in config and config_dir is not None:
|
|
1156
|
+
schema_sql = _load_schema(config["schema"], config_dir)
|
|
1157
|
+
untyped = getattr(args, "untyped", None) or config.get("untyped", "skip")
|
|
1158
|
+
if untyped not in ("skip", "sentinel"):
|
|
1159
|
+
raise ConfigError(
|
|
1160
|
+
f"{self.source.origin('untyped', label)}: untyped must be 'skip' or 'sentinel'"
|
|
1161
|
+
)
|
|
1162
|
+
json_types = config.get("json_types", "typing.Any")
|
|
1163
|
+
if json_types not in ("typing.Any", "typing.TypedDict"):
|
|
1164
|
+
raise ConfigError(
|
|
1165
|
+
f"{self.source.origin('json_types', label)}: json-types must be 'typing.Any' or 'typing.TypedDict'"
|
|
1166
|
+
)
|
|
1167
|
+
import_style = config.get("import_style")
|
|
1168
|
+
try:
|
|
1169
|
+
strip_source("", import_style=import_style)
|
|
1170
|
+
except ValueError as exc:
|
|
1171
|
+
raise ConfigError(
|
|
1172
|
+
f"{self.source.origin('import_style', label)}: {exc}; "
|
|
1173
|
+
"use 'from sqlty import SQL [as Q]' or 'import sqlty [as sy]'"
|
|
1174
|
+
) from None
|
|
1175
|
+
dialect = args.dialect or config.get("dialect", "postgres")
|
|
1176
|
+
return Settings(
|
|
1177
|
+
dialect=dialect,
|
|
1178
|
+
schema=None
|
|
1179
|
+
if schema_sql is None
|
|
1180
|
+
else self._schema(schema_sql, dialect, config.get("types")),
|
|
1181
|
+
import_style=import_style,
|
|
1182
|
+
untyped=untyped == "sentinel",
|
|
1183
|
+
json_types=json_types,
|
|
1184
|
+
rules=config.get("rules"),
|
|
1185
|
+
type_modules=tuple(config.get("type_modules", ())),
|
|
1186
|
+
functions=tuple(
|
|
1187
|
+
(f["import_path"], f["query_param"], f.get("return_type_param"))
|
|
1188
|
+
for f in config.get("functions", [])
|
|
1189
|
+
),
|
|
1190
|
+
format_command=tuple(config.get("format_command", ())),
|
|
1191
|
+
overrides=tuple(config.get("overrides", ())),
|
|
1192
|
+
config_dir=config_dir,
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _load_schema(spec: SchemaSpec, base: Path) -> str | None:
|
|
1197
|
+
"""Concatenate the schema files named by ``spec``; None if none can be read."""
|
|
1198
|
+
from sqlty._config import expand_schema
|
|
1199
|
+
|
|
1200
|
+
parts: list[str] = []
|
|
1201
|
+
for path in expand_schema(spec, base):
|
|
1202
|
+
if path.is_file():
|
|
1203
|
+
parts.append(path.read_text(encoding="utf-8"))
|
|
1204
|
+
else:
|
|
1205
|
+
logger.warning("Schema file not found: %s", path)
|
|
1206
|
+
return "\n".join(parts) if parts else None
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def _print_diff(path: Path, original: str, modified: str) -> None:
|
|
1210
|
+
"""Print unified diff between original and modified."""
|
|
1211
|
+
import difflib
|
|
1212
|
+
|
|
1213
|
+
diff = difflib.unified_diff(
|
|
1214
|
+
original.splitlines(keepends=True),
|
|
1215
|
+
modified.splitlines(keepends=True),
|
|
1216
|
+
fromfile=str(path),
|
|
1217
|
+
tofile=str(path),
|
|
1218
|
+
)
|
|
1219
|
+
sys.stdout.writelines(diff)
|