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/__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
@@ -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,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)