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/_json_shape.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Check the JSON a query builds against the annotation of the row field that receives it.
|
|
2
|
+
|
|
3
|
+
The engine reports the shape of a ``jsonb_build_object()``, ``jsonb_agg()`` or ``to_jsonb()``
|
|
4
|
+
column as JSON text (see ``rust/crates/sqlty-sql/src/json.rs``); drivers decode the value into
|
|
5
|
+
dicts, lists and scalars. `json_problems` compares that shape with the annotation, following
|
|
6
|
+
nested TypedDicts, ``list[...]`` and ``dict[str, ...]``, and lists what cannot hold it.
|
|
7
|
+
|
|
8
|
+
Only what is decidable is reported: ``Any``, ``object``, classes sqlty cannot see into
|
|
9
|
+
(imports, Pydantic models) and annotations it cannot read accept anything.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
import json
|
|
16
|
+
import typing
|
|
17
|
+
|
|
18
|
+
if typing.TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
|
|
21
|
+
from sqlty._type_extractor import TypeInfo
|
|
22
|
+
|
|
23
|
+
# Annotation heads a JSON scalar decodes into (bool is an int, an int a float; a number
|
|
24
|
+
# is an int when integral, else a float, and ``Decimal`` loads it exactly).
|
|
25
|
+
_SCALAR_HEADS = {
|
|
26
|
+
"boolean": {"bool", "int", "float"},
|
|
27
|
+
"integer": {"int", "float"},
|
|
28
|
+
"number": {"float", "int", "Decimal"},
|
|
29
|
+
"string": {"str"},
|
|
30
|
+
}
|
|
31
|
+
_SCALAR_NAMES = {
|
|
32
|
+
"boolean": "a boolean",
|
|
33
|
+
"integer": "an integer",
|
|
34
|
+
"number": "a number",
|
|
35
|
+
"string": "a string",
|
|
36
|
+
}
|
|
37
|
+
_LISTS = {"list", "List", "Sequence", "MutableSequence", "Collection", "Iterable"}
|
|
38
|
+
_MAPPINGS = {"dict", "Dict", "Mapping", "MutableMapping"}
|
|
39
|
+
# Classes that can never hold a JSON value of another kind.
|
|
40
|
+
_BUILTINS = {"bool", "int", "float", "str", "bytes", "tuple", "Tuple", "set", "frozenset"}
|
|
41
|
+
_OPEN = {"Any", "object"}
|
|
42
|
+
_MAX_PROBLEMS = 3
|
|
43
|
+
_MAX_DEPTH = 32
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Context(typing.NamedTuple):
|
|
47
|
+
defs: Mapping[str, TypeInfo]
|
|
48
|
+
aliases: Mapping[str, str]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def json_problems(
|
|
52
|
+
shape: str,
|
|
53
|
+
annotation: ast.expr,
|
|
54
|
+
defs: Mapping[str, TypeInfo],
|
|
55
|
+
aliases: Mapping[str, str],
|
|
56
|
+
name: str = "",
|
|
57
|
+
) -> list[str]:
|
|
58
|
+
"""What keeps ``annotation`` from holding the JSON of ``shape``, as sentences; empty if it fits.
|
|
59
|
+
|
|
60
|
+
``shape`` is the JSON text the engine reports for a column; ``defs`` are the user types
|
|
61
|
+
of the file (see ``extract_type_definitions``) and ``aliases`` its typing names. Each
|
|
62
|
+
sentence starts with the path of the value below ``name``: ``items[].price is int but ...``.
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
>>> shape = '{"array":{"shape":"integer","nullable":true}}'
|
|
66
|
+
>>> annotation = ast.parse("list[int]", mode="eval").body
|
|
67
|
+
>>> json_problems(shape, annotation, {}, {})
|
|
68
|
+
['[] can be null but is declared int']
|
|
69
|
+
>>> json_problems(shape, ast.parse("dict[str, int]", mode="eval").body, {}, {})
|
|
70
|
+
['is dict[str, int] but the query builds an array']
|
|
71
|
+
"""
|
|
72
|
+
out: list[str] = []
|
|
73
|
+
value = {"shape": json.loads(shape), "nullable": False}
|
|
74
|
+
_check(value, annotation, name, _Context(defs, aliases), out, 0)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def render(shape: str) -> str:
|
|
79
|
+
"""The JSON shape the engine reports, as readable text.
|
|
80
|
+
|
|
81
|
+
Objects are ``{key: shape}`` (``key?`` when it may be missing), arrays ``[shape]``,
|
|
82
|
+
maps ``{[key]: shape}``, and a value that can be ``null`` ends in ``| null``.
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
>>> render('{"array":{"shape":{"object":[{"name":"id","value":{"shape":"integer","nullable":false},"optional":true}]},"nullable":true}}')
|
|
86
|
+
'[{id?: integer} | null]'
|
|
87
|
+
"""
|
|
88
|
+
return _render(json.loads(shape))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _render(shape: typing.Any) -> str:
|
|
92
|
+
if isinstance(shape, str):
|
|
93
|
+
return shape
|
|
94
|
+
kind, body = next(iter(shape.items()))
|
|
95
|
+
if kind == "array":
|
|
96
|
+
return f"[{_render_value(body)}]"
|
|
97
|
+
if kind == "map":
|
|
98
|
+
return f"{{[key]: {_render_value(body)}}}"
|
|
99
|
+
fields = ", ".join(
|
|
100
|
+
f"{f['name']}{'?' if f['optional'] else ''}: {_render_value(f['value'])}" for f in body
|
|
101
|
+
)
|
|
102
|
+
return f"{{{fields}}}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _render_value(value: Mapping[str, typing.Any]) -> str:
|
|
106
|
+
text = _render(value["shape"])
|
|
107
|
+
return f"{text} | null" if value["nullable"] else text
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def summarize(problems: list[str]) -> str:
|
|
111
|
+
"""The first few problems on one line, the rest counted."""
|
|
112
|
+
shown = "; ".join(problems[:_MAX_PROBLEMS])
|
|
113
|
+
extra = len(problems) - _MAX_PROBLEMS
|
|
114
|
+
return f"{shown}; and {extra} more" if extra > 0 else shown
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _head(node: ast.expr, ctx: _Context) -> str | None:
|
|
118
|
+
"""The name a (possibly subscripted) annotation is, ``typing`` aliases resolved."""
|
|
119
|
+
if isinstance(node, ast.Subscript):
|
|
120
|
+
node = node.value
|
|
121
|
+
if isinstance(node, ast.Name):
|
|
122
|
+
return ctx.aliases.get(node.id, node.id)
|
|
123
|
+
if isinstance(node, ast.Attribute):
|
|
124
|
+
return node.attr
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_none(node: ast.expr) -> bool:
|
|
129
|
+
return isinstance(node, ast.Constant) and node.value is None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _members(node: ast.expr, ctx: _Context, depth: int = 0) -> list[ast.expr]:
|
|
133
|
+
"""The alternatives of a union, ``Optional`` and ``Annotated`` looked through."""
|
|
134
|
+
if depth > _MAX_DEPTH:
|
|
135
|
+
return [node]
|
|
136
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
137
|
+
try:
|
|
138
|
+
return _members(ast.parse(node.value, mode="eval").body, ctx, depth + 1)
|
|
139
|
+
except SyntaxError, ValueError:
|
|
140
|
+
return [node]
|
|
141
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
142
|
+
return _members(node.left, ctx, depth + 1) + _members(node.right, ctx, depth + 1)
|
|
143
|
+
if isinstance(node, ast.Subscript):
|
|
144
|
+
head = _head(node, ctx)
|
|
145
|
+
args = list(node.slice.elts) if isinstance(node.slice, ast.Tuple) else [node.slice]
|
|
146
|
+
if head == "Optional":
|
|
147
|
+
return [*_members(args[0], ctx, depth + 1), ast.Constant(None)]
|
|
148
|
+
if head == "Union":
|
|
149
|
+
return [m for arg in args for m in _members(arg, ctx, depth + 1)]
|
|
150
|
+
if head == "Annotated":
|
|
151
|
+
return _members(args[0], ctx, depth + 1)
|
|
152
|
+
return [node]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _admits_none(members: list[ast.expr], ctx: _Context) -> bool:
|
|
156
|
+
for member in members:
|
|
157
|
+
if _is_none(member) or _head(member, ctx) in _OPEN:
|
|
158
|
+
return True
|
|
159
|
+
if _head(member, ctx) == "Literal" and isinstance(member, ast.Subscript):
|
|
160
|
+
args = member.slice.elts if isinstance(member.slice, ast.Tuple) else [member.slice]
|
|
161
|
+
if any(_is_none(arg) for arg in args):
|
|
162
|
+
return True
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _describe(shape: typing.Any) -> str:
|
|
167
|
+
if isinstance(shape, str):
|
|
168
|
+
return _SCALAR_NAMES.get(shape, shape)
|
|
169
|
+
return {"array": "an array", "map": "a map", "object": "an object"}[next(iter(shape))]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _at(path: str, text: str) -> str:
|
|
173
|
+
return f"{path} {text}" if path else text
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _check(
|
|
177
|
+
value: Mapping[str, typing.Any],
|
|
178
|
+
node: ast.expr,
|
|
179
|
+
path: str,
|
|
180
|
+
ctx: _Context,
|
|
181
|
+
out: list[str],
|
|
182
|
+
depth: int,
|
|
183
|
+
) -> None:
|
|
184
|
+
if depth > _MAX_DEPTH:
|
|
185
|
+
return
|
|
186
|
+
members = _members(node, ctx)
|
|
187
|
+
if value["nullable"] and not _admits_none(members, ctx):
|
|
188
|
+
out.append(_at(path, f"can be null but is declared {ast.unparse(node)}"))
|
|
189
|
+
shape = value["shape"]
|
|
190
|
+
if shape in ("any", "never", "null"):
|
|
191
|
+
return
|
|
192
|
+
candidates = [m for m in members if not _is_none(m)]
|
|
193
|
+
if not candidates:
|
|
194
|
+
out.append(_at(path, f"is None but the query builds {_describe(shape)}"))
|
|
195
|
+
return
|
|
196
|
+
failures: list[list[str]] = []
|
|
197
|
+
for member in candidates:
|
|
198
|
+
found: list[str] = []
|
|
199
|
+
_check_member(shape, member, path, ctx, found, depth)
|
|
200
|
+
if not found:
|
|
201
|
+
return
|
|
202
|
+
failures.append(found)
|
|
203
|
+
out.extend(failures[0])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _mismatch(shape: typing.Any, member: ast.expr, path: str, out: list[str]) -> None:
|
|
207
|
+
out.append(_at(path, f"is {ast.unparse(member)} but the query builds {_describe(shape)}"))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _check_member(
|
|
211
|
+
shape: typing.Any,
|
|
212
|
+
member: ast.expr,
|
|
213
|
+
path: str,
|
|
214
|
+
ctx: _Context,
|
|
215
|
+
out: list[str],
|
|
216
|
+
depth: int,
|
|
217
|
+
) -> None:
|
|
218
|
+
head = _head(member, ctx)
|
|
219
|
+
if head is None or head in _OPEN:
|
|
220
|
+
return
|
|
221
|
+
info = ctx.defs.get(head) if isinstance(member, ast.Name) else None
|
|
222
|
+
row_type = info if info is not None and info.kind in ("TypedDict", "BaseModel") else None
|
|
223
|
+
if isinstance(shape, str):
|
|
224
|
+
if head in _BUILTINS | _LISTS | _MAPPINGS or row_type:
|
|
225
|
+
if head not in _SCALAR_HEADS[shape]:
|
|
226
|
+
_mismatch(shape, member, path, out)
|
|
227
|
+
return
|
|
228
|
+
kind = next(iter(shape))
|
|
229
|
+
if kind == "array":
|
|
230
|
+
if head in _LISTS:
|
|
231
|
+
if isinstance(member, ast.Subscript):
|
|
232
|
+
_check(shape["array"], member.slice, f"{path}[]", ctx, out, depth + 1)
|
|
233
|
+
elif head in _BUILTINS | _MAPPINGS or row_type:
|
|
234
|
+
_mismatch(shape, member, path, out)
|
|
235
|
+
elif kind == "map":
|
|
236
|
+
if head in _MAPPINGS:
|
|
237
|
+
if parts := _mapping_parts(member, path, ctx, out):
|
|
238
|
+
_check(shape["map"], parts, f"{path}[key]", ctx, out, depth + 1)
|
|
239
|
+
elif head in _BUILTINS | _LISTS or row_type:
|
|
240
|
+
_mismatch(shape, member, path, out)
|
|
241
|
+
elif head in _MAPPINGS:
|
|
242
|
+
if parts := _mapping_parts(member, path, ctx, out):
|
|
243
|
+
for field in shape["object"]:
|
|
244
|
+
_check(field["value"], parts, _join(path, field["name"]), ctx, out, depth + 1)
|
|
245
|
+
elif row_type is not None:
|
|
246
|
+
_check_row_type(shape["object"], row_type, path, ctx, out, depth)
|
|
247
|
+
elif head in _BUILTINS | _LISTS:
|
|
248
|
+
_mismatch(shape, member, path, out)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _mapping_parts(member: ast.expr, path: str, ctx: _Context, out: list[str]) -> ast.expr | None:
|
|
252
|
+
"""The value annotation of ``dict[K, V]``, reporting a key type that is not ``str``."""
|
|
253
|
+
if not isinstance(member, ast.Subscript) or not isinstance(member.slice, ast.Tuple):
|
|
254
|
+
return None
|
|
255
|
+
key, item = member.slice.elts[0], member.slice.elts[-1]
|
|
256
|
+
if (head := _head(key, ctx)) in _BUILTINS and head != "str":
|
|
257
|
+
out.append(_at(path, f"has keys of {ast.unparse(key)} but JSON object keys are strings"))
|
|
258
|
+
return item
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _join(path: str, name: str) -> str:
|
|
262
|
+
return f"{path}.{name}" if path else name
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _check_row_type(
|
|
266
|
+
fields: list[Mapping[str, typing.Any]],
|
|
267
|
+
info: TypeInfo,
|
|
268
|
+
path: str,
|
|
269
|
+
ctx: _Context,
|
|
270
|
+
out: list[str],
|
|
271
|
+
depth: int,
|
|
272
|
+
) -> None:
|
|
273
|
+
inner = _Context(ctx.defs, info.aliases or ctx.aliases)
|
|
274
|
+
built = {field["name"]: field for field in fields}
|
|
275
|
+
# A Pydantic model reads keys by alias; its field types are not checked here.
|
|
276
|
+
model = info.kind == "BaseModel"
|
|
277
|
+
admits_extra = info.extra_items or info.allows_extra or info.opaque
|
|
278
|
+
for name, field in built.items():
|
|
279
|
+
where = _join(path, name)
|
|
280
|
+
key = info.field_for(name)
|
|
281
|
+
if key is None:
|
|
282
|
+
if not admits_extra:
|
|
283
|
+
out.append(
|
|
284
|
+
_at(path or "the object", f"has key '{name}' that {info.name} does not declare")
|
|
285
|
+
)
|
|
286
|
+
continue
|
|
287
|
+
if field["optional"] and key in info.required:
|
|
288
|
+
out.append(_at(where, f"is required by {info.name} but the query may omit it"))
|
|
289
|
+
if not model and (annotation := info.annotations.get(key)) is not None:
|
|
290
|
+
_check(field["value"], annotation, where, inner, out, depth + 1)
|
|
291
|
+
for key, _ in info.fields:
|
|
292
|
+
names = info.column_names(key)
|
|
293
|
+
if key in info.required and key not in info.opaque and not built.keys() & set(names):
|
|
294
|
+
out.append(
|
|
295
|
+
_at(
|
|
296
|
+
_join(path, names[0]),
|
|
297
|
+
f"is required by {info.name} but the query does not build it",
|
|
298
|
+
)
|
|
299
|
+
)
|
sqlty/_json_types.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""TypedDicts for JSON columns: what ``json-types = "typing.TypedDict"`` writes into a module.
|
|
2
|
+
|
|
3
|
+
``rewrite`` types a JSON column ``typing.Any``. Where the engine knows the shape of the
|
|
4
|
+
JSON a query builds (``jsonb_build_object()``, ``jsonb_agg()``, ``to_jsonb()``), the
|
|
5
|
+
``typing.Any`` items of a ``SQL[tuple[...]]`` annotation become nested types instead, with a
|
|
6
|
+
``TypedDict`` per object. Each class is written after the imports, below a
|
|
7
|
+
``# sqlty: generated`` line, and ``strip`` removes them again.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import json
|
|
14
|
+
import keyword
|
|
15
|
+
import re
|
|
16
|
+
import typing
|
|
17
|
+
|
|
18
|
+
from sqlty._core import analyze_columns
|
|
19
|
+
from sqlty._source import _wrapper_name
|
|
20
|
+
|
|
21
|
+
if typing.TYPE_CHECKING:
|
|
22
|
+
from sqlty._core import Schema
|
|
23
|
+
|
|
24
|
+
MARKER = "# sqlty: generated"
|
|
25
|
+
_BLOCK = (
|
|
26
|
+
rf"{MARKER}\r?\n(?:class [^\r\n]*:\r?\n(?: [^\r\n]*\r?\n)+"
|
|
27
|
+
r"|[A-Za-z_]\w* = typing\.TypedDict\([^\r\n]*\)\r?\n)"
|
|
28
|
+
)
|
|
29
|
+
_GROUP = re.compile(rf"(\r?\n){{2}}{_BLOCK}(?:(?:\r?\n){{2}}{_BLOCK})*(?:\r?\n){{2}}")
|
|
30
|
+
_SCALARS = {
|
|
31
|
+
"integer": "int",
|
|
32
|
+
"number": "float",
|
|
33
|
+
"string": "str",
|
|
34
|
+
"boolean": "bool",
|
|
35
|
+
"null": "None",
|
|
36
|
+
"any": "typing.Any",
|
|
37
|
+
"never": "typing.Any",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def add_json_types(
|
|
42
|
+
source: str,
|
|
43
|
+
*,
|
|
44
|
+
dialect: str = "postgres",
|
|
45
|
+
schema_sql: str | Schema | None = None,
|
|
46
|
+
import_style: str | None = None,
|
|
47
|
+
) -> str:
|
|
48
|
+
"""``source`` with the ``typing.Any`` row items of JSON columns of known shape typed.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
>>> source = (
|
|
52
|
+
... "import typing\\nfrom sqlty import SQL\\n"
|
|
53
|
+
... "q = SQL[tuple[int, typing.Any]]"
|
|
54
|
+
... "(\\"--sql\\\\nSELECT 1::INTEGER AS id, jsonb_build_object('n', 1) AS doc\\")\\n"
|
|
55
|
+
... )
|
|
56
|
+
>>> print(add_json_types(source))
|
|
57
|
+
import typing
|
|
58
|
+
from sqlty import SQL
|
|
59
|
+
<BLANKLINE>
|
|
60
|
+
<BLANKLINE>
|
|
61
|
+
# sqlty: generated
|
|
62
|
+
class Doc(typing.TypedDict):
|
|
63
|
+
n: int
|
|
64
|
+
<BLANKLINE>
|
|
65
|
+
<BLANKLINE>
|
|
66
|
+
q = SQL[tuple[int, Doc]]("--sql\\nSELECT 1::INTEGER AS id, jsonb_build_object('n', 1) AS doc")
|
|
67
|
+
<BLANKLINE>
|
|
68
|
+
"""
|
|
69
|
+
wrapper = _wrapper_name(import_style)
|
|
70
|
+
try:
|
|
71
|
+
tree = ast.parse(source)
|
|
72
|
+
except SyntaxError, ValueError:
|
|
73
|
+
return source
|
|
74
|
+
if wrapper is None:
|
|
75
|
+
return source
|
|
76
|
+
module = _Module(tree, source)
|
|
77
|
+
edits: list[tuple[int, int, str]] = []
|
|
78
|
+
for node in ast.walk(tree):
|
|
79
|
+
if not (
|
|
80
|
+
isinstance(node, ast.Call)
|
|
81
|
+
and isinstance(node.func, ast.Subscript)
|
|
82
|
+
and ast.unparse(node.func.value) == wrapper
|
|
83
|
+
and node.args
|
|
84
|
+
and isinstance(node.args[0], ast.Constant)
|
|
85
|
+
and isinstance(node.args[0].value, str)
|
|
86
|
+
):
|
|
87
|
+
continue
|
|
88
|
+
row = node.func.slice
|
|
89
|
+
if not (isinstance(row, ast.Subscript) and ast.unparse(row.value) == "tuple"):
|
|
90
|
+
continue
|
|
91
|
+
items = row.slice.elts if isinstance(row.slice, ast.Tuple) else [row.slice]
|
|
92
|
+
if not any(ast.unparse(item) in ("typing.Any", "Any") for item in items):
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
columns = analyze_columns(node.args[0].value, dialect=dialect, schema_sql=schema_sql)
|
|
96
|
+
except RuntimeError:
|
|
97
|
+
continue
|
|
98
|
+
if columns is None or len(columns) != len(items):
|
|
99
|
+
continue
|
|
100
|
+
for item, (name, _, _, nullable, shape) in zip(items, columns, strict=True):
|
|
101
|
+
if shape is None or ast.unparse(item) not in ("typing.Any", "Any"):
|
|
102
|
+
continue
|
|
103
|
+
value = {"shape": json.loads(shape), "nullable": nullable is True}
|
|
104
|
+
if value["shape"] == "any":
|
|
105
|
+
continue
|
|
106
|
+
edits.append((*module.span(item), module.type_of(value, _pascal(name))))
|
|
107
|
+
if not edits:
|
|
108
|
+
return source
|
|
109
|
+
for start, end, text in sorted(edits, reverse=True):
|
|
110
|
+
source = source[:start] + text + source[end:]
|
|
111
|
+
return module.with_classes(source)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def strip_json_types(source: str) -> str:
|
|
115
|
+
"""``source`` without the classes ``add_json_types`` wrote.
|
|
116
|
+
|
|
117
|
+
Example:
|
|
118
|
+
>>> strip_json_types("import typing\\n\\n\\n# sqlty: generated\\nclass A(typing.TypedDict):\\n a: int\\n\\n\\nq = 1\\n")
|
|
119
|
+
'import typing\\nq = 1\\n'
|
|
120
|
+
"""
|
|
121
|
+
return _GROUP.sub("", source) if MARKER in source else source
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Module:
|
|
125
|
+
"""The top-level names of a module and the classes generated for it so far."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, tree: ast.Module, source: str) -> None:
|
|
128
|
+
self.tree = tree
|
|
129
|
+
self.lines = source.splitlines(keepends=True)
|
|
130
|
+
self.taken: set[str] = set()
|
|
131
|
+
self.existing: dict[str, str] = {}
|
|
132
|
+
self.new: dict[str, str] = {}
|
|
133
|
+
generated = {
|
|
134
|
+
index + 1 for index, line in enumerate(self.lines) if line.rstrip("\r\n") == MARKER
|
|
135
|
+
}
|
|
136
|
+
for stmt in tree.body:
|
|
137
|
+
names = [
|
|
138
|
+
target.id for target in getattr(stmt, "targets", []) if isinstance(target, ast.Name)
|
|
139
|
+
]
|
|
140
|
+
if isinstance(stmt, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
|
|
141
|
+
names.append(stmt.name)
|
|
142
|
+
if isinstance(stmt, ast.Import | ast.ImportFrom):
|
|
143
|
+
names += [(alias.asname or alias.name).split(".")[0] for alias in stmt.names]
|
|
144
|
+
self.taken.update(names)
|
|
145
|
+
if stmt.lineno - 1 in generated and names:
|
|
146
|
+
self.existing[names[0]] = ast.unparse(stmt)
|
|
147
|
+
|
|
148
|
+
def span(self, node: ast.expr) -> tuple[int, int]:
|
|
149
|
+
"""Character offsets of ``node`` in the source (``ast`` counts UTF-8 bytes)."""
|
|
150
|
+
assert node.end_lineno is not None and node.end_col_offset is not None
|
|
151
|
+
|
|
152
|
+
def offset(line: int, column: int) -> int:
|
|
153
|
+
before = sum(len(text) for text in self.lines[: line - 1])
|
|
154
|
+
return before + len(self.lines[line - 1].encode()[:column].decode())
|
|
155
|
+
|
|
156
|
+
return offset(node.lineno, node.col_offset), offset(node.end_lineno, node.end_col_offset)
|
|
157
|
+
|
|
158
|
+
def type_of(self, value: dict[str, typing.Any], name: str) -> str:
|
|
159
|
+
"""The Python type of a JSON value of the engine's shape; objects get a class ``name``."""
|
|
160
|
+
shape = value["shape"]
|
|
161
|
+
if isinstance(shape, str):
|
|
162
|
+
text = _SCALARS.get(shape, "typing.Any")
|
|
163
|
+
else:
|
|
164
|
+
kind, body = next(iter(shape.items()))
|
|
165
|
+
if kind == "array":
|
|
166
|
+
text = f"list[{self.type_of(body, name + 'Item')}]"
|
|
167
|
+
elif kind == "map":
|
|
168
|
+
text = f"dict[str, {self.type_of(body, name + 'Value')}]"
|
|
169
|
+
else:
|
|
170
|
+
text = self.typed_dict(body, name)
|
|
171
|
+
return f"{text} | None" if value["nullable"] and text != "None" else text
|
|
172
|
+
|
|
173
|
+
def typed_dict(self, fields: list[dict[str, typing.Any]], name: str) -> str:
|
|
174
|
+
"""The name of a ``TypedDict`` class with ``fields``, written once per module."""
|
|
175
|
+
types = []
|
|
176
|
+
for field in fields:
|
|
177
|
+
text = self.type_of(field["value"], name + _pascal(field["name"]))
|
|
178
|
+
types.append(
|
|
179
|
+
(field["name"], f"typing.NotRequired[{text}]" if field["optional"] else text)
|
|
180
|
+
)
|
|
181
|
+
for candidate in [name, *(f"{name}{n}" for n in range(2, 100))]:
|
|
182
|
+
code = _class(candidate, types)
|
|
183
|
+
body = ast.unparse(ast.parse(code).body[0])
|
|
184
|
+
if self.existing.get(candidate) == body or self.new.get(candidate) == code:
|
|
185
|
+
return candidate
|
|
186
|
+
if candidate not in self.taken and candidate not in self.new:
|
|
187
|
+
self.new[candidate] = code
|
|
188
|
+
return candidate
|
|
189
|
+
return "typing.Any"
|
|
190
|
+
|
|
191
|
+
def with_classes(self, source: str) -> str:
|
|
192
|
+
"""``source`` with the new classes after the imports, or after the generated ones."""
|
|
193
|
+
if not self.new:
|
|
194
|
+
return source
|
|
195
|
+
eol = "\r\n" if "\r\n" in source else "\n"
|
|
196
|
+
blocks = "\n\n".join(f"{MARKER}\n{code}" for code in self.new.values())
|
|
197
|
+
blocks = blocks.replace("\n", eol)
|
|
198
|
+
groups = list(_GROUP.finditer(source))
|
|
199
|
+
if groups:
|
|
200
|
+
at = groups[-1].end()
|
|
201
|
+
return source[:at] + blocks + eol * 2 + source[at:]
|
|
202
|
+
header = [stmt for stmt in self.tree.body if isinstance(stmt, ast.Import | ast.ImportFrom)]
|
|
203
|
+
line = header[-1].end_lineno if header else 0
|
|
204
|
+
at = sum(len(text) for text in self.lines[: line or 0])
|
|
205
|
+
if at and not source[:at].endswith("\n"):
|
|
206
|
+
source = source[:at] + eol + source[at:]
|
|
207
|
+
at += len(eol)
|
|
208
|
+
return source[:at] + eol * 2 + blocks + eol * 2 + source[at:]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _class(name: str, fields: list[tuple[str, str]]) -> str:
|
|
212
|
+
if fields and all(key.isidentifier() and not keyword.iskeyword(key) for key, _ in fields):
|
|
213
|
+
body = "".join(f" {key}: {text}\n" for key, text in fields)
|
|
214
|
+
return f"class {name}(typing.TypedDict):\n{body}"
|
|
215
|
+
items = ", ".join(f"{key!r}: {text}" for key, text in fields)
|
|
216
|
+
return f"{name} = typing.TypedDict({name!r}, {{{items}}})\n"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _pascal(name: str) -> str:
|
|
220
|
+
"""``order_lines`` as ``OrderLines``; a name with no letters is ``Json``."""
|
|
221
|
+
words = re.findall(r"[A-Za-z0-9]+", name)
|
|
222
|
+
text = "".join(word[:1].upper() + word[1:] for word in words)
|
|
223
|
+
if not text or text[0].isdigit():
|
|
224
|
+
text = "Json" + text
|
|
225
|
+
return text
|