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/_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