stackthread 0.1.0.dev2__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.

Potentially problematic release.


This version of stackthread might be problematic. Click here for more details.

Files changed (40) hide show
  1. stackthread/__init__.py +6 -0
  2. stackthread/__main__.py +3 -0
  3. stackthread/_vendor/THIRD_PARTY_NOTICES.txt +23 -0
  4. stackthread/_vendor/extract.mjs +15 -0
  5. stackthread/adapters.py +130 -0
  6. stackthread/cli.py +342 -0
  7. stackthread/config.py +409 -0
  8. stackthread/detect.py +150 -0
  9. stackthread/lang/__init__.py +1 -0
  10. stackthread/lang/js/__init__.py +1 -0
  11. stackthread/lang/js/aliases.py +133 -0
  12. stackthread/lang/js/build.py +455 -0
  13. stackthread/lang/js/worker.py +186 -0
  14. stackthread/lang/python/__init__.py +1 -0
  15. stackthread/lang/python/build.py +249 -0
  16. stackthread/lang/python/extract.py +494 -0
  17. stackthread/lang/python/frameworks.py +700 -0
  18. stackthread/lang/python/orm.py +300 -0
  19. stackthread/lang/python/resolve.py +448 -0
  20. stackthread/lineage.py +965 -0
  21. stackthread/link.py +130 -0
  22. stackthread/logs.py +314 -0
  23. stackthread/model.py +161 -0
  24. stackthread/paths.py +173 -0
  25. stackthread/project.py +414 -0
  26. stackthread/report.py +203 -0
  27. stackthread/rules.py +48 -0
  28. stackthread/server/__init__.py +5 -0
  29. stackthread/server/app.py +696 -0
  30. stackthread/server/edit.py +108 -0
  31. stackthread/server/security.py +73 -0
  32. stackthread/static/THIRD_PARTY_NOTICES.txt +379 -0
  33. stackthread/static/app.css +1 -0
  34. stackthread/static/app.js +29 -0
  35. stackthread/static/index.html +16 -0
  36. stackthread-0.1.0.dev2.dist-info/METADATA +51 -0
  37. stackthread-0.1.0.dev2.dist-info/RECORD +40 -0
  38. stackthread-0.1.0.dev2.dist-info/WHEEL +4 -0
  39. stackthread-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  40. stackthread-0.1.0.dev2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,300 @@
1
+ """Data-layer detectors: Django ORM, SQLAlchemy and SQLModel models, MongoDB collections (design §8.4).
2
+
3
+ Model classes become `model` nodes in the data layer. Functions that query or change them get `reads` and
4
+ `writes` edges. MongoDB collections named by literals become `store` nodes (heuristic confidence).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from stackthread.lang.python.resolve import bare, split_dotted
13
+ from stackthread.model import Confidence, Edge, EdgeKind, Node
14
+
15
+ if TYPE_CHECKING:
16
+ from stackthread.lang.python.build import PyBuild
17
+ from stackthread.lang.python.resolve import Ref
18
+
19
+ DJANGO_MODEL = ("django.db.models.Model", "django.db.models.base.Model")
20
+ DJANGO_MANAGERS = ("objects", "_default_manager", "_base_manager")
21
+ DJANGO_WRITES = frozenset(
22
+ {
23
+ "create",
24
+ "update",
25
+ "delete",
26
+ "bulk_create",
27
+ "bulk_update",
28
+ "get_or_create",
29
+ "update_or_create",
30
+ "acreate",
31
+ "aupdate",
32
+ "adelete",
33
+ "abulk_create",
34
+ "abulk_update",
35
+ "aget_or_create",
36
+ "aupdate_or_create",
37
+ }
38
+ )
39
+ SQLA_BASES = (
40
+ "sqlalchemy.orm.DeclarativeBase",
41
+ "sqlalchemy.orm.decl_api.DeclarativeBase",
42
+ "sqlalchemy.ext.declarative.declarative_base",
43
+ "sqlalchemy.orm.declarative_base",
44
+ "sqlalchemy.orm.DeclarativeBaseNoMeta",
45
+ "sqlmodel.SQLModel",
46
+ "sqlmodel.main.SQLModel",
47
+ )
48
+ SQLA_STATEMENTS: dict[str, EdgeKind] = {"select": "reads", "insert": "writes", "update": "writes", "delete": "writes"}
49
+ MONGO_OPS: dict[str, EdgeKind] = {
50
+ "find": "reads",
51
+ "find_one": "reads",
52
+ "aggregate": "reads",
53
+ "count_documents": "reads",
54
+ "distinct": "reads",
55
+ "estimated_document_count": "reads",
56
+ "insert_one": "writes",
57
+ "insert_many": "writes",
58
+ "update_one": "writes",
59
+ "update_many": "writes",
60
+ "replace_one": "writes",
61
+ "delete_one": "writes",
62
+ "delete_many": "writes",
63
+ "find_one_and_update": "writes",
64
+ "find_one_and_replace": "writes",
65
+ "find_one_and_delete": "writes",
66
+ "bulk_write": "writes",
67
+ }
68
+
69
+
70
+ class OrmDetector:
71
+ def __init__(self, b: PyBuild) -> None:
72
+ self.b = b
73
+ self.idx = b.idx
74
+ self.models: dict[str, str] = {} # class key -> orm name
75
+ self.sqla_bases: set[str] = set()
76
+
77
+ def run(self) -> None:
78
+ self._find_models()
79
+ for key, d in self.idx.defs.items():
80
+ for call in d.get("calls", []):
81
+ self._call(key, d, call)
82
+ for assignment in d.get("sets", []):
83
+ self._set(key, d, assignment)
84
+
85
+ def _set(self, key: str, d: dict[str, Any], assignment: dict[str, Any]) -> None:
86
+ """`task.done = True` where `task` is a model instance: a write to that model."""
87
+ receiver = assignment["f"].split(".")[0]
88
+ hint = self.idx.local_hint(d, receiver)
89
+ ref = self.idx.instance_type(d["module"], hint) if hint else None
90
+ if ref is not None and ref.kind == "def" and ref.key in self.models:
91
+ self._edge(key, d, ref.key, "writes", assignment["line"])
92
+
93
+ # ---- models ----
94
+
95
+ def _find_models(self) -> None:
96
+ idx = self.idx
97
+ for key, var in idx.vars.items():
98
+ value = var.get("value")
99
+ if value and value.get("t") == "call":
100
+ ref = idx.resolve_dotted(var["module"], value["f"])
101
+ if ref is not None and ref.kind == "external" and ref.key.endswith("declarative_base"):
102
+ self.sqla_bases.add(key)
103
+ changed = True
104
+ while changed:
105
+ changed = False
106
+ for key, d in idx.defs.items():
107
+ if d["kind"] != "class" or key in self.models:
108
+ continue
109
+ orm = self._model_kind(key, d)
110
+ if orm:
111
+ self.models[key] = orm
112
+ changed = True
113
+ for key, orm in self.models.items():
114
+ node = self.b.graph.nodes.get(self.b.node_of(key))
115
+ if node is None:
116
+ continue
117
+ d = idx.defs[key]
118
+ node.kind = "model"
119
+ node.layer = "data"
120
+ node.tags = [*node.tags, orm]
121
+ node.attrs["orm"] = orm
122
+ table = self._table(key, d, orm)
123
+ if table:
124
+ node.attrs["table"] = table
125
+
126
+ def _model_kind(self, key: str, d: dict[str, Any]) -> str | None:
127
+ for ref in self.idx.bases(key):
128
+ if ref.kind == "external":
129
+ if ref.key in DJANGO_MODEL or (ref.key.endswith("models.Model") and ref.key.startswith("django")):
130
+ return "django"
131
+ if ref.key in SQLA_BASES:
132
+ if ref.key.startswith("sqlmodel"):
133
+ return "sqlalchemy" if self._sqlmodel_table(d) else None
134
+ # `class Base(DeclarativeBase)` is the declarative base, not a table
135
+ if self._is_sqla_model(d):
136
+ return "sqlalchemy"
137
+ self.sqla_bases.add(key)
138
+ return None
139
+ elif ref.kind == "var" and ref.key in self.sqla_bases:
140
+ return "sqlalchemy"
141
+ elif ref.kind == "def":
142
+ if ref.key in self.models:
143
+ return self.models[ref.key]
144
+ if ref.key in self.sqla_bases or self._declarative(ref.key):
145
+ return "sqlalchemy"
146
+ return None
147
+
148
+ def _declarative(self, key: str) -> bool:
149
+ """A project class that is itself a declarative base (subclasses DeclarativeBase without a table)."""
150
+ d = self.idx.defs.get(key, {})
151
+ return not self._is_sqla_model(d) and any(
152
+ r.kind == "external" and r.key in SQLA_BASES and not r.key.startswith("sqlmodel")
153
+ for r in self.idx.bases(key)
154
+ )
155
+
156
+ def _sqlmodel_table(self, d: dict[str, Any]) -> bool:
157
+ table = d.get("kw", {}).get("table")
158
+ return bool(table and table.get("t") == "lit" and table.get("v") is True)
159
+
160
+ def _is_sqla_model(self, d: dict[str, Any]) -> bool:
161
+ return "__tablename__" in d.get("attrs", {})
162
+
163
+ def _table(self, key: str, d: dict[str, Any], orm: str) -> str | None:
164
+ if orm == "sqlalchemy":
165
+ value = self.idx.const(d["module"], d.get("attrs", {}).get("__tablename__"))
166
+ return value if isinstance(value, str) else None
167
+ meta = self.idx.children.get(key, {}).get("Meta")
168
+ if meta:
169
+ value = self.idx.const(d["module"], self.idx.defs[meta].get("attrs", {}).get("db_table"))
170
+ if isinstance(value, str):
171
+ return value
172
+ parts = d["module"].split(".")
173
+ app = parts[-2] if len(parts) >= 2 and parts[-1] == "models" else parts[0]
174
+ return f"{app}_{d['name'].lower()}"
175
+
176
+ def _model_ref(self, module: str, name: str, scope: dict[str, Any] | None = None) -> str | None:
177
+ ref: Ref | None = self.idx.resolve_dotted(module, name, scope)
178
+ if ref is not None and ref.kind == "def" and ref.key in self.models:
179
+ return ref.key
180
+ return None
181
+
182
+ # ---- calls ----
183
+
184
+ def _edge(
185
+ self,
186
+ scope_key: str,
187
+ d: dict[str, Any],
188
+ model_key: str,
189
+ kind: EdgeKind,
190
+ line: int,
191
+ confidence: Confidence = "exact",
192
+ ) -> None:
193
+ self.b.graph.add_edge(
194
+ Edge(
195
+ self.b.node_of(scope_key),
196
+ self.b.node_of(model_key),
197
+ kind,
198
+ confidence,
199
+ site=self.b.site(d["module"], line),
200
+ )
201
+ )
202
+
203
+ def _call(self, key: str, d: dict[str, Any], call: dict[str, Any]) -> None:
204
+ f, line, module = call["f"], call["line"], d["module"]
205
+ segments = split_dotted(f)
206
+ names = [bare(s) for s in segments]
207
+ # Django: Model.objects.<op>(...).<op>(...)
208
+ for i, name in enumerate(names):
209
+ if name in DJANGO_MANAGERS and i > 0:
210
+ model = self._model_ref(module, ".".join(names[:i]), d)
211
+ if model and i + 1 < len(names):
212
+ ops = names[i + 1 :]
213
+ manager_kind: EdgeKind = "writes" if any(op in DJANGO_WRITES for op in ops) else "reads"
214
+ self._edge(key, d, model, manager_kind, line)
215
+ return
216
+ last = names[-1] if names else ""
217
+ if last in ("get_object_or_404", "get_list_or_404", "aget_object_or_404") and call["args"]:
218
+ arg = call["args"][0]
219
+ if arg and arg.get("t") == "name" and (model := self._model_ref(module, arg["v"], d)):
220
+ self._edge(key, d, model, "reads", line)
221
+ return
222
+ # instance.save() / .delete() / .refresh_from_db() on a typed local or self attribute
223
+ if len(names) >= 2 and last in ("save", "delete", "asave", "adelete", "refresh_from_db", "arefresh_from_db"):
224
+ receiver = ".".join(segments[:-1])
225
+ ref = self.idx.resolve_dotted(module, receiver, d) if "(" not in receiver else None
226
+ if ref is None and len(names) == 2:
227
+ hint = self.idx.local_hint(d, names[0])
228
+ ref = self.idx.instance_type(module, hint) if hint else None
229
+ if ref is not None and ref.kind == "def" and ref.key in self.models:
230
+ self._edge(key, d, ref.key, "reads" if "refresh" in last else "writes", line)
231
+ return
232
+ # not a model instance: maybe a SQLAlchemy session (`db.delete(obj)`); fall through
233
+ # SQLAlchemy statements: select(Model), insert(Model), update(Model), delete(Model)
234
+ if len(names) == 1 or (len(names) == 2 and names[0] in ("sa", "sqlalchemy", "sqlmodel")):
235
+ statement = SQLA_STATEMENTS.get(last)
236
+ if statement and call["args"]:
237
+ ref = self.idx.resolve_dotted(module, f)
238
+ if ref is not None and ref.kind == "external" and ref.key.split(".")[0] in ("sqlalchemy", "sqlmodel"):
239
+ for arg in call["args"]:
240
+ if arg and arg.get("t") == "name" and (model := self._model_ref(module, arg["v"], d)):
241
+ self._edge(key, d, model, statement, line)
242
+ return
243
+ # SQLAlchemy session API: session.query(M), session.get(M, id), session.add(M(...)), session.delete(obj)
244
+ if len(names) >= 2 and last in ("query", "get", "add", "merge", "delete", "add_all") and call["args"]:
245
+ arg = call["args"][0]
246
+ session_kind: EdgeKind = "reads" if last in ("query", "get") else "writes"
247
+ if arg and arg.get("t") == "name":
248
+ model = self._model_ref(module, arg["v"], d)
249
+ if model is None and last in ("add", "merge", "delete"):
250
+ hint = self.idx.local_hint(d, arg["v"])
251
+ ref = self.idx.instance_type(module, hint) if hint else None
252
+ model = ref.key if ref is not None and ref.kind == "def" and ref.key in self.models else None
253
+ if model:
254
+ self._edge(key, d, model, session_kind, line)
255
+ elif arg and arg.get("t") == "call" and (model := self._model_ref(module, arg["f"], d)):
256
+ self._edge(key, d, model, session_kind, line)
257
+ return
258
+ # Flask-SQLAlchemy style: Model.query.<op>
259
+ if "query" in names[1:] and (model := self._model_ref(module, ".".join(names[: names.index("query", 1)]), d)):
260
+ self._edge(key, d, model, "reads", line)
261
+ return
262
+ # MongoDB: <db>.<collection>.<op>(...) or <db>[<collection>].<op>(...)
263
+ if last in MONGO_OPS and len(segments) >= 3 and "(" not in segments[-2]:
264
+ coll_segment = segments[-2]
265
+ sub = re.fullmatch(r"\w+\[(\w+)\]", coll_segment)
266
+ collection = sub.group(1) if sub else bare(coll_segment)
267
+ if collection and collection not in ("self", "cls") and not collection.startswith("_"):
268
+ self._store(key, d, collection, MONGO_OPS[last], line)
269
+
270
+ def _store(self, key: str, d: dict[str, Any], collection: str, kind: EdgeKind, line: int) -> None:
271
+ g, src = self.b.graph, self.b.source.name
272
+ node_id = f"store:{src}:mongo:{collection}"
273
+ g.add_node(
274
+ Node(
275
+ id=node_id,
276
+ kind="store",
277
+ name=collection,
278
+ lang="python",
279
+ source=src,
280
+ layer="data",
281
+ tags=["mongo"],
282
+ attrs={"engine": "mongodb", "collection": collection},
283
+ )
284
+ )
285
+ g.add_edge(
286
+ Edge(
287
+ self.b.node_of(key),
288
+ node_id,
289
+ kind,
290
+ "heuristic",
291
+ site=self.b.site(d["module"], line),
292
+ )
293
+ )
294
+
295
+
296
+ def detect(b: PyBuild) -> None:
297
+ enabled = set(b.source.frameworks)
298
+ if not ({"auto", "django-orm", "django", "drf", "ninja", "sqlalchemy", "mongo"} & enabled):
299
+ return
300
+ OrmDetector(b).run()