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,508 @@
1
+ """Django models -> tables/columns/relations, ORM type rules and ORM read/write edges."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+ import re
6
+
7
+ from ...core.model import EXACT, HEURISTIC, RESOLVED
8
+ from ..python.plugin import ClassInfo, Ctx, PyProgram, const_str, dotted, kwarg, walk_body
9
+
10
+ MODEL_BASES = ("django.db.models.Model", "django.db.models.base.Model", "models.Model")
11
+ MODEL_BASE_NAMES = {"Model", "AbstractUser", "AbstractBaseUser", "PermissionsMixin", "MPTTModel", "TimeStampedModel",
12
+ "PolymorphicModel", "ClusterableModel", "Page", "TranslatableMixin", "AbstractBaseSession",
13
+ "AbstractImage", "AbstractDocument", "AbstractRendition", "TitleSlugDescriptionModel",
14
+ "SoftDeletableModel", "StatusModel", "UUIDModel", "OrderedModel", "TreeNode", "MP_Node", "NS_Node",
15
+ "AL_Node", "PostgresModel", "ContentType"}
16
+ # Field class name -> wire/value type
17
+ FIELD_TYPES = {
18
+ "AutoField": "int", "BigAutoField": "int", "SmallAutoField": "int", "IntegerField": "int", "BigIntegerField": "int",
19
+ "SmallIntegerField": "int", "PositiveIntegerField": "int", "PositiveSmallIntegerField": "int",
20
+ "PositiveBigIntegerField": "int", "FloatField": "float", "DecimalField": "decimal", "BooleanField": "bool",
21
+ "NullBooleanField": "bool", "CharField": "str", "TextField": "str", "SlugField": "str", "EmailField": "str",
22
+ "URLField": "str", "UUIDField": "uuid", "DateTimeField": "datetime", "DateField": "date", "TimeField": "time",
23
+ "DurationField": "duration", "JSONField": "json", "FileField": "file", "ImageField": "file", "FilePathField": "str",
24
+ "GenericIPAddressField": "str", "IPAddressField": "str", "BinaryField": "bytes", "ArrayField": "list",
25
+ "HStoreField": "dict", "ForeignKey": "fk", "OneToOneField": "fk", "ManyToManyField": "m2m",
26
+ "TreeForeignKey": "fk", "ParentalKey": "fk", "ParentalManyToManyField": "m2m", "CountryField": "str",
27
+ "PhoneNumberField": "str", "MoneyField": "decimal", "RichTextField": "str", "StreamField": "json",
28
+ "ColorField": "str", "TaggableManager": "m2m", "GenericForeignKey": "generic", "GenericRelation": "m2m_rev",
29
+ "SearchVectorField": "str", "CICharField": "str", "CIEmailField": "str", "CITextField": "str", "MACAddressField": "str",
30
+ }
31
+ QS_METHODS = {"filter", "exclude", "all", "order_by", "select_related", "prefetch_related", "annotate", "distinct",
32
+ "only", "defer", "using", "select_for_update", "reverse", "none", "union", "intersection", "difference",
33
+ "extra", "alias", "filter_by", "active", "visible", "published", "for_user", "restrict"}
34
+ INST_METHODS = {"get", "first", "last", "latest", "earliest", "create", "get_by_natural_key"}
35
+ TUPLE_METHODS = {"get_or_create", "update_or_create"}
36
+ READ_METHODS = QS_METHODS | {"get", "first", "last", "latest", "earliest", "count", "exists", "values", "values_list",
37
+ "aggregate", "iterator", "in_bulk", "raw", "dates", "datetimes", "contains", "explain",
38
+ "aget", "afirst", "alast", "acount", "aexists", "aiterator", "aaggregate"}
39
+ WRITE_METHODS = {"create", "update", "delete", "bulk_create", "bulk_update", "get_or_create", "update_or_create",
40
+ "acreate", "aupdate", "adelete", "abulk_create", "abulk_update", "aget_or_create", "aupdate_or_create"}
41
+ LOOKUP_KW = {"filter", "exclude", "get", "get_or_create", "update_or_create", "aget", "aget_or_create", "aupdate_or_create"}
42
+ WRITE_KW = {"create", "update", "acreate", "aupdate"}
43
+
44
+
45
+ def snake(name: str) -> str:
46
+ return name.lower()
47
+
48
+
49
+ class Models:
50
+ """Model registry over a PyProgram."""
51
+
52
+ def __init__(self, prog: PyProgram):
53
+ self.prog = prog
54
+ self.models: dict[str, dict] = {} # class qual -> info
55
+ self.by_name: dict[str, list[str]] = {} # short / app_label.Name -> quals
56
+ self.app_labels = self._app_labels()
57
+
58
+ def _app_labels(self) -> dict[str, str]:
59
+ """app package -> label (AppConfig.label overrides the last path component)."""
60
+ out = {}
61
+ for m in self.prog.modules.values():
62
+ if not m.name.endswith(".apps") and m.name != "apps":
63
+ continue
64
+ for c in m.classes.values():
65
+ if not self.prog.subclass_of(c, "AppConfig"):
66
+ continue
67
+ name = const_str((c.attrs.get("name") or (None,))[0])
68
+ label = const_str((c.attrs.get("label") or (None,))[0])
69
+ pkg = name or m.name.rpartition(".")[0]
70
+ out[pkg] = label or pkg.split(".")[-1]
71
+ out[m.name.rpartition(".")[0]] = label or (name or pkg).split(".")[-1]
72
+ return out
73
+
74
+ def is_model(self, c: ClassInfo) -> bool:
75
+ if c.qual in self.models:
76
+ return True
77
+ for b in self.prog.ext_bases(c):
78
+ last = b.split(".")[-1]
79
+ if b in MODEL_BASES or (last in MODEL_BASE_NAMES and ("models" in b or "django" in b or last == "Model")):
80
+ return True
81
+ return False
82
+
83
+ def meta(self, c: ClassInfo) -> dict:
84
+ out = {}
85
+ m = c.inner.get("Meta")
86
+ if m is None:
87
+ return out
88
+ for st in m.body:
89
+ if isinstance(st, ast.Assign) and isinstance(st.targets[0], ast.Name):
90
+ k = st.targets[0].id
91
+ v = st.value
92
+ if isinstance(v, ast.Constant):
93
+ out[k] = v.value
94
+ elif isinstance(v, (ast.List, ast.Tuple)):
95
+ out[k] = [const_str(x) for x in v.elts if const_str(x)]
96
+ else:
97
+ out[k] = v
98
+ return out
99
+
100
+ def app_label(self, c: ClassInfo) -> str:
101
+ meta = self.meta(c)
102
+ if isinstance(meta.get("app_label"), str):
103
+ return meta["app_label"]
104
+ parts = c.module.name.split(".")
105
+ if "models" in parts:
106
+ pkg = ".".join(parts[:parts.index("models")])
107
+ else:
108
+ pkg = ".".join(parts[:-1])
109
+ if pkg in self.app_labels:
110
+ return self.app_labels[pkg]
111
+ return pkg.split(".")[-1] if pkg else c.module.name.split(".")[0]
112
+
113
+ def build(self) -> None:
114
+ prog = self.prog
115
+ cands = [c for c in prog.classes.values() if c.outer is None and self.is_model(c)]
116
+ for c in cands:
117
+ meta = self.meta(c)
118
+ abstract = meta.get("abstract") is True
119
+ label = self.app_label(c)
120
+ info = {"class": c, "abstract": abstract, "label": label, "meta": meta, "fields": {}, "managers": {},
121
+ "proxy": meta.get("proxy") is True}
122
+ if not abstract:
123
+ info["table"] = meta["db_table"] if isinstance(meta.get("db_table"), str) else f"{label}_{snake(c.name)}"
124
+ info["table_explicit"] = isinstance(meta.get("db_table"), str)
125
+ self.models[c.qual] = info
126
+ self.by_name.setdefault(c.name, []).append(c.qual)
127
+ self.by_name.setdefault(f"{label}.{c.name}", []).append(c.qual)
128
+ for q, info in self.models.items():
129
+ c = info["class"]
130
+ if info["proxy"]:
131
+ for b in prog.mro(c):
132
+ if b[0] == "type" and b[1].qual in self.models and "table" in self.models[b[1].qual] and not self.models[b[1].qual]["proxy"]:
133
+ info["table"] = self.models[b[1].qual]["table"]
134
+ break
135
+ # own + abstract-inherited fields
136
+ chain = [c] + [b[1] for b in prog.mro(c) if b[0] == "type" and b[1].qual in self.models and self.models[b[1].qual]["abstract"]]
137
+ for k in reversed(chain):
138
+ for name, (val, line, ann) in k.attrs.items():
139
+ fd = self.field_def(k, name, val, line)
140
+ if fd:
141
+ info["fields"][name] = fd
142
+ mgr = self.manager_def(k, val)
143
+ if mgr is not None:
144
+ info["managers"][name] = mgr
145
+ # fields inherited from django.contrib.auth abstract bases (external, so not parsed)
146
+ ext = {b.split(".")[-1] for b in prog.lineage(c)}
147
+ implicit = []
148
+ if ext & {"AbstractBaseUser", "AbstractUser"}:
149
+ implicit += [("password", "CharField", "str", False), ("last_login", "DateTimeField", "datetime", True)]
150
+ if ext & {"AbstractUser", "PermissionsMixin"}:
151
+ implicit += [("is_superuser", "BooleanField", "bool", False)]
152
+ if "AbstractUser" in ext:
153
+ implicit += [("username", "CharField", "str", False), ("first_name", "CharField", "str", False),
154
+ ("last_name", "CharField", "str", False), ("email", "EmailField", "str", False),
155
+ ("is_staff", "BooleanField", "bool", False), ("is_active", "BooleanField", "bool", False),
156
+ ("date_joined", "DateTimeField", "datetime", False)]
157
+ for name, cls_, typ, null in implicit:
158
+ info["fields"].setdefault(name, {"name": name, "column": name, "type": typ, "class": cls_, "null": null, "line": c.line,
159
+ "file": c.file, "owner": c.qual, "inherited_from": "django.contrib.auth"})
160
+ if not any(f.get("primary_key") for f in info["fields"].values()):
161
+ parent = next((b[1] for b in prog.mro(c) if b[0] == "type" and b[1].qual in self.models and
162
+ not self.models[b[1].qual]["abstract"]), None)
163
+ pk = {"name": "id", "column": "id", "type": "int", "auto": True, "primary_key": True, "line": c.line,
164
+ "file": c.file, "null": False, "class": "AutoField"}
165
+ if parent is not None and not info["proxy"]:
166
+ pk = {**pk, "name": f"{snake(parent.name)}_ptr", "column": f"{snake(parent.name)}_ptr_id", "type": "fk",
167
+ "target": parent.qual}
168
+ info["fields"].setdefault(pk["name"], pk)
169
+ info["pk"] = pk["name"]
170
+ else:
171
+ info["pk"] = next(n for n, f in info["fields"].items() if f.get("primary_key"))
172
+ # resolve relation targets
173
+ for info in self.models.values():
174
+ for f in info["fields"].values():
175
+ if f.get("target_expr") is not None:
176
+ f["target"] = self.resolve_target(info["class"], f.pop("target_expr"))
177
+ f.pop("target_expr", None)
178
+
179
+ def field_class(self, k: ClassInfo, val):
180
+ if not isinstance(val, ast.Call):
181
+ return None
182
+ t = self.prog.infer(val.func, Ctx(k.module, None, None))
183
+ name = None
184
+ if t and t[0] == "ext":
185
+ name = t[1].split(".")[-1]
186
+ elif t and t[0] == "type":
187
+ # local Field subclass: use the nearest external base
188
+ for b in self.prog.ext_bases(t[1]):
189
+ if b.split(".")[-1] in FIELD_TYPES:
190
+ name = b.split(".")[-1]
191
+ break
192
+ name = name or (t[1].name if t[1].name.endswith("Field") else None)
193
+ else:
194
+ d = dotted(val.func) or ""
195
+ last = d.split(".")[-1]
196
+ if last.endswith("Field") or last in ("ForeignKey", "TaggableManager", "ParentalKey"):
197
+ name = last
198
+ if name and (name in FIELD_TYPES or name.endswith("Field") or name in ("ForeignKey",)):
199
+ return name
200
+ return None
201
+
202
+ def field_def(self, k: ClassInfo, name: str, val, line: int) -> dict | None:
203
+ fc = self.field_class(k, val)
204
+ if not fc:
205
+ return None
206
+ call: ast.Call = val
207
+ typ = FIELD_TYPES.get(fc, "any")
208
+ fd = {"name": name, "class": fc, "type": typ, "line": line, "file": k.file, "owner": k.qual}
209
+ for kw in ("null", "blank", "primary_key", "unique", "db_index", "editable"):
210
+ v = kwarg(call, kw)
211
+ if isinstance(v, ast.Constant):
212
+ fd[kw] = v.value
213
+ fd.setdefault("null", False)
214
+ if kwarg(call, "default") is not None:
215
+ fd["has_default"] = True
216
+ dbc = const_str(kwarg(call, "db_column"))
217
+ if typ == "fk":
218
+ fd["column"] = dbc or f"{name}_id"
219
+ fd["target_expr"] = call.args[0] if call.args else kwarg(call, "to")
220
+ rn = const_str(kwarg(call, "related_name"))
221
+ if rn:
222
+ fd["related_name"] = rn
223
+ elif typ in ("m2m",):
224
+ fd["target_expr"] = call.args[0] if call.args else kwarg(call, "to")
225
+ th = kwarg(call, "through")
226
+ fd["through"] = const_str(th) or dotted(th)
227
+ rn = const_str(kwarg(call, "related_name"))
228
+ if rn:
229
+ fd["related_name"] = rn
230
+ elif typ in ("generic", "m2m_rev"):
231
+ return {**fd, "virtual": True}
232
+ else:
233
+ fd["column"] = dbc or name
234
+ ml = kwarg(call, "max_length")
235
+ if isinstance(ml, ast.Constant):
236
+ fd["max_length"] = ml.value
237
+ ch = kwarg(call, "choices")
238
+ if ch is not None:
239
+ fd["choices"] = self.choices(k, ch)
240
+ if fc == "ArrayField" and call.args:
241
+ inner = self.field_class(k, call.args[0])
242
+ fd["item_type"] = FIELD_TYPES.get(inner or "", "any")
243
+ return fd
244
+
245
+ def choices(self, k: ClassInfo, e) -> list | None:
246
+ """Choice values from a literal list of pairs, a TextChoices/IntegerChoices class (X.choices), or a module var."""
247
+ prog = self.prog
248
+ if isinstance(e, ast.Attribute) and e.attr == "choices":
249
+ t = prog.infer(e.value, Ctx(k.module, None, None))
250
+ if t and t[0] == "type":
251
+ return self.enum_values(t[1])
252
+ if isinstance(e, ast.Name):
253
+ t = prog.infer(e, Ctx(k.module, None, k))
254
+ if t and t[0] == "var":
255
+ e = prog.var_value(t[1], t[2])
256
+ elif e.id in k.attrs:
257
+ e = k.attrs[e.id][0]
258
+ if isinstance(e, (ast.List, ast.Tuple)):
259
+ out = []
260
+ for it in e.elts:
261
+ if isinstance(it, (ast.Tuple, ast.List)) and it.elts and isinstance(it.elts[0], ast.Constant):
262
+ out.append(it.elts[0].value)
263
+ return out or None
264
+ return None
265
+
266
+ def enum_values(self, c: ClassInfo) -> list | None:
267
+ out = []
268
+ for name, (val, line, ann) in c.attrs.items():
269
+ if name.startswith("_") or name in ("Meta",):
270
+ continue
271
+ if isinstance(val, ast.Tuple) and val.elts and isinstance(val.elts[0], ast.Constant):
272
+ out.append(val.elts[0].value)
273
+ elif isinstance(val, ast.Constant):
274
+ out.append(val.value)
275
+ elif isinstance(val, ast.Call) and dotted(val.func) in ("auto", "enum.auto"):
276
+ out.append(name.lower())
277
+ return out or None
278
+
279
+ def manager_def(self, k: ClassInfo, val):
280
+ if not isinstance(val, ast.Call):
281
+ return None
282
+ ctx = Ctx(k.module, None, None)
283
+ f = val.func
284
+ if isinstance(f, ast.Attribute) and f.attr in ("as_manager", "from_queryset"):
285
+ t = self.prog.infer(f.value, ctx)
286
+ return t[1] if t and t[0] == "type" else False
287
+ if isinstance(f, ast.Call) and isinstance(f.func, ast.Attribute) and f.func.attr == "from_queryset":
288
+ t = self.prog.infer(f.args[0], ctx) if f.args else None
289
+ return t[1] if t and t[0] == "type" else False
290
+ t = self.prog.infer(f, ctx)
291
+ if t and t[0] == "type" and self.prog.subclass_of(t[1], "Manager", "BaseManager", "QuerySet"):
292
+ return t[1]
293
+ if t and t[0] == "ext" and t[1].split(".")[-1] in ("Manager", "QuerySet") :
294
+ return False
295
+ return None
296
+
297
+ def resolve_target(self, c: ClassInfo, e):
298
+ if e is None:
299
+ return None
300
+ s = const_str(e)
301
+ if s is not None:
302
+ if s == "self":
303
+ return c.qual
304
+ if s in self.by_name and len(self.by_name[s]) == 1:
305
+ return self.by_name[s][0]
306
+ short = s.split(".")[-1]
307
+ qs = self.by_name.get(short) or []
308
+ if "." in s:
309
+ qs = [q for q in qs if self.models[q]["label"] == s.split(".")[0]] or qs
310
+ return qs[0] if len(qs) == 1 else (f"?{s}" if not qs else qs[0])
311
+ t = self.prog.infer(e, Ctx(c.module, None, None))
312
+ if t and t[0] == "type":
313
+ return t[1].qual
314
+ if t and t[0] == "ext":
315
+ return f"?{t[1]}"
316
+ return None
317
+
318
+ def table(self, qual: str) -> str | None:
319
+ m = self.models.get(qual)
320
+ return m.get("table") if m else None
321
+
322
+ def field(self, qual: str, name: str) -> dict | None:
323
+ m = self.models.get(qual)
324
+ if not m:
325
+ return None
326
+ if name == "pk":
327
+ name = m.get("pk", "id")
328
+ f = m["fields"].get(name)
329
+ if f is None and name.endswith("_id"):
330
+ f = m["fields"].get(name[:-3])
331
+ if f and f["type"] != "fk":
332
+ f = None
333
+ return f
334
+
335
+ # ---- type rules
336
+ def attr_rule(self, prog, t, attr, ctx):
337
+ if t is None:
338
+ return None
339
+ if t[0] == "type" and t[1].qual in self.models:
340
+ info = self.models[t[1].qual]
341
+ if attr in ("objects", "_default_manager", "_base_manager") or attr in info["managers"]:
342
+ return ("qs", t[1])
343
+ return None
344
+ if t[0] == "inst" and t[1].qual in self.models:
345
+ info = self.models[t[1].qual]
346
+ f = info["fields"].get(attr)
347
+ if f and f["type"] == "fk" and isinstance(f.get("target"), str) and f["target"] in prog.classes:
348
+ return ("inst", prog.classes[f["target"]])
349
+ if f and f["type"] == "m2m" and isinstance(f.get("target"), str) and f["target"] in prog.classes:
350
+ return ("qs", prog.classes[f["target"]])
351
+ rev = self.reverse(t[1].qual).get(attr)
352
+ if rev:
353
+ return rev
354
+ return None
355
+ if t[0] == "qs":
356
+ mc = t[1]
357
+ for mgr in self.models.get(mc.qual, {}).get("managers", {}).values():
358
+ if mgr:
359
+ f = prog.find_method(mgr, attr)
360
+ if f:
361
+ return ("bound", f, ("inst", mgr))
362
+ return ("qsm", mc, attr)
363
+ return None
364
+
365
+ def reverse(self, qual: str) -> dict:
366
+ if not hasattr(self, "_rev"):
367
+ self._rev = {}
368
+ for q, info in self.models.items():
369
+ for f in info["fields"].values():
370
+ tg = f.get("target")
371
+ if not isinstance(tg, str) or tg not in self.prog.classes:
372
+ continue
373
+ c = info["class"]
374
+ if f["type"] == "fk":
375
+ rn = f.get("related_name") or (f"{snake(c.name)}_set" if f["class"] != "OneToOneField" else snake(c.name))
376
+ self._rev.setdefault(tg, {})[rn] = ("qs", c) if f["class"] != "OneToOneField" else ("inst", c)
377
+ elif f["type"] == "m2m":
378
+ rn = f.get("related_name") or f"{snake(c.name)}_set"
379
+ self._rev.setdefault(tg, {})[rn] = ("qs", c)
380
+ return self._rev.get(qual, {})
381
+
382
+ def call_rule(self, prog, call, ft, ctx):
383
+ if ft is not None and ft[0] == "qsm":
384
+ mc, m = ft[1], ft[2]
385
+ if m in QS_METHODS:
386
+ return ("qs", mc)
387
+ if m in INST_METHODS or m in ("aget", "afirst", "alast", "acreate"):
388
+ return ("inst", mc)
389
+ if m in TUPLE_METHODS or m in ("aget_or_create", "aupdate_or_create"):
390
+ return ("tuple", [("inst", mc), None])
391
+ return ("qsr", mc, m)
392
+ if ft is not None and ft[0] == "ext" and ft[1].split(".")[-1] in ("get_object_or_404", "get_list_or_404", "aget_object_or_404") and call.args:
393
+ t = prog.infer(call.args[0], ctx)
394
+ if t and t[0] == "type" and t[1].qual in self.models:
395
+ return ("inst", t[1]) if "object" in ft[1] else ("qs", t[1])
396
+ if t and t[0] == "qs":
397
+ return ("inst", t[1]) if "object" in ft[1] else t
398
+ return None
399
+
400
+
401
+ def orm_edges(models: Models, b, f, stats) -> None:
402
+ """READS/WRITES_TABLE and READS/WRITES_COLUMN for one function."""
403
+ prog = models.prog
404
+ ctx = Ctx(f.module, f, f.cls)
405
+ for sub in walk_body(f.node):
406
+ if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute):
407
+ meth = sub.func.attr
408
+ rt = prog.infer(sub.func.value, ctx)
409
+ if rt and rt[0] == "qs" and rt[1].qual in models.models:
410
+ tbl = models.table(rt[1].qual)
411
+ if not tbl:
412
+ continue
413
+ tid = f"table:{tbl}"
414
+ if meth in WRITE_METHODS:
415
+ b.add_edge(f.id, tid, "WRITES_TABLE", f.file, sub.lineno, RESOLVED, via=f"orm.{meth}")
416
+ stats["orm_writes"] += 1
417
+ if meth in READ_METHODS or meth in ("get_or_create", "update_or_create", "delete", "update"):
418
+ if meth not in ("create", "bulk_create", "acreate", "abulk_create"):
419
+ b.add_edge(f.id, tid, "READS_TABLE", f.file, sub.lineno, RESOLVED, via=f"orm.{meth}")
420
+ stats["orm_reads"] += 1
421
+ kws = [k for k in sub.keywords if k.arg]
422
+ for k in kws:
423
+ if k.arg == "defaults" and isinstance(k.value, ast.Dict):
424
+ for kk in k.value.keys:
425
+ s = const_str(kk)
426
+ if s:
427
+ _col(models, b, f, rt[1].qual, s, "WRITES_COLUMN", sub.lineno, via=f"orm.{meth}(defaults)")
428
+ continue
429
+ if meth in LOOKUP_KW or meth in QS_METHODS:
430
+ _col(models, b, f, rt[1].qual, k.arg.split("__")[0], "READS_COLUMN", sub.lineno, via=f"orm.{meth}")
431
+ if meth in WRITE_KW or (meth in TUPLE_METHODS and False):
432
+ _col(models, b, f, rt[1].qual, k.arg.split("__")[0], "WRITES_COLUMN", sub.lineno, via=f"orm.{meth}")
433
+ if meth in ("order_by", "values", "values_list", "only", "defer"):
434
+ for a in sub.args:
435
+ s = const_str(a)
436
+ if s:
437
+ _col(models, b, f, rt[1].qual, s.lstrip("-").split("__")[0], "READS_COLUMN", sub.lineno, via=f"orm.{meth}")
438
+ elif rt and rt[0] == "inst" and rt[1].qual in models.models:
439
+ tbl = models.table(rt[1].qual)
440
+ if tbl and meth in ("save", "delete", "asave", "adelete", "refresh_from_db", "arefresh_from_db"):
441
+ kind = "READS_TABLE" if "refresh" in meth else "WRITES_TABLE"
442
+ b.add_edge(f.id, f"table:{tbl}", kind, f.file, sub.lineno, RESOLVED, via=f"instance.{meth}")
443
+ stats["orm_writes" if kind == "WRITES_TABLE" else "orm_reads"] += 1
444
+ uf = kwarg(sub, "update_fields")
445
+ if isinstance(uf, (ast.List, ast.Tuple)):
446
+ for x in uf.elts:
447
+ s = const_str(x)
448
+ if s:
449
+ _col(models, b, f, rt[1].qual, s, "WRITES_COLUMN", sub.lineno, via="save(update_fields)")
450
+ elif isinstance(sub, ast.Call):
451
+ ft = prog.infer(sub.func, ctx)
452
+ if ft and ft[0] == "type" and ft[1].qual in models.models:
453
+ for k in sub.keywords:
454
+ if k.arg:
455
+ _col(models, b, f, ft[1].qual, k.arg, "WRITES_COLUMN", sub.lineno, via="Model(...)", conf=HEURISTIC)
456
+ elif isinstance(sub, ast.Attribute) and not isinstance(getattr(sub, "_parent_call", None), ast.Call):
457
+ if isinstance(sub.value, ast.Name) and sub.value.id in ("self", "cls") and f.cls is not None and f.cls.qual in models.models:
458
+ rt = ("inst", f.cls)
459
+ else:
460
+ rt = prog.infer(sub.value, ctx) if isinstance(sub.value, (ast.Name, ast.Attribute)) else None
461
+ if rt and rt[0] == "inst" and rt[1].qual in models.models:
462
+ fd = models.field(rt[1].qual, sub.attr)
463
+ if fd and fd.get("column"):
464
+ kind = "WRITES_COLUMN" if isinstance(sub.ctx, ast.Store) else "READS_COLUMN"
465
+ _col(models, b, f, rt[1].qual, sub.attr, kind, sub.lineno, via="attribute")
466
+
467
+
468
+ def _col(models: Models, b, f, qual, name, kind, line, via, conf=RESOLVED):
469
+ fd = models.field(qual, name)
470
+ tbl = models.table(qual)
471
+ if not fd or not tbl or not fd.get("column"):
472
+ return
473
+ b.add_edge(f.id, f"column:{tbl}.{fd['column']}", kind, f.file, line, conf, via=via)
474
+
475
+
476
+ def emit_models(models: Models, b) -> dict:
477
+ n_t = n_c = n_r = 0
478
+ for q, info in models.models.items():
479
+ c = info["class"]
480
+ attrs = {"django_model": True, "abstract": info["abstract"], "app_label": info["label"],
481
+ "fields": [{k: v for k, v in fd.items() if k not in ("owner",)} for fd in info["fields"].values()]}
482
+ b.nodes[c.id].attrs.update(attrs)
483
+ tbl = info.get("table")
484
+ if not tbl:
485
+ continue
486
+ tid = b.add_node("table", tbl, file=c.file, line=c.line, module=c.module.name, lang="sql",
487
+ attrs={"model": q, "explicit": info.get("table_explicit", False)})
488
+ n_t += 1
489
+ b.add_edge(c.id, tid, "MAPS_TO_TABLE", c.file, c.line, EXACT if info.get("table_explicit") else RESOLVED,
490
+ rule="Meta.db_table" if info.get("table_explicit") else "<app_label>_<model>")
491
+ for fd in info["fields"].values():
492
+ if fd.get("column"):
493
+ cid = b.add_node("column", f"{tbl}.{fd['column']}", name=fd["column"], fqn=f"{tbl}.{fd['column']}",
494
+ file=fd["file"], line=fd["line"], lang="sql",
495
+ attrs={k: fd[k] for k in ("class", "type", "null", "choices", "max_length", "target") if k in fd})
496
+ b.add_edge(tid, cid, "CONTAINS", fd["file"], fd["line"], EXACT)
497
+ b.add_edge(c.id, cid, "DEFINES", fd["file"], fd["line"], EXACT)
498
+ n_c += 1
499
+ tg = fd.get("target")
500
+ if fd["type"] in ("fk", "m2m") and isinstance(tg, str) and not tg.startswith("?"):
501
+ b.add_edge(c.id, f"class:{tg}", "HAS_RELATION", fd["file"], fd["line"], EXACT, relation=fd["class"], field=fd["name"])
502
+ n_r += 1
503
+ if fd["type"] == "m2m" and not fd.get("through"):
504
+ tt = models.table(tg)
505
+ if tt:
506
+ b.add_node("table", f"{tbl}_{fd['name']}", file=fd["file"], line=fd["line"], lang="sql",
507
+ attrs={"m2m_through": True, "between": [tbl, tt]})
508
+ return {"models": len(models.models), "tables": n_t, "columns": n_c, "relations": n_r}