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.
Files changed (71) hide show
  1. sqlty/__init__.py +99 -0
  2. sqlty/__main__.py +5 -0
  3. sqlty/_breaking.py +191 -0
  4. sqlty/_check.py +880 -0
  5. sqlty/_cli.py +1219 -0
  6. sqlty/_config.py +666 -0
  7. sqlty/_core.cp315-win_amd64.pyd +0 -0
  8. sqlty/_core.pyi +108 -0
  9. sqlty/_decode.py +62 -0
  10. sqlty/_drivers.py +274 -0
  11. sqlty/_gitignore.py +58 -0
  12. sqlty/_helpers.py +613 -0
  13. sqlty/_json_shape.py +299 -0
  14. sqlty/_json_types.py +225 -0
  15. sqlty/_lint.py +350 -0
  16. sqlty/_params.py +177 -0
  17. sqlty/_psycopg.py +269 -0
  18. sqlty/_source.py +654 -0
  19. sqlty/_type_extractor/__init__.py +51 -0
  20. sqlty/_type_extractor/ast_fields.py +823 -0
  21. sqlty/_type_extractor/compat.py +252 -0
  22. sqlty/_type_extractor/extract.py +179 -0
  23. sqlty/_type_extractor/generic.py +95 -0
  24. sqlty/_type_extractor/info.py +161 -0
  25. sqlty/_type_extractor/sandbox.py +385 -0
  26. sqlty/_type_extractor/subtype.py +156 -0
  27. sqlty/_widening.py +89 -0
  28. sqlty/core.py +566 -0
  29. sqlty/drivers/psycopg.py +104 -0
  30. sqlty/magic.py +126 -0
  31. sqlty/py.typed +0 -0
  32. sqlty/rules/ambiguous-column.md +48 -0
  33. sqlty/rules/column-count-mismatch.md +30 -0
  34. sqlty/rules/column-name-mismatch.md +34 -0
  35. sqlty/rules/column-type-mismatch.md +75 -0
  36. sqlty/rules/fstring-skipped.md +23 -0
  37. sqlty/rules/inline-sql-comment.md +27 -0
  38. sqlty/rules/insert-missing-column.md +58 -0
  39. sqlty/rules/json-shape-mismatch.md +96 -0
  40. sqlty/rules/literal-mismatch.md +83 -0
  41. sqlty/rules/mysql-sql-marker.md +32 -0
  42. sqlty/rules/nullable-mismatch.md +76 -0
  43. sqlty/rules/param-count-mismatch.md +66 -0
  44. sqlty/rules/param-type-mismatch.md +73 -0
  45. sqlty/rules/redundant-literal.md +68 -0
  46. sqlty/rules/redundant-optional.md +100 -0
  47. sqlty/rules/select-star-no-schema.md +24 -0
  48. sqlty/rules/select-star.md +77 -0
  49. sqlty/rules/stale-rewrite.md +33 -0
  50. sqlty/rules/stale-type.md +50 -0
  51. sqlty/rules/tstring-skipped.md +68 -0
  52. sqlty/rules/typeddict-inheritance.md +35 -0
  53. sqlty/rules/typeddict-key-mismatch.md +38 -0
  54. sqlty/rules/unchecked-query.md +34 -0
  55. sqlty/rules/ungrouped-column.md +67 -0
  56. sqlty/rules/unknown-column.md +68 -0
  57. sqlty/rules/unknown-function.md +49 -0
  58. sqlty/rules/unknown-table.md +47 -0
  59. sqlty/rules/unknown-type.md +74 -0
  60. sqlty/rules/unparsed-query.md +46 -0
  61. sqlty/rules/unsupported-syntax.md +69 -0
  62. sqlty/rules/untyped-query.md +39 -0
  63. sqlty/rules/unused-ignore.md +36 -0
  64. sqlty/skill/SKILL.md +110 -0
  65. sqlty-0.1.0.dist-info/METADATA +210 -0
  66. sqlty-0.1.0.dist-info/RECORD +71 -0
  67. sqlty-0.1.0.dist-info/WHEEL +4 -0
  68. sqlty-0.1.0.dist-info/entry_points.txt +2 -0
  69. sqlty-0.1.0.dist-info/licenses/LICENSE +21 -0
  70. sqlty-0.1.0.dist-info/sboms/sqlty-core.cyclonedx.json +1891 -0
  71. 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)