sqlty 0.6.0__cp315-abi3.abi3t-win_arm64.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 (91) hide show
  1. sqlty/__init__.py +132 -0
  2. sqlty/__main__.py +5 -0
  3. sqlty/_breaking.py +482 -0
  4. sqlty/_check.py +1554 -0
  5. sqlty/_cli.py +1831 -0
  6. sqlty/_config.py +685 -0
  7. sqlty/_core.pyd +4 -0
  8. sqlty/_core.pyi +377 -0
  9. sqlty/_decode.py +62 -0
  10. sqlty/_drivers.py +515 -0
  11. sqlty/_gitignore.py +58 -0
  12. sqlty/_helpers.py +1095 -0
  13. sqlty/_json_shape.py +299 -0
  14. sqlty/_json_types.py +216 -0
  15. sqlty/_lint.py +441 -0
  16. sqlty/_lsp_breaking.py +151 -0
  17. sqlty/_lsp_bridge.py +136 -0
  18. sqlty/_params.py +228 -0
  19. sqlty/_psycopg.py +304 -0
  20. sqlty/_queries.py +125 -0
  21. sqlty/_sarif.py +74 -0
  22. sqlty/_snapshot.py +187 -0
  23. sqlty/_source.py +941 -0
  24. sqlty/_type_extractor/__init__.py +46 -0
  25. sqlty/_type_extractor/_stdlib.py +2722 -0
  26. sqlty/_type_extractor/ast_fields.py +993 -0
  27. sqlty/_type_extractor/compat.py +245 -0
  28. sqlty/_type_extractor/extract.py +233 -0
  29. sqlty/_type_extractor/generic.py +75 -0
  30. sqlty/_type_extractor/info.py +156 -0
  31. sqlty/_type_extractor/pydantic_types.py +133 -0
  32. sqlty/_type_extractor/stdlib.py +46 -0
  33. sqlty/_type_extractor/subtype.py +161 -0
  34. sqlty/_type_extractor/symbols.py +87 -0
  35. sqlty/_variants.py +941 -0
  36. sqlty/core.py +1020 -0
  37. sqlty/drivers/psycopg.py +150 -0
  38. sqlty/lexer.py +74 -0
  39. sqlty/magic.py +126 -0
  40. sqlty/py.typed +0 -0
  41. sqlty/rules/ambiguous-column.md +48 -0
  42. sqlty/rules/chasm-join.md +76 -0
  43. sqlty/rules/column-count-mismatch.md +51 -0
  44. sqlty/rules/column-name-mismatch.md +34 -0
  45. sqlty/rules/column-type-mismatch.md +75 -0
  46. sqlty/rules/duplicate-alias.md +52 -0
  47. sqlty/rules/dynamic-query.md +83 -0
  48. sqlty/rules/entity-mismatch.md +66 -0
  49. sqlty/rules/extra-param.md +53 -0
  50. sqlty/rules/fanout-aggregate.md +103 -0
  51. sqlty/rules/fstring-skipped.md +23 -0
  52. sqlty/rules/inline-sql-comment.md +27 -0
  53. sqlty/rules/insert-missing-column.md +63 -0
  54. sqlty/rules/insert-value-count.md +58 -0
  55. sqlty/rules/json-shape-mismatch.md +96 -0
  56. sqlty/rules/literal-mismatch.md +83 -0
  57. sqlty/rules/mysql-sql-marker.md +32 -0
  58. sqlty/rules/narrow-type.md +50 -0
  59. sqlty/rules/nullable-mismatch.md +76 -0
  60. sqlty/rules/param-count-mismatch.md +70 -0
  61. sqlty/rules/param-type-mismatch.md +84 -0
  62. sqlty/rules/params-type-mismatch.md +65 -0
  63. sqlty/rules/redundant-literal.md +68 -0
  64. sqlty/rules/redundant-optional.md +100 -0
  65. sqlty/rules/select-star-no-schema.md +24 -0
  66. sqlty/rules/select-star.md +78 -0
  67. sqlty/rules/sqruff.md +56 -0
  68. sqlty/rules/stale-rewrite.md +58 -0
  69. sqlty/rules/stale-type.md +56 -0
  70. sqlty/rules/tstring-skipped.md +68 -0
  71. sqlty/rules/typeddict-inheritance.md +35 -0
  72. sqlty/rules/typeddict-key-mismatch.md +38 -0
  73. sqlty/rules/unchecked-query.md +39 -0
  74. sqlty/rules/ungrouped-column.md +70 -0
  75. sqlty/rules/unknown-column.md +100 -0
  76. sqlty/rules/unknown-database.md +30 -0
  77. sqlty/rules/unknown-function.md +49 -0
  78. sqlty/rules/unknown-table.md +47 -0
  79. sqlty/rules/unknown-type.md +74 -0
  80. sqlty/rules/unparsed-query.md +48 -0
  81. sqlty/rules/unproven-join.md +55 -0
  82. sqlty/rules/unsupported-syntax.md +69 -0
  83. sqlty/rules/untyped-query.md +39 -0
  84. sqlty/rules/unused-ignore.md +36 -0
  85. sqlty-0.6.0.dist-info/METADATA +224 -0
  86. sqlty-0.6.0.dist-info/RECORD +91 -0
  87. sqlty-0.6.0.dist-info/WHEEL +5 -0
  88. sqlty-0.6.0.dist-info/entry_points.txt +4 -0
  89. sqlty-0.6.0.dist-info/licenses/LICENSE +21 -0
  90. sqlty-0.6.0.dist-info/sboms/sqlty-core.cyclonedx.json +6441 -0
  91. sqlty_magic.pth +1 -0
sqlty/__init__.py ADDED
@@ -0,0 +1,132 @@
1
+ """SQLTy - Type-safe SQL queries for Python."""
2
+
3
+ import sys
4
+ from string.templatelib import Template
5
+ from typing import TYPE_CHECKING, Any, NamedTuple, get_args, overload
6
+
7
+ if sys.version_info >= (3, 15):
8
+ from typing import TypedDict, TypeForm
9
+ else:
10
+ from typing_extensions import Sentinel as sentinel
11
+ from typing_extensions import TypedDict, TypeForm
12
+
13
+
14
+ def __getattr__(name: str) -> str:
15
+ # ``__version__`` is looked up lazily: importlib.metadata costs ~10 ms to import.
16
+ if name == "__version__":
17
+ import importlib.metadata
18
+
19
+ try:
20
+ version = importlib.metadata.version(__name__)
21
+ except importlib.metadata.PackageNotFoundError:
22
+ version = "0.0.0" # Fallback for development mode
23
+ globals()["__version__"] = version
24
+ return version
25
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
26
+
27
+
28
+ __all__ = ["SQL", "UNTYPED", "Bound", "NoParams", "SQLTemplate", "row_form"]
29
+
30
+
31
+ class SQL[R, P = Any](str):
32
+ """A query whose rows are ``R`` and whose parameters are ``P``.
33
+
34
+ ``P`` defaults to ``Any``, so ``SQL[R]`` takes any parameters. A query is typed like a
35
+ function from ``P`` to ``R``: ``SQL[R1, P1]`` fits ``SQL[R2, P2]`` when ``R1`` fits ``R2``
36
+ and ``P2`` fits ``P1``. Positional parameters are a ``tuple``, named ones a ``TypedDict``.
37
+
38
+ Example:
39
+ >>> query = SQL[tuple[str], tuple[int]]("--sql\\nSELECT name::TEXT FROM users WHERE id = %s")
40
+ >>> query.bind((1,)).params
41
+ (1,)
42
+ """
43
+
44
+ if TYPE_CHECKING:
45
+
46
+ def __sqlty_row__(self) -> R: ...
47
+
48
+ def bind(self, params: P, /) -> Bound[R]:
49
+ """The query with ``params``, checked against ``P``: ``cursor.execute(*bound)`` runs it."""
50
+ return Bound(self, params)
51
+
52
+
53
+ class _NoKeys(TypedDict, closed=True):
54
+ pass
55
+
56
+
57
+ type NoParams = tuple[()] | _NoKeys
58
+ """Parameters of a query without placeholders, ``SQL[R, NoParams]``: ``()`` or ``{}``."""
59
+
60
+
61
+ class Bound[R](NamedTuple):
62
+ """A query and its parameters, from ``SQL.bind``."""
63
+
64
+ query: SQL[R]
65
+ params: Any
66
+
67
+
68
+ class SQLTemplate[T]:
69
+ """A t-string query, ``SQLTemplate[T](t"--sql\\n...")``, whose rows are ``T``.
70
+
71
+ ``string.templatelib.Template`` cannot be subclassed, so the template is kept in
72
+ ``template``: pass that to a driver that runs templates (psycopg >= 3.3), or the
73
+ whole query to the ``sqlty.drivers.psycopg`` helpers. ``row_form`` reads ``T``.
74
+
75
+ Example:
76
+ >>> user_id = 1
77
+ >>> query = SQLTemplate[tuple[int]](t"--sql\\nSELECT id::INTEGER FROM users WHERE id = {user_id}")
78
+ >>> query.template.values
79
+ (1,)
80
+ >>> row_form(query)
81
+ tuple[int]
82
+ """
83
+
84
+ __slots__ = ("__orig_class__", "template")
85
+
86
+ def __init__(self, template: Template, /) -> None:
87
+ self.template = template
88
+
89
+ def __repr__(self) -> str:
90
+ return f"{type(self).__name__}({self.template!r})"
91
+
92
+
93
+ UNTYPED = sentinel("UNTYPED")
94
+ """Row type of a query sqlty could not infer: ``SQL[UNTYPED]``.
95
+
96
+ Unlike ``Any``, type checkers reject using an ``UNTYPED`` row as a concrete
97
+ type. ``sqlty rewrite --untyped sentinel`` emits it; ``sqlty check`` flags it
98
+ (``untyped-query``) until casts or a schema make the row type known.
99
+ """
100
+
101
+
102
+ _MISSING: object = object()
103
+
104
+
105
+ @overload
106
+ def row_form[T](query: SQL[T] | SQLTemplate[T], /) -> TypeForm[T]: ...
107
+
108
+
109
+ @overload
110
+ def row_form[T, D](query: SQL[T] | SQLTemplate[T], default: D, /) -> TypeForm[T] | D: ...
111
+
112
+
113
+ def row_form(query: object, default: object = _MISSING, /) -> object:
114
+ """Return the row type ``T`` a ``SQL[T](...)`` instance was built with.
115
+
116
+ The type is read from ``__orig_class__``, so it exists only for queries
117
+ constructed as ``SQL[T](...)``. Plain strings, annotation-only
118
+ ``q: SQL[T] = "..."`` and stripped sources carry no runtime type: these
119
+ raise ``TypeError`` unless a ``default`` is given.
120
+
121
+ Example:
122
+ >>> row_form(SQL[tuple[int, str]]("--sql\\nSELECT 1, 'a'"))
123
+ tuple[int, str]
124
+ >>> row_form(SQL("--sql\\nSELECT 1"), None) is None
125
+ True
126
+ """
127
+ orig = getattr(query, "__orig_class__", None)
128
+ if orig is not None and (args := get_args(orig)):
129
+ return args[0]
130
+ if default is not _MISSING:
131
+ return default
132
+ raise TypeError(f"{type(query).__name__} has no runtime row type; construct it as SQL[T](...)")
sqlty/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enable running sqlty as a module: python -m sqlty"""
2
+
3
+ from sqlty._cli import main
4
+
5
+ main()
sqlty/_breaking.py ADDED
@@ -0,0 +1,482 @@
1
+ """``sqlty breaking``: the queries and schema of two revisions, checked across.
2
+
3
+ During a rolling deploy the code of one revision runs against the schema of another.
4
+ ``--against`` names the baseline ``A``; ``--head`` (the working tree by default) names
5
+ ``B``. The backward check reads the queries of ``A`` against the schema of ``B`` (migrate
6
+ first), the forward check the queries of ``B`` against the schema of ``A`` (deploy
7
+ first). Each reports only what the other schema breaks: diagnostics a query does not
8
+ already get against its own schema.
9
+
10
+ Every revision but the working tree is checked out with ``git worktree add --detach``
11
+ into a temporary directory, removed afterwards, so the working tree is never touched.
12
+ Only the analyzed paths, the files of the directories above them and the schema are
13
+ checked out, unless a configuration reads more (see ``_checkout``).
14
+ A baseline can also be a snapshot ``--save`` wrote, which needs no git history, or a
15
+ schema file, which has no queries and so is only checked forward.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import glob
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ from collections.abc import Generator, Iterable
28
+ from dataclasses import dataclass, field, replace
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING, Literal, NamedTuple, Protocol
31
+
32
+ if TYPE_CHECKING:
33
+ import argparse
34
+
35
+ from sqlty._cli import Settings, SettingsResolver
36
+ from sqlty._helpers import HelperIndex
37
+ from sqlty._lint import Diagnostic
38
+ from sqlty._snapshot import Snapshot
39
+ from sqlty._source import ParsedSource
40
+ from sqlty.core import Schema
41
+
42
+ _Work = tuple[Path, ParsedSource, Settings, Schema | None, HelperIndex]
43
+
44
+ Direction = Literal["backward", "forward", "both"]
45
+ SchemaKey = tuple[str | None, str, tuple[tuple[str, str], ...]]
46
+
47
+
48
+ class GitError(Exception):
49
+ """A git command failed: not a repository, or an unknown revision."""
50
+
51
+
52
+ class Source(NamedTuple):
53
+ """A Python file of a revision, with the module names its helpers are imported by, or a
54
+ ``.sql`` file of named queries, which has none."""
55
+
56
+ text: str
57
+ module: str
58
+ names: tuple[str, ...]
59
+
60
+
61
+ class Queries(Protocol):
62
+ """A revision whose Python files are checked: a git revision or a snapshot."""
63
+
64
+ label: str
65
+
66
+ def sources(self) -> dict[Path, Source]: ...
67
+
68
+ def settings(self, relative: Path) -> Settings: ...
69
+
70
+ def roots(self) -> list[Path]: ...
71
+
72
+
73
+ class Schemas(Protocol):
74
+ """A revision whose schema the other revision's queries are checked against."""
75
+
76
+ label: str
77
+
78
+ def schema(self, relative: Path, own: Settings) -> Schema | None: ...
79
+
80
+ def schema_key(self, relative: Path, own: Settings) -> SchemaKey: ...
81
+
82
+
83
+ @dataclass
84
+ class Revision:
85
+ """A checkout of the project at one revision, read as ``sqlty check`` reads it."""
86
+
87
+ label: str
88
+ root: Path
89
+ resolver: SettingsResolver
90
+ files: list[Path]
91
+ paths: list[Path]
92
+
93
+ def settings(self, relative: Path) -> Settings:
94
+ return self.resolver.for_file(self.root / relative)
95
+
96
+ def schema(self, relative: Path, own: Settings) -> Schema | None:
97
+ return self.settings(relative).schema
98
+
99
+ def schema_key(self, relative: Path, own: Settings) -> SchemaKey:
100
+ return self.settings(relative).schema_key
101
+
102
+ def roots(self) -> list[Path]:
103
+ from sqlty._cli import _checked_roots
104
+
105
+ return _checked_roots([self.root / p for p in self.paths], self.files)
106
+
107
+ def sources(self) -> dict[Path, Source]:
108
+ from sqlty._cli import _module_names, read_source
109
+
110
+ roots = [self.root / p for p in self.paths]
111
+ sources: dict[Path, Source] = {}
112
+ for path in self.files:
113
+ if (text := read_source(path)) is None:
114
+ continue
115
+ if path.suffix == ".sql":
116
+ sources[path.relative_to(self.root)] = Source(text, "", ())
117
+ continue
118
+ names = _module_names(self.resolver.resolve(path), roots)
119
+ module = names[0] + (".__init__" if path.name == "__init__.py" else "")
120
+ sources[path.relative_to(self.root)] = Source(text, module, tuple(names))
121
+ return sources
122
+
123
+
124
+ @dataclass
125
+ class SchemaFiles:
126
+ """A schema without queries, such as a dump or migrations: a baseline only forward."""
127
+
128
+ label: str
129
+ sql: str
130
+ loaded: dict[tuple[str, tuple[tuple[str, str], ...]], Schema] = field(default_factory=dict)
131
+
132
+ def schema(self, relative: Path, own: Settings) -> Schema:
133
+ from sqlty.core import load_schema
134
+
135
+ key = (own.dialect, tuple(sorted((own.types or {}).items())))
136
+ if key not in self.loaded:
137
+ self.loaded[key] = load_schema(self.sql, dialect=own.dialect, types=own.types)
138
+ return self.loaded[key]
139
+
140
+ def schema_key(self, relative: Path, own: Settings) -> SchemaKey:
141
+ return replace(own, schema_sql=self.sql).schema_key
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class Finding:
146
+ direction: Literal["backward", "forward"]
147
+ against: str
148
+ path: Path
149
+ diagnostic: Diagnostic
150
+ source: ParsedSource
151
+
152
+
153
+ def run(args: argparse.Namespace) -> list[Finding]:
154
+ """The findings of ``sqlty breaking``, for the paths and revisions ``args`` name; with
155
+ ``--save``, the snapshot of ``--head`` is written first."""
156
+ from sqlty._snapshot import save
157
+
158
+ root = _root(args)
159
+ jobs = os.process_cpu_count() or 1
160
+ paths = [Path(p).resolve().relative_to(root) for p in (args.paths or [Path.cwd()])]
161
+ with contextlib.ExitStack() as stack:
162
+ baselines = _baselines(args.against, root, paths, stack) if args.against else []
163
+ head_root = stack.enter_context(_worktree(root, args.head, paths)) if args.head else root
164
+ head = _revision(args.head or "the working tree", head_root, paths)
165
+ if args.save:
166
+ save(head, args.save)
167
+ findings: list[Finding] = []
168
+ for baseline in baselines:
169
+ if args.direction in ("backward", "both"):
170
+ if isinstance(baseline, SchemaFiles):
171
+ print(
172
+ f"sqlty: {baseline.label} has no queries; skipping the backward check",
173
+ file=sys.stderr,
174
+ )
175
+ else:
176
+ findings += _compare(
177
+ "backward", baseline.label, queries=baseline, schema=head, jobs=jobs
178
+ )
179
+ if args.direction in ("forward", "both"):
180
+ findings += _compare(
181
+ "forward", baseline.label, queries=head, schema=baseline, jobs=jobs
182
+ )
183
+ return findings
184
+
185
+
186
+ def report(
187
+ findings: list[Finding],
188
+ head: str | None,
189
+ quiet: bool,
190
+ output_format: Literal["text", "json", "github", "sarif"] = "text",
191
+ ) -> None:
192
+ """Print each finding: ``path:line: [rule] message (direction)``, a JSON array with a
193
+ ``direction`` and ``against`` per finding, GitHub Actions annotations at each
194
+ finding's severity, or a SARIF log."""
195
+ import json
196
+
197
+ from sqlty._cli import github_annotation, json_diagnostic
198
+ from sqlty._sarif import sarif_log
199
+
200
+ if quiet:
201
+ return
202
+ head = head or "the working tree"
203
+
204
+ def message(finding: Finding) -> str:
205
+ if finding.direction == "backward":
206
+ title = f"the queries of {finding.against} against the schema of {head}"
207
+ else:
208
+ title = f"the queries of {head} against the schema of {finding.against}"
209
+ return f"{finding.diagnostic['message']} ({finding.direction}: {title})"
210
+
211
+ if output_format in ("json", "sarif"):
212
+ rows = [
213
+ {
214
+ "file": str(f.path),
215
+ **json_diagnostic(f.diagnostic, f.source),
216
+ **({"message": message(f)} if output_format == "sarif" else {}),
217
+ "direction": f.direction,
218
+ "against": f.against,
219
+ }
220
+ for f in findings
221
+ ]
222
+ print(json.dumps(rows if output_format == "json" else sarif_log(rows), indent=2))
223
+ return
224
+ for finding in findings:
225
+ d = finding.diagnostic
226
+ if output_format == "github":
227
+ print(github_annotation(finding.path, {**d, "message": message(finding)}))
228
+ else:
229
+ print(f"{finding.path}:{d.get('line', '')}: [{d['rule']}] {message(finding)}")
230
+ if findings and output_format == "text":
231
+ print(f"Found {len(findings)} breaking change(s)")
232
+
233
+
234
+ def _compare(
235
+ direction: Literal["backward", "forward"],
236
+ against: str,
237
+ *,
238
+ queries: Queries,
239
+ schema: Schemas,
240
+ jobs: int = 1,
241
+ ) -> list[Finding]:
242
+ """What ``schema`` breaks in ``queries``; up to ``jobs`` processes check the files."""
243
+ from sqlty._helpers import configured_helpers
244
+ from sqlty._source import ParsedSource
245
+ from sqlty.core import HelperIndex
246
+
247
+ sources: dict[Path, ParsedSource] = {}
248
+ index = HelperIndex()
249
+ for relative, source in queries.sources().items():
250
+ sources[relative] = ParsedSource(source.text, source.module or None)
251
+ for name in source.names:
252
+ index.add(name, sources[relative])
253
+ helpers_for = configured_helpers(index, queries.roots())
254
+ work: list[_Work] = []
255
+ for relative, parsed in sources.items():
256
+ own = queries.settings(relative)
257
+ if schema.schema_key(relative, own) != own.schema_key:
258
+ work.append((relative, parsed, own, schema.schema(relative, own), helpers_for(own)))
259
+ return [
260
+ Finding(direction, against, relative, diagnostic, parsed)
261
+ for (relative, parsed, *_), found in zip(work, _map(work, jobs), strict=True)
262
+ for diagnostic in found
263
+ ]
264
+
265
+
266
+ _shared: list[_Work] = []
267
+
268
+
269
+ def _map(work: list[_Work], jobs: int) -> list[list[Diagnostic]]:
270
+ """The diagnostics of each item of ``work``, checked by forked processes that inherit it.
271
+
272
+ The checks are mostly Python, so threads would wait on the GIL. Without ``fork`` (Windows),
273
+ or for one job, they run here.
274
+ """
275
+ import multiprocessing
276
+ from concurrent.futures import ProcessPoolExecutor
277
+
278
+ jobs = min(jobs, len(work))
279
+ if jobs <= 1 or "fork" not in multiprocessing.get_all_start_methods():
280
+ return [_check(item) for item in work]
281
+ _shared[:] = work
282
+ try:
283
+ context = multiprocessing.get_context("fork")
284
+ size = max(1, min(64, len(work) // (jobs * 4)))
285
+ chunks = [range(i, min(i + size, len(work))) for i in range(0, len(work), size)]
286
+ with ProcessPoolExecutor(jobs, mp_context=context) as pool:
287
+ return [found for chunk in pool.map(_check_shared, chunks) for found in chunk]
288
+ finally:
289
+ _shared.clear()
290
+
291
+
292
+ def _check_shared(chunk: range) -> list[list[Diagnostic]]:
293
+ return [_check(_shared[i]) for i in chunk]
294
+
295
+
296
+ def _check(item: _Work) -> list[Diagnostic]:
297
+ from sqlty._queries import breaking_query_file
298
+ from sqlty.core import breaking_source
299
+
300
+ relative, parsed, own, against, helpers = item
301
+ if relative.suffix == ".sql":
302
+ return breaking_query_file(
303
+ parsed.text,
304
+ name=relative.stem,
305
+ query_format=own.query_format,
306
+ dialect=own.dialect,
307
+ schema_sql=own.schema,
308
+ against_sql=against,
309
+ rules=own.rules,
310
+ )
311
+ return breaking_source(
312
+ parsed,
313
+ schema_sql=own.schema,
314
+ against_sql=against,
315
+ dialect=own.dialect,
316
+ rules=own.rules,
317
+ import_style=own.import_style,
318
+ untyped=own.untyped,
319
+ query_variants=own.query_variants,
320
+ helpers=helpers,
321
+ coerce_row_types=own.coerce_row_types,
322
+ )
323
+
324
+
325
+ def _baselines(
326
+ against: str, root: Path, paths: list[Path], stack: contextlib.ExitStack
327
+ ) -> list[Revision | Snapshot | SchemaFiles]:
328
+ """What ``--against`` names: a git revision when no such path exists, else a snapshot,
329
+ each snapshot of a directory, or schema files."""
330
+ from sqlty._cli import _load_schema
331
+ from sqlty._config import ConfigError
332
+ from sqlty._snapshot import Snapshot
333
+
334
+ path = Path(against)
335
+ if not path.exists():
336
+ return [_revision(against, stack.enter_context(_worktree(root, against, paths)), paths)]
337
+ if path.suffix == ".json" and path.is_file():
338
+ return [Snapshot.load(path, against, paths)]
339
+ if path.is_dir() and (snapshots := sorted(path.glob("*.json"))):
340
+ return [Snapshot.load(p, p.stem, paths) for p in snapshots]
341
+ if (sql := _load_schema(str(path), Path.cwd())) is None:
342
+ raise ConfigError(f"{against} has no schema .sql files")
343
+ return [SchemaFiles(against, sql)]
344
+
345
+
346
+ def _root(args: argparse.Namespace) -> Path:
347
+ """The repository's top level; outside one, the current directory, unless a git
348
+ revision is needed."""
349
+ try:
350
+ return Path(_git(Path.cwd(), "rev-parse", "--show-toplevel")).resolve()
351
+ except GitError:
352
+ if args.head or (args.against and not Path(args.against).exists()):
353
+ raise
354
+ return Path.cwd().resolve()
355
+
356
+
357
+ def _revision(label: str, root: Path, paths: list[Path]) -> Revision:
358
+ from sqlty._cli import (
359
+ SettingsResolver,
360
+ _collect_python_files,
361
+ _collect_query_files,
362
+ build_parser,
363
+ )
364
+
365
+ args = build_parser().parse_args(["check", *(str(root / p) for p in paths)])
366
+ with contextlib.chdir(root):
367
+ resolver = SettingsResolver(args)
368
+ files = _collect_python_files([root / p for p in paths], resolver.source)
369
+ files += _collect_query_files([root / p for p in paths], files, resolver)
370
+ files = [f.resolve() for f in files]
371
+ return Revision(label, root, resolver, files, paths)
372
+
373
+
374
+ @contextlib.contextmanager
375
+ def _worktree(root: Path, ref: str, paths: list[Path]) -> Generator[Path]:
376
+ """``ref`` checked out into a temporary worktree, removed on exit: the files
377
+ ``paths`` need, or all of them."""
378
+ _git(root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
379
+ directory = Path(tempfile.mkdtemp(prefix="sqlty-breaking-"))
380
+ # Named like the repository, so its files have the module names they have there.
381
+ checkout = directory / root.name
382
+ try:
383
+ _git(root, "worktree", "add", "--detach", "--no-checkout", "--quiet", str(checkout), ref)
384
+ _checkout(checkout, paths)
385
+ yield checkout.resolve()
386
+ finally:
387
+ with contextlib.suppress(GitError):
388
+ _git(root, "worktree", "remove", "--force", str(checkout))
389
+ shutil.rmtree(directory, ignore_errors=True)
390
+ with contextlib.suppress(GitError):
391
+ _git(root, "worktree", "prune")
392
+
393
+
394
+ def _checkout(checkout: Path, paths: list[Path]) -> None:
395
+ """Check out what checking ``paths`` reads: each path, the files of the directories
396
+ above it (where its configuration is found) and the schema and query files the
397
+ configurations name. Everything when a path is the root or a configuration reads
398
+ more: ``functions`` modules, ``databases``, a glob or a file outside the repository."""
399
+ from sqlty._config import ConfigError, find_config
400
+
401
+ listed = [
402
+ n for n in _git(checkout, "ls-tree", "-r", "--name-only", "-z", "HEAD").split("\0") if n
403
+ ]
404
+ above = {parent for path in paths for parent in path.parents}
405
+ full = ["reset", "--hard", "--quiet"]
406
+ if Path(".") in paths:
407
+ _git(checkout, *full)
408
+ return
409
+ _restore(checkout, [*paths, *(n for n in listed if Path(n).parent in above)], listed)
410
+ configs = {
411
+ Path(n).parent
412
+ for n in listed
413
+ if Path(n).name in _CONFIG_FILES
414
+ and (Path(n).parent in above or any(Path(n).is_relative_to(path) for path in paths))
415
+ }
416
+ needed: list[str | Path] = []
417
+ for directory in configs | set(paths):
418
+ try:
419
+ config, found = find_config(checkout / directory)
420
+ except ConfigError:
421
+ _git(checkout, *full)
422
+ return
423
+ if found is None:
424
+ continue
425
+ specs = [config.get("schema", []), config.get("queries", [])]
426
+ entries = [entry for spec in specs for entry in ([spec] if isinstance(spec, str) else spec)]
427
+ named = [os.path.relpath(found.parent / entry, checkout.resolve()) for entry in entries]
428
+ if (
429
+ {"functions", "databases"} & config.keys()
430
+ or any(glob.has_magic(entry) for entry in entries)
431
+ or any(name.startswith("..") for name in named)
432
+ ):
433
+ _git(checkout, *full)
434
+ return
435
+ needed += named
436
+ _restore(checkout, needed, listed)
437
+
438
+
439
+ def _restore(checkout: Path, names: Iterable[str | Path], listed: list[str]) -> None:
440
+ """Check out the files and directories of ``names`` that HEAD has."""
441
+ directories = {str(parent) for name in listed for parent in Path(name).parents}
442
+ present = {str(name) for name in names} & ({*listed} | directories - {"."})
443
+ if present:
444
+ _git(
445
+ checkout,
446
+ "checkout",
447
+ "--quiet",
448
+ "HEAD",
449
+ "--",
450
+ *(f":(literal){n}" for n in sorted(present)),
451
+ )
452
+
453
+
454
+ _CONFIG_FILES = ("sqlty.toml", "pyproject.toml", "Cargo.toml")
455
+
456
+
457
+ def _git(cwd: Path, *args: str) -> str:
458
+ try:
459
+ done = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=False)
460
+ except FileNotFoundError as exc:
461
+ raise GitError("git is not installed") from exc
462
+ if done.returncode != 0:
463
+ message = done.stderr.strip() or f"git {' '.join(args)} failed"
464
+ if args[:2] == ("rev-parse", "--verify"):
465
+ message = f"unknown revision {args[-1].removesuffix('^{commit}')!r}"
466
+ raise GitError(message)
467
+ return done.stdout.strip()
468
+
469
+
470
+ def main(args: argparse.Namespace) -> None:
471
+ from sqlty._cli import EXIT_FOUND
472
+ from sqlty._config import ConfigError
473
+
474
+ if args.against is None and args.save is None:
475
+ raise ConfigError("sqlty breaking needs --against, --save or both")
476
+ try:
477
+ findings = run(args)
478
+ except GitError as exc:
479
+ raise ConfigError(str(exc)) from exc
480
+ report(findings, args.head, args.quiet, args.output_format)
481
+ if any(f.diagnostic["severity"] == "error" for f in findings):
482
+ sys.exit(EXIT_FOUND)