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/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""SQLTy - Type-safe SQL queries for Python."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from string.templatelib import Template
|
|
5
|
+
from typing import get_args, overload
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 15):
|
|
8
|
+
from typing import TypeForm
|
|
9
|
+
else:
|
|
10
|
+
from typing_extensions import Sentinel as sentinel
|
|
11
|
+
from typing_extensions import 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", "SQLTemplate", "row_form"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SQL[T](str):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SQLTemplate[T]:
|
|
36
|
+
"""A t-string query, ``SQLTemplate[T](t"--sql\\n...")``, whose rows are ``T``.
|
|
37
|
+
|
|
38
|
+
``string.templatelib.Template`` cannot be subclassed, so the template is kept in
|
|
39
|
+
``template``: pass that to a driver that runs templates (psycopg >= 3.3), or the
|
|
40
|
+
whole query to the ``sqlty.drivers.psycopg`` helpers. ``row_form`` reads ``T``.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
>>> user_id = 1
|
|
44
|
+
>>> query = SQLTemplate[tuple[int]](t"--sql\\nSELECT id::INTEGER FROM users WHERE id = {user_id}")
|
|
45
|
+
>>> query.template.values
|
|
46
|
+
(1,)
|
|
47
|
+
>>> row_form(query)
|
|
48
|
+
tuple[int]
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
__slots__ = ("__orig_class__", "template")
|
|
52
|
+
|
|
53
|
+
def __init__(self, template: Template, /) -> None:
|
|
54
|
+
self.template = template
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
return f"{type(self).__name__}({self.template!r})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
UNTYPED = sentinel("UNTYPED")
|
|
61
|
+
"""Row type of a query sqlty could not infer: ``SQL[UNTYPED]``.
|
|
62
|
+
|
|
63
|
+
Unlike ``Any``, type checkers reject using an ``UNTYPED`` row as a concrete
|
|
64
|
+
type. ``sqlty rewrite --untyped sentinel`` emits it; ``sqlty check`` flags it
|
|
65
|
+
(``untyped-query``) until casts or a schema make the row type known.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_MISSING: object = object()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@overload
|
|
73
|
+
def row_form[T](query: SQL[T] | SQLTemplate[T], /) -> TypeForm[T]: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@overload
|
|
77
|
+
def row_form[T, D](query: SQL[T] | SQLTemplate[T], default: D, /) -> TypeForm[T] | D: ...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def row_form(query: object, default: object = _MISSING, /) -> object:
|
|
81
|
+
"""Return the row type ``T`` a ``SQL[T](...)`` instance was built with.
|
|
82
|
+
|
|
83
|
+
The type is read from ``__orig_class__``, so it exists only for queries
|
|
84
|
+
constructed as ``SQL[T](...)``. Plain strings, annotation-only
|
|
85
|
+
``q: SQL[T] = "..."`` and stripped sources carry no runtime type: these
|
|
86
|
+
raise ``TypeError`` unless a ``default`` is given.
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
>>> row_form(SQL[tuple[int, str]]("--sql\\nSELECT 1, 'a'"))
|
|
90
|
+
tuple[int, str]
|
|
91
|
+
>>> row_form(SQL("--sql\\nSELECT 1"), None) is None
|
|
92
|
+
True
|
|
93
|
+
"""
|
|
94
|
+
orig = getattr(query, "__orig_class__", None)
|
|
95
|
+
if orig is not None and (args := get_args(orig)):
|
|
96
|
+
return args[0]
|
|
97
|
+
if default is not _MISSING:
|
|
98
|
+
return default
|
|
99
|
+
raise TypeError(f"{type(query).__name__} has no runtime row type; construct it as SQL[T](...)")
|
sqlty/__main__.py
ADDED
sqlty/_breaking.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""``sqlty breaking``: the queries and schema of two git 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
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from collections.abc import Generator
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING, Literal
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
import argparse
|
|
28
|
+
|
|
29
|
+
from sqlty._cli import Settings, SettingsResolver
|
|
30
|
+
from sqlty._lint import Diagnostic
|
|
31
|
+
|
|
32
|
+
Direction = Literal["backward", "forward", "both"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GitError(Exception):
|
|
36
|
+
"""A git command failed: not a repository, or an unknown revision."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Revision:
|
|
41
|
+
"""A checkout of the project at one revision, read as ``sqlty check`` reads it."""
|
|
42
|
+
|
|
43
|
+
label: str
|
|
44
|
+
root: Path
|
|
45
|
+
resolver: SettingsResolver
|
|
46
|
+
files: list[Path]
|
|
47
|
+
|
|
48
|
+
def settings(self, relative: Path) -> Settings:
|
|
49
|
+
return self.resolver.for_file(self.root / relative)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Finding:
|
|
54
|
+
direction: Literal["backward", "forward"]
|
|
55
|
+
path: Path
|
|
56
|
+
diagnostic: Diagnostic
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run(args: argparse.Namespace) -> list[Finding]:
|
|
60
|
+
"""The findings of ``sqlty breaking``, for the paths and revisions ``args`` name."""
|
|
61
|
+
root = Path(_git(Path.cwd(), "rev-parse", "--show-toplevel")).resolve()
|
|
62
|
+
paths = [Path(p).resolve().relative_to(root) for p in (args.paths or [Path.cwd()])]
|
|
63
|
+
with contextlib.ExitStack() as stack:
|
|
64
|
+
against = _revision(args.against, stack.enter_context(_worktree(root, args.against)), paths)
|
|
65
|
+
head_root = stack.enter_context(_worktree(root, args.head)) if args.head else root
|
|
66
|
+
head = _revision(args.head or "the working tree", head_root, paths)
|
|
67
|
+
findings: list[Finding] = []
|
|
68
|
+
if args.direction in ("backward", "both"):
|
|
69
|
+
findings += _compare("backward", queries=against, schema=head)
|
|
70
|
+
if args.direction in ("forward", "both"):
|
|
71
|
+
findings += _compare("forward", queries=head, schema=against)
|
|
72
|
+
return findings
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def report(
|
|
76
|
+
findings: list[Finding],
|
|
77
|
+
against: str,
|
|
78
|
+
head: str | None,
|
|
79
|
+
quiet: bool,
|
|
80
|
+
output_format: Literal["text", "json", "github"] = "text",
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Print each finding: ``path:line: [rule] message (direction)``, a JSON array with a
|
|
83
|
+
``direction`` per finding, or GitHub Actions annotations, all errors."""
|
|
84
|
+
import json
|
|
85
|
+
|
|
86
|
+
from sqlty._cli import github_annotation
|
|
87
|
+
|
|
88
|
+
if quiet:
|
|
89
|
+
return
|
|
90
|
+
head = head or "the working tree"
|
|
91
|
+
titles = {
|
|
92
|
+
"backward": f"the queries of {against} against the schema of {head}",
|
|
93
|
+
"forward": f"the queries of {head} against the schema of {against}",
|
|
94
|
+
}
|
|
95
|
+
if output_format == "json":
|
|
96
|
+
rows = [{"file": str(f.path), **f.diagnostic, "direction": f.direction} for f in findings]
|
|
97
|
+
print(json.dumps(rows, indent=2))
|
|
98
|
+
return
|
|
99
|
+
for finding in findings:
|
|
100
|
+
d = finding.diagnostic
|
|
101
|
+
message = f"{d['message']} ({finding.direction}: {titles[finding.direction]})"
|
|
102
|
+
if output_format == "github":
|
|
103
|
+
print(github_annotation(finding.path, {**d, "message": message, "severity": "error"}))
|
|
104
|
+
else:
|
|
105
|
+
print(f"{finding.path}:{d.get('line', '')}: [{d['rule']}] {message}")
|
|
106
|
+
if findings and output_format == "text":
|
|
107
|
+
print(f"Found {len(findings)} breaking change(s)")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _compare(
|
|
111
|
+
direction: Literal["backward", "forward"], *, queries: Revision, schema: Revision
|
|
112
|
+
) -> list[Finding]:
|
|
113
|
+
from sqlty._cli import _module_names, read_source
|
|
114
|
+
from sqlty._source import ParsedSource
|
|
115
|
+
from sqlty.core import breaking_source
|
|
116
|
+
|
|
117
|
+
findings: list[Finding] = []
|
|
118
|
+
roots = [queries.root]
|
|
119
|
+
for path in queries.files:
|
|
120
|
+
relative = path.relative_to(queries.root)
|
|
121
|
+
own = queries.settings(relative)
|
|
122
|
+
other = schema.settings(relative)
|
|
123
|
+
module = _module_names(queries.resolver.resolve(path), roots)[0]
|
|
124
|
+
text = read_source(path)
|
|
125
|
+
if text is None:
|
|
126
|
+
continue
|
|
127
|
+
parsed = ParsedSource(text, module)
|
|
128
|
+
for diagnostic in breaking_source(
|
|
129
|
+
parsed,
|
|
130
|
+
schema_sql=own.schema,
|
|
131
|
+
against_sql=other.schema,
|
|
132
|
+
dialect=own.dialect,
|
|
133
|
+
rules=own.rules,
|
|
134
|
+
import_style=own.import_style,
|
|
135
|
+
untyped=own.untyped,
|
|
136
|
+
type_modules=own.type_modules,
|
|
137
|
+
):
|
|
138
|
+
findings.append(Finding(direction, relative, diagnostic))
|
|
139
|
+
return findings
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _revision(label: str, root: Path, paths: list[Path]) -> Revision:
|
|
143
|
+
from sqlty._cli import SettingsResolver, _collect_python_files, build_parser
|
|
144
|
+
|
|
145
|
+
args = build_parser().parse_args(["check", *(str(root / p) for p in paths)])
|
|
146
|
+
with contextlib.chdir(root):
|
|
147
|
+
resolver = SettingsResolver(args)
|
|
148
|
+
files = _collect_python_files([root / p for p in paths], resolver.source)
|
|
149
|
+
return Revision(label, root, resolver, [f.resolve() for f in files])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@contextlib.contextmanager
|
|
153
|
+
def _worktree(root: Path, ref: str) -> Generator[Path]:
|
|
154
|
+
"""``ref`` checked out into a temporary worktree, removed on exit."""
|
|
155
|
+
_git(root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
|
|
156
|
+
directory = Path(tempfile.mkdtemp(prefix="sqlty-breaking-"))
|
|
157
|
+
try:
|
|
158
|
+
_git(root, "worktree", "add", "--detach", "--quiet", str(directory), ref)
|
|
159
|
+
yield directory.resolve()
|
|
160
|
+
finally:
|
|
161
|
+
with contextlib.suppress(GitError):
|
|
162
|
+
_git(root, "worktree", "remove", "--force", str(directory))
|
|
163
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
164
|
+
with contextlib.suppress(GitError):
|
|
165
|
+
_git(root, "worktree", "prune")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _git(cwd: Path, *args: str) -> str:
|
|
169
|
+
try:
|
|
170
|
+
done = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=False)
|
|
171
|
+
except FileNotFoundError as exc:
|
|
172
|
+
raise GitError("git is not installed") from exc
|
|
173
|
+
if done.returncode != 0:
|
|
174
|
+
message = done.stderr.strip() or f"git {' '.join(args)} failed"
|
|
175
|
+
if args[:2] == ("rev-parse", "--verify"):
|
|
176
|
+
message = f"unknown revision {args[-1].removesuffix('^{commit}')!r}"
|
|
177
|
+
raise GitError(message)
|
|
178
|
+
return done.stdout.strip()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main(args: argparse.Namespace) -> None:
|
|
182
|
+
from sqlty._cli import EXIT_FOUND
|
|
183
|
+
from sqlty._config import ConfigError
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
findings = run(args)
|
|
187
|
+
except GitError as exc:
|
|
188
|
+
raise ConfigError(str(exc)) from exc
|
|
189
|
+
report(findings, args.against, args.head, args.quiet, args.output_format)
|
|
190
|
+
if findings:
|
|
191
|
+
sys.exit(EXIT_FOUND)
|