cg-code-graph 0.10.1__py3-none-any.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 (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
@@ -0,0 +1,339 @@
1
+ """Wire schemas: django-ninja / pydantic Schema classes and DRF serializers -> field lists.
2
+
3
+ Field dict: {name, json, type, nullable, required, read_only, write_only, many, ref (class qual of a nested
4
+ schema), source, file, line, choices}. Stored on the class node as attrs.schema_fields so the cross-repo
5
+ payload check can compare them with client JSON keys.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import re
11
+
12
+ from ..python.plugin import ClassInfo, Ctx, PyProgram, ann_base, ann_text, const_str, dotted, is_nullable_ann, kwarg
13
+
14
+ PY_TYPES = {"str": "str", "int": "int", "float": "float", "bool": "bool", "dict": "dict", "Dict": "dict", "list": "list",
15
+ "List": "list", "datetime": "datetime", "date": "date", "time": "time", "UUID": "uuid", "Decimal": "decimal",
16
+ "Any": "any", "EmailStr": "str", "HttpUrl": "str", "AnyUrl": "str", "conint": "int", "constr": "str",
17
+ "confloat": "float", "Json": "json", "UploadedFile": "file", "bytes": "bytes", "timedelta": "duration",
18
+ "Literal": "str", "Enum": "str", "PositiveInt": "int", "NonNegativeInt": "int", "StrictStr": "str",
19
+ "StrictInt": "int", "StrictBool": "bool", "set": "list", "tuple": "list", "Sequence": "list", "object": "any"}
20
+ DRF_TYPES = {"CharField": "str", "EmailField": "str", "RegexField": "str", "SlugField": "str", "URLField": "str",
21
+ "UUIDField": "uuid", "IPAddressField": "str", "IntegerField": "int", "FloatField": "float",
22
+ "DecimalField": "decimal", "BooleanField": "bool", "NullBooleanField": "bool", "DateTimeField": "datetime",
23
+ "DateField": "date", "TimeField": "time", "DurationField": "duration", "ChoiceField": "str",
24
+ "MultipleChoiceField": "list", "FileField": "file", "ImageField": "file", "ListField": "list",
25
+ "DictField": "dict", "HStoreField": "dict", "JSONField": "json", "ReadOnlyField": "any",
26
+ "HiddenField": "any", "SerializerMethodField": "any", "PrimaryKeyRelatedField": "int",
27
+ "HyperlinkedRelatedField": "str", "SlugRelatedField": "str", "StringRelatedField": "str",
28
+ "HyperlinkedIdentityField": "str", "ModelField": "any", "Field": "any", "ManyRelatedField": "list"}
29
+ MODEL_TO_WIRE = {"fk": "int", "m2m": "list", "uuid": "str", "decimal": "str", "datetime": "datetime", "date": "date",
30
+ "time": "str", "json": "json", "file": "str", "duration": "str"}
31
+
32
+
33
+ def camel(s: str) -> str:
34
+ p = s.split("_")
35
+ return p[0] + "".join(x[:1].upper() + x[1:] for x in p[1:])
36
+
37
+
38
+ class Schemas:
39
+ def __init__(self, prog: PyProgram, models):
40
+ self.prog = prog
41
+ self.models = models
42
+ self.cache: dict[str, list | None] = {}
43
+
44
+ def kind(self, c: ClassInfo) -> str | None:
45
+ p = self.prog
46
+ if p.subclass_of(c, "ninja.ModelSchema", "ninja.orm.metaclass.ModelSchema", "ninja.orm.ModelSchema"):
47
+ return "ninja_model"
48
+ if p.subclass_of(c, "ninja.Schema", "ninja.schema.Schema", "ninja.FilterSchema", "ninja.filter_schema.FilterSchema"):
49
+ return "ninja"
50
+ if p.subclass_of(c, "pydantic.BaseModel", "pydantic.main.BaseModel", "pydantic.v1.BaseModel"):
51
+ return "pydantic"
52
+ if p.subclass_of(c, "serializers.ModelSerializer", "serializers.HyperlinkedModelSerializer"):
53
+ return "drf_model"
54
+ if p.subclass_of(c, "serializers.Serializer", "serializers.BaseSerializer", "serializers.ListSerializer"):
55
+ return "drf"
56
+ return None
57
+
58
+ def fields(self, c: ClassInfo) -> list | None:
59
+ if c.qual in self.cache:
60
+ return self.cache[c.qual]
61
+ self.cache[c.qual] = []
62
+ k = self.kind(c)
63
+ out = None
64
+ if k in ("ninja", "pydantic", "ninja_model"):
65
+ out = self._pyd(c, k)
66
+ elif k in ("drf", "drf_model"):
67
+ out = self._drf(c, k)
68
+ self.cache[c.qual] = out
69
+ return out
70
+
71
+ # ---- pydantic / ninja
72
+ def _alias_gen(self, c: ClassInfo) -> str | None:
73
+ for k in [c] + [b[1] for b in self.prog.mro(c) if b[0] == "type"]:
74
+ cfg = k.attrs.get("model_config")
75
+ if cfg and isinstance(cfg[0], ast.Call):
76
+ ag = kwarg(cfg[0], "alias_generator")
77
+ if ag is not None:
78
+ return (dotted(ag) or "").split(".")[-1]
79
+ for nm in ("Config", "Meta"):
80
+ inner = k.inner.get(nm)
81
+ if inner is None:
82
+ continue
83
+ for st in inner.body:
84
+ if isinstance(st, ast.Assign) and isinstance(st.targets[0], ast.Name) and st.targets[0].id == "alias_generator":
85
+ return (dotted(st.value) or "").split(".")[-1]
86
+ return None
87
+
88
+ def _pyd(self, c: ClassInfo, kind: str) -> list:
89
+ prog = self.prog
90
+ chain = [c] + [b[1] for b in prog.mro(c) if b[0] == "type" and self.kind(b[1])]
91
+ fields: dict[str, dict] = {}
92
+ for k in reversed(chain):
93
+ if self.kind(k) == "ninja_model" or kind == "ninja_model":
94
+ for f in self._model_schema_fields(k):
95
+ fields[f["name"]] = f
96
+ for name, (val, line, ann) in k.attrs.items():
97
+ if ann is None or name.startswith("_") or name in ("model_config", "Config", "Meta"):
98
+ continue
99
+ if (dotted(ann) or "").endswith("ClassVar") or (isinstance(ann, ast.Subscript) and (dotted(ann.value) or "").endswith("ClassVar")):
100
+ continue
101
+ fd = {"name": name, "json": name, "file": k.file, "line": line}
102
+ fd.update(self.ann_info(k, ann))
103
+ fd["nullable"] = fd.get("nullable") or is_nullable_ann(ann)
104
+ required = val is None
105
+ if isinstance(val, ast.Call) and (dotted(val.func) or "").split(".")[-1] in ("Field", "Query", "Body", "Form", "Path", "File"):
106
+ d0 = val.args[0] if val.args else kwarg(val, "default")
107
+ required = d0 is None and kwarg(val, "default_factory") is None or (isinstance(d0, ast.Constant) and d0.value is Ellipsis)
108
+ al = const_str(kwarg(val, "alias")) or const_str(kwarg(val, "serialization_alias"))
109
+ if al:
110
+ fd["alias"] = al
111
+ if isinstance(d0, ast.Constant) and d0.value is None:
112
+ fd["default_none"] = True
113
+ elif isinstance(val, ast.Constant) and val.value is None:
114
+ fd["default_none"] = True
115
+ fd["required"] = bool(required)
116
+ fields[name] = fd
117
+ for name, f in k.methods.items():
118
+ if name.startswith("resolve_") and name[8:] in fields:
119
+ fields[name[8:]]["resolver"] = f"{f.file}:{f.line}"
120
+ ag = self._alias_gen(c)
121
+ for fd in fields.values():
122
+ if ag and "camel" in ag.lower():
123
+ fd["json_in"] = camel(fd["name"])
124
+ if kind == "pydantic":
125
+ fd["json"] = camel(fd["name"])
126
+ if kind == "pydantic" and fd.get("alias"):
127
+ fd["json"] = fd["alias"]
128
+ return list(fields.values())
129
+
130
+ def ann_info(self, k: ClassInfo, ann) -> dict:
131
+ e, cont = ann_base(ann)
132
+ out = {"type_text": ann_text(ann)}
133
+ if cont == "list":
134
+ out["many"] = True
135
+ if e is None:
136
+ out["type"] = "list" if cont == "list" else ("dict" if "Dict" in (ann_text(ann) or "") or "dict" in (ann_text(ann) or "") else "any")
137
+ return out
138
+ if isinstance(e, ast.Subscript):
139
+ head = (dotted(e.value) or "").split(".")[-1]
140
+ if head == "Literal":
141
+ sl = e.slice
142
+ vals = [x.value for x in (sl.elts if isinstance(sl, ast.Tuple) else [sl]) if isinstance(x, ast.Constant)]
143
+ out["choices"] = vals
144
+ out["type"] = "str" if all(isinstance(v, str) for v in vals) else "any"
145
+ return out
146
+ out["type"] = PY_TYPES.get(head, "any")
147
+ return out
148
+ d = dotted(e)
149
+ t = self.prog.infer(e, Ctx(k.module, None, None))
150
+ if t and t[0] == "type":
151
+ if self.kind(t[1]):
152
+ out["ref"] = t[1].qual
153
+ out["type"] = "object"
154
+ return out
155
+ if self.prog.subclass_of(t[1], "Enum", "TextChoices", "IntegerChoices", "StrEnum", "IntEnum", "Choices"):
156
+ out["type"] = "int" if self.prog.subclass_of(t[1], "IntegerChoices", "IntEnum") else "str"
157
+ out["choices"] = self.models.enum_values(t[1]) if self.models else None
158
+ out["enum"] = t[1].qual
159
+ return out
160
+ last = (d or "").split(".")[-1]
161
+ out["type"] = PY_TYPES.get(last, "any")
162
+ return out
163
+
164
+ def str_list(self, m, e, depth=0):
165
+ """Literal list/tuple of strings, possibly via a module var or `a + b` concatenation; else None."""
166
+ if depth > 5 or e is None:
167
+ return None
168
+ if isinstance(e, (ast.List, ast.Tuple, ast.Set)):
169
+ vals = [const_str(x) for x in e.elts]
170
+ return [v for v in vals if v is not None]
171
+ if isinstance(e, ast.Constant) and e.value == "__all__":
172
+ return "__all__"
173
+ if isinstance(e, ast.BinOp) and isinstance(e.op, ast.Add):
174
+ a, b = self.str_list(m, e.left, depth + 1), self.str_list(m, e.right, depth + 1)
175
+ if isinstance(a, list) and isinstance(b, list):
176
+ return a + b
177
+ return None
178
+ if isinstance(e, (ast.Name, ast.Attribute)):
179
+ t = self.prog.infer(e, Ctx(m, None, None))
180
+ if t and t[0] == "var":
181
+ return self.str_list(t[1], self.prog.var_value(t[1], t[2]), depth + 1)
182
+ return None
183
+
184
+ def _meta_opts(self, c: ClassInfo) -> dict:
185
+ out = {}
186
+ for nm in ("Meta", "Config"):
187
+ inner = c.inner.get(nm)
188
+ if inner is None:
189
+ continue
190
+ for st in inner.body:
191
+ if isinstance(st, ast.Assign) and isinstance(st.targets[0], ast.Name):
192
+ key = st.targets[0].id
193
+ v = st.value
194
+ if isinstance(v, ast.Constant):
195
+ out[key] = v.value
196
+ elif isinstance(v, ast.Dict):
197
+ out[key] = v
198
+ elif key in ("fields", "exclude", "read_only_fields", "model_fields", "model_exclude", "fields_optional",
199
+ "model_fields_optional", "brief_fields"):
200
+ sl = self.str_list(c.module, v)
201
+ if sl is not None:
202
+ out[key] = sl
203
+ else:
204
+ out[key + "_unresolved"] = ann_text(v)
205
+ else:
206
+ out[key] = v
207
+ return out
208
+
209
+ def _model_of(self, c: ClassInfo, opts: dict):
210
+ m = opts.get("model")
211
+ if isinstance(m, ast.AST):
212
+ t = self.prog.infer(m, Ctx(c.module, None, None))
213
+ if t and t[0] == "type" and self.models and t[1].qual in self.models.models:
214
+ return t[1].qual
215
+ return None
216
+
217
+ def _model_schema_fields(self, c: ClassInfo) -> list:
218
+ opts = self._meta_opts(c)
219
+ mq = self._model_of(c, opts)
220
+ if not mq:
221
+ return []
222
+ mf = self.models.models[mq]["fields"]
223
+ sel = opts.get("fields") if opts.get("fields") is not None else opts.get("model_fields")
224
+ exclude = opts.get("exclude") or opts.get("model_exclude") or []
225
+ exclude = set(exclude) if isinstance(exclude, list) else set()
226
+ names = list(mf) if not isinstance(sel, list) else [x for x in sel if x in mf]
227
+ opt = opts.get("fields_optional") or opts.get("model_fields_optional")
228
+ out = []
229
+ for n in names:
230
+ if n in exclude or mf[n].get("virtual") or mf[n]["type"] == "m2m_rev":
231
+ continue
232
+ fd = mf[n]
233
+ t = MODEL_TO_WIRE.get(fd["type"], fd["type"])
234
+ out.append({"name": n, "json": n, "type": t, "nullable": bool(fd.get("null")), "required": not (fd.get("null") or fd.get("has_default") or fd.get("blank") or fd.get("auto") or opt == "__all__" or (isinstance(opt, list) and n in opt)),
235
+ "file": fd["file"], "line": fd["line"], "from_model": mq, **({"choices": fd["choices"]} if fd.get("choices") else {})})
236
+ return out
237
+
238
+ # ---- DRF
239
+ def _drf(self, c: ClassInfo, kind: str) -> list:
240
+ prog = self.prog
241
+ chain = [c] + [b[1] for b in prog.mro(c) if b[0] == "type" and self.kind(b[1])]
242
+ fields: dict[str, dict] = {}
243
+ opts = {}
244
+ for k in reversed(chain):
245
+ o = self._meta_opts(k)
246
+ if o:
247
+ opts = {**opts, **o}
248
+ mq = self._model_of(c, opts) or next((self._model_of(k, self._meta_opts(k)) for k in chain if self._meta_opts(k)), None)
249
+ if kind == "drf_model" and mq:
250
+ mf = self.models.models[mq]["fields"]
251
+ sel = opts.get("fields")
252
+ exclude = set(opts.get("exclude")) if isinstance(opts.get("exclude"), list) else set()
253
+ names = [n for n in mf if not mf[n].get("virtual") and mf[n]["type"] != "m2m_rev"] if sel in ("__all__", None) else list(sel)
254
+ ro = set(opts.get("read_only_fields") or []) if isinstance(opts.get("read_only_fields"), list) else set()
255
+ if not (sel in ("__all__", None) or isinstance(sel, list)):
256
+ sel = None
257
+ xk = {}
258
+ if isinstance(opts.get("extra_kwargs"), ast.Dict):
259
+ for kk, vv in zip(opts["extra_kwargs"].keys, opts["extra_kwargs"].values):
260
+ s = const_str(kk)
261
+ if s and isinstance(vv, ast.Dict):
262
+ xk[s] = {const_str(a): (b.value if isinstance(b, ast.Constant) else None) for a, b in zip(vv.keys, vv.values)}
263
+ for n in names:
264
+ if n in exclude:
265
+ continue
266
+ fd = mf.get(n)
267
+ if fd is None:
268
+ fields[n] = {"name": n, "json": n, "type": "any", "file": c.file, "line": c.line, "unresolved": True,
269
+ "required": False, "read_only": True}
270
+ continue
271
+ t = MODEL_TO_WIRE.get(fd["type"], fd["type"])
272
+ f2 = {"name": n, "json": n, "type": t, "nullable": bool(fd.get("null")), "file": fd["file"], "line": fd["line"],
273
+ "from_model": mq, "read_only": n in ro or bool(fd.get("auto")) or fd.get("editable") is False,
274
+ "required": not (fd.get("null") or fd.get("blank") or fd.get("has_default") or fd.get("auto"))}
275
+ if fd.get("choices"):
276
+ f2["choices"] = fd["choices"]
277
+ e = xk.get(n) or {}
278
+ for kk in ("read_only", "write_only", "required", "allow_null"):
279
+ if kk in e and e[kk] is not None:
280
+ f2["nullable" if kk == "allow_null" else kk] = e[kk]
281
+ fields[n] = f2
282
+ for k in reversed(chain):
283
+ for name, (val, line, ann) in k.attrs.items():
284
+ if not isinstance(val, ast.Call) or name in ("Meta",):
285
+ continue
286
+ t = prog.infer(val.func, Ctx(k.module, None, None))
287
+ fcls = None
288
+ ref = None
289
+ if t and t[0] == "ext":
290
+ fcls = t[1].split(".")[-1]
291
+ if fcls not in DRF_TYPES and not fcls.endswith("Field") and not fcls.endswith("Serializer"):
292
+ continue
293
+ elif t and t[0] == "type":
294
+ if self.kind(t[1]) in ("drf", "drf_model"):
295
+ ref = t[1].qual
296
+ fcls = "Serializer"
297
+ elif prog.subclass_of(t[1], "Field", "RelatedField", "serializers.Field"):
298
+ fcls = next((b.split(".")[-1] for b in prog.ext_bases(t[1]) if b.split(".")[-1] in DRF_TYPES), "Field")
299
+ else:
300
+ continue
301
+ else:
302
+ continue
303
+ kw = {x.arg: (x.value.value if isinstance(x.value, ast.Constant) else x.value) for x in val.keywords if x.arg}
304
+ fd = {"name": name, "json": name, "file": k.file, "line": line, "field_class": fcls,
305
+ "type": "object" if ref else DRF_TYPES.get(fcls, "any"),
306
+ "read_only": kw.get("read_only") is True or fcls in ("SerializerMethodField", "ReadOnlyField", "HyperlinkedIdentityField", "StringRelatedField"),
307
+ "write_only": kw.get("write_only") is True,
308
+ "nullable": kw.get("allow_null") is True}
309
+ fd["required"] = (kw.get("required") is not False) and not fd["read_only"] and "default" not in kw
310
+ if ref:
311
+ fd["ref"] = ref
312
+ if kw.get("many") is True or fcls in ("ListField", "ManyRelatedField", "MultipleChoiceField"):
313
+ fd["many"] = True
314
+ src = kw.get("source")
315
+ if isinstance(src, str):
316
+ fd["source"] = src
317
+ if mq and src.split(".")[0] in self.models.models[mq]["fields"]:
318
+ mfd = self.models.models[mq]["fields"][src.split(".")[0]]
319
+ if "." not in src:
320
+ fd["nullable"] = fd["nullable"] or bool(mfd.get("null"))
321
+ elif mq and name in self.models.models[mq]["fields"] and fd["type"] in ("any",):
322
+ mfd = self.models.models[mq]["fields"][name]
323
+ fd["type"] = MODEL_TO_WIRE.get(mfd["type"], mfd["type"])
324
+ ch = kw.get("choices")
325
+ if isinstance(ch, ast.AST) and self.models:
326
+ vals = self.models.choices(k, ch)
327
+ if vals:
328
+ fd["choices"] = vals
329
+ fields[name] = fd
330
+ return list(fields.values())
331
+
332
+ def describe(self, qual: str) -> dict | None:
333
+ c = self.prog.classes.get(qual)
334
+ if c is None:
335
+ return None
336
+ fs = self.fields(c)
337
+ if fs is None:
338
+ return None
339
+ return {"class": qual, "kind": self.kind(c), "fields": fs, "file": c.file, "line": c.line}
@@ -0,0 +1,216 @@
1
+ """Nested JSON shapes of values a Django/ninja view returns (dict literals, helper functions building
2
+ dicts, Schema.from_orm(...).dict(), list comprehensions), with value types inferred from model
3
+ fields (`obj.title` -> CharField -> str, null=True -> nullable) and literals.
4
+
5
+ shape = {key: {"line", "file", "type", "nullable", "many", "children": shape | None, "schema": qual | None}}
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+
11
+ from ..python.plugin import Ctx, FuncInfo, const_str, dotted
12
+
13
+ PY_CASTS = {"str": "str", "int": "int", "float": "float", "bool": "bool", "len": "int", "round": "float", "sum": "int",
14
+ "Decimal": "decimal", "list": "list", "dict": "dict", "sorted": "list"}
15
+ STR_METHODS = {"isoformat", "strftime", "lower", "upper", "strip", "format", "join", "replace", "get_full_name", "__str__",
16
+ "get_absolute_url", "build_absolute_uri", "hex"}
17
+
18
+
19
+ class ShapeBuilder:
20
+ def __init__(self, plugin):
21
+ self.p = plugin
22
+ self.prog = plugin.prog
23
+ self.models = plugin.models
24
+
25
+ def returns(self, f: FuncInfo, depth=0) -> list[tuple[ast.AST, int | None]]:
26
+ out = []
27
+ for sub in ast.walk(f.node):
28
+ if isinstance(sub, ast.Return) and sub.value is not None:
29
+ v, status = sub.value, None
30
+ if isinstance(v, ast.Tuple) and len(v.elts) == 2 and isinstance(v.elts[0], ast.Constant):
31
+ status, v = v.elts[0].value, v.elts[1]
32
+ out.append((v, status, sub.lineno))
33
+ return out
34
+
35
+ def shape(self, e, ctx: Ctx, env=None, depth=0) -> dict | None:
36
+ env = env or {}
37
+ if depth > 5 or e is None:
38
+ return None
39
+ if isinstance(e, ast.Dict):
40
+ out = {}
41
+ for k, v in zip(e.keys, e.values):
42
+ if k is None: # **spread
43
+ sub = self.shape(v, ctx, env, depth + 1)
44
+ if sub:
45
+ out.update(sub)
46
+ continue
47
+ name = const_str(k)
48
+ if name is None:
49
+ continue
50
+ out[name] = dict(self.value(v, ctx, env, depth + 1), line=v.lineno, file=ctx.mod.file)
51
+ return out
52
+ if isinstance(e, ast.Call):
53
+ fn = (dotted(e.func) or "").split(".")[-1]
54
+ if fn == "dict" and e.keywords:
55
+ return {k.arg: dict(self.value(k.value, ctx, env, depth + 1), line=k.value.lineno, file=ctx.mod.file)
56
+ for k in e.keywords if k.arg}
57
+ if fn in ("JsonResponse", "Response", "JSONResponse", "HttpResponse") and e.args:
58
+ return self.shape(e.args[0], ctx, env, depth + 1)
59
+ if isinstance(e.func, ast.Attribute) and e.func.attr in ("dict", "model_dump") and isinstance(e.func.value, ast.Call):
60
+ return self.schema_shape(e.func.value, ctx)
61
+ sc = self.schema_shape(e, ctx)
62
+ if sc is not None:
63
+ return sc
64
+ t = self.prog.infer(e.func, ctx)
65
+ if t and t[0] in ("func", "bound") and depth < 4:
66
+ f = t[1]
67
+ c2 = Ctx(f.module, f, f.cls)
68
+ merged: dict = {}
69
+ for v, status, line in self.returns(f):
70
+ s = self.shape(v, c2, {}, depth + 1)
71
+ if s:
72
+ for k, info in s.items():
73
+ merged.setdefault(k, info)
74
+ return merged or None
75
+ return None
76
+ if isinstance(e, ast.Name) and ctx.func is not None:
77
+ if e.id in env:
78
+ return None
79
+ out = {}
80
+ for val, ann, kind in self.prog.local_vars(ctx).get(e.id, []):
81
+ if val is not None and not isinstance(val, ast.Name):
82
+ s = self.shape(val, ctx, env, depth + 1)
83
+ if s:
84
+ out.update(s)
85
+ for sub in ast.walk(ctx.func.node): # d["k"] = v
86
+ if isinstance(sub, ast.Assign):
87
+ for t in sub.targets:
88
+ if isinstance(t, ast.Subscript) and isinstance(t.value, ast.Name) and t.value.id == e.id and const_str(t.slice):
89
+ out.setdefault(const_str(t.slice), dict(self.value(sub.value, ctx, env, depth + 1), line=sub.lineno,
90
+ file=ctx.mod.file, conditional=True))
91
+ return out or None
92
+ return None
93
+
94
+ def schema_shape(self, call: ast.Call, ctx: Ctx) -> dict | None:
95
+ """Schema.from_orm(x) / Schema.model_validate(x) / Schema(**kw) / Serializer(x).data -> declared fields."""
96
+ f = call.func
97
+ tgt = f.value if isinstance(f, ast.Attribute) and f.attr in ("from_orm", "model_validate", "from_attributes", "validate") else f
98
+ t = self.prog.infer(tgt, ctx)
99
+ if t and t[0] == "type" and self.p.schemas.kind(t[1]):
100
+ return self.fields_shape(t[1].qual)
101
+ return None
102
+
103
+ def fields_shape(self, qual: str, depth=0) -> dict:
104
+ c = self.prog.classes.get(qual)
105
+ out = {}
106
+ for fd in (self.p.schemas.fields(c) if c else []) or []:
107
+ ch = self.fields_shape(fd["ref"], depth + 1) if fd.get("ref") and depth < 4 else None
108
+ out[fd.get("json") or fd["name"]] = {"type": fd.get("type"), "nullable": fd.get("nullable"), "many": fd.get("many"),
109
+ "children": ch, "schema": qual, "line": fd.get("line"), "file": fd.get("file"),
110
+ "choices": fd.get("choices")}
111
+ return out
112
+
113
+ def value(self, v, ctx: Ctx, env, depth) -> dict:
114
+ if isinstance(v, ast.Constant):
115
+ if v.value is None:
116
+ return {"type": "null", "nullable": True}
117
+ return {"type": {str: "str", int: "int", float: "float", bool: "bool"}.get(type(v.value), "any")}
118
+ if isinstance(v, ast.JoinedStr):
119
+ return {"type": "str"}
120
+ if isinstance(v, (ast.Dict,)) or (isinstance(v, ast.Call) and (dotted(v.func) or "").split(".")[-1] == "dict"):
121
+ return {"type": "dict", "children": self.shape(v, ctx, env, depth + 1)}
122
+ if isinstance(v, (ast.List, ast.Tuple)):
123
+ el = v.elts[0] if v.elts else None
124
+ info = self.value(el, ctx, env, depth + 1) if el is not None else {}
125
+ return {"type": "list", "many": True, "children": info.get("children"), "item_type": info.get("type")}
126
+ if isinstance(v, ast.ListComp):
127
+ env2 = dict(env)
128
+ g = v.generators[0]
129
+ if isinstance(g.target, ast.Name):
130
+ it = self.prog.infer(g.iter, ctx)
131
+ env2[g.target.id] = self.prog.iter_type(it) or (it[1] if it and it[0] == "list" else None)
132
+ info = self.value(v.elt, ctx, env2, depth + 1)
133
+ return {"type": "list", "many": True, "children": info.get("children"), "item_type": info.get("type")}
134
+ if isinstance(v, ast.IfExp):
135
+ a = self.value(v.body, ctx, env, depth + 1)
136
+ b = self.value(v.orelse, ctx, env, depth + 1)
137
+ base = a if a.get("type") not in (None, "null") else (b if b.get("type") not in (None, "null") else dict(a, type=None))
138
+ return dict(base, nullable=bool(a.get("nullable") or b.get("nullable") or a.get("type") == "null" or b.get("type") == "null"))
139
+ if isinstance(v, ast.BoolOp):
140
+ return self.value(v.values[-1], ctx, env, depth + 1) if isinstance(v.op, ast.Or) else {"type": "any"}
141
+ if isinstance(v, ast.Compare) or (isinstance(v, ast.UnaryOp) and isinstance(v.op, ast.Not)):
142
+ return {"type": "bool"}
143
+ if isinstance(v, ast.BinOp):
144
+ l, r = self.value(v.left, ctx, env, depth + 1), self.value(v.right, ctx, env, depth + 1)
145
+ if "str" in (l.get("type"), r.get("type")):
146
+ return {"type": "str"}
147
+ if l.get("type") in ("int", "float") and r.get("type") in ("int", "float"):
148
+ return {"type": "float" if "float" in (l.get("type"), r.get("type")) or isinstance(v.op, ast.Div) else "int"}
149
+ return {"type": None}
150
+ if isinstance(v, ast.Call):
151
+ fn = dotted(v.func) or ""
152
+ last = fn.split(".")[-1]
153
+ if last in PY_CASTS and "." not in fn:
154
+ return {"type": PY_CASTS[last]}
155
+ if isinstance(v.func, ast.Attribute) and v.func.attr in STR_METHODS:
156
+ inner = self.value(v.func.value, ctx, env, depth + 1)
157
+ return {"type": "str", "nullable": False}
158
+ if isinstance(v.func, ast.Attribute) and v.func.attr in ("count",):
159
+ return {"type": "int"}
160
+ if isinstance(v.func, ast.Attribute) and v.func.attr in ("dict", "model_dump") and isinstance(v.func.value, ast.Call):
161
+ return {"type": "dict", "children": self.schema_shape(v.func.value, ctx)}
162
+ s = self.shape(v, ctx, env, depth + 1)
163
+ if s:
164
+ return {"type": "dict", "children": s}
165
+ t = self.prog.infer(v.func, ctx)
166
+ if t and t[0] in ("func", "bound"):
167
+ f = t[1]
168
+ rets = self.returns(f)
169
+ if rets:
170
+ vv = rets[0][0]
171
+ if isinstance(vv, (ast.List, ast.ListComp)):
172
+ return self.value(vv, Ctx(f.module, f, f.cls), {}, depth + 1)
173
+ return {"type": None}
174
+ if isinstance(v, ast.Attribute):
175
+ return self.attr_value(v, ctx, env, depth)
176
+ if isinstance(v, ast.Name):
177
+ if v.id in env:
178
+ return {"type": "model", "model": env[v.id][1].qual} if env[v.id] and env[v.id][0] == "inst" else {"type": None}
179
+ vals = [val for val, ann, kind in self.prog.local_vars(ctx).get(v.id, []) if val is not None] if ctx.func else []
180
+ if ctx.func and any(isinstance(x, ast.List) and not x.elts for x in vals):
181
+ for sub in ast.walk(ctx.func.node): # items = []; items.append({...})
182
+ if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) and sub.func.attr == "append" \
183
+ and isinstance(sub.func.value, ast.Name) and sub.func.value.id == v.id and sub.args:
184
+ info = self.value(sub.args[0], ctx, {**env, v.id: None}, depth + 1)
185
+ return {"type": "list", "many": True, "children": info.get("children"), "item_type": info.get("type")}
186
+ if len(vals) >= 1 and depth < 4:
187
+ infos = [self.value(x, ctx, {**env, v.id: None}, depth + 1) for x in vals[:3]]
188
+ base = next((i for i in infos if i.get("type") not in (None, "null")), infos[0])
189
+ return dict(base, nullable=any(i.get("nullable") or i.get("type") == "null" for i in infos) or base.get("nullable"))
190
+ return {"type": None}
191
+ if isinstance(v, ast.Subscript):
192
+ return {"type": None}
193
+ return {"type": None}
194
+
195
+ def attr_value(self, v: ast.Attribute, ctx: Ctx, env, depth) -> dict:
196
+ base = v.value
197
+ if isinstance(base, ast.Name) and base.id in env and env[base.id] and env[base.id][0] == "inst":
198
+ t = env[base.id]
199
+ else:
200
+ t = self.prog.infer(base, ctx)
201
+ if isinstance(base, ast.Attribute) and v.attr in ("url", "name", "path"):
202
+ inner = self.attr_value(base, ctx, env, depth + 1)
203
+ if inner.get("type") == "file":
204
+ return {"type": "str", "nullable": inner.get("nullable"), "model_field": inner.get("model_field")}
205
+ if t and t[0] == "inst":
206
+ mf = self.models.field(t[1].qual, v.attr)
207
+ if mf:
208
+ nullable = bool(mf.get("null")) or (mf.get("type") == "file" and bool(mf.get("blank")))
209
+ typ = mf.get("type")
210
+ if typ == "fk":
211
+ typ = "model"
212
+ return {"type": typ, "nullable": nullable, "model_field": f"{t[1].qual}.{v.attr}", "choices": mf.get("choices"),
213
+ "field_line": mf.get("line"), "field_file": mf.get("file")}
214
+ if v.attr == "id" or v.attr == "pk":
215
+ return {"type": "int"}
216
+ return {"type": None}