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.
- stackthread/__init__.py +6 -0
- stackthread/__main__.py +3 -0
- stackthread/_vendor/THIRD_PARTY_NOTICES.txt +23 -0
- stackthread/_vendor/extract.mjs +15 -0
- stackthread/adapters.py +130 -0
- stackthread/cli.py +342 -0
- stackthread/config.py +409 -0
- stackthread/detect.py +150 -0
- stackthread/lang/__init__.py +1 -0
- stackthread/lang/js/__init__.py +1 -0
- stackthread/lang/js/aliases.py +133 -0
- stackthread/lang/js/build.py +455 -0
- stackthread/lang/js/worker.py +186 -0
- stackthread/lang/python/__init__.py +1 -0
- stackthread/lang/python/build.py +249 -0
- stackthread/lang/python/extract.py +494 -0
- stackthread/lang/python/frameworks.py +700 -0
- stackthread/lang/python/orm.py +300 -0
- stackthread/lang/python/resolve.py +448 -0
- stackthread/lineage.py +965 -0
- stackthread/link.py +130 -0
- stackthread/logs.py +314 -0
- stackthread/model.py +161 -0
- stackthread/paths.py +173 -0
- stackthread/project.py +414 -0
- stackthread/report.py +203 -0
- stackthread/rules.py +48 -0
- stackthread/server/__init__.py +5 -0
- stackthread/server/app.py +696 -0
- stackthread/server/edit.py +108 -0
- stackthread/server/security.py +73 -0
- stackthread/static/THIRD_PARTY_NOTICES.txt +379 -0
- stackthread/static/app.css +1 -0
- stackthread/static/app.js +29 -0
- stackthread/static/index.html +16 -0
- stackthread-0.1.0.dev2.dist-info/METADATA +51 -0
- stackthread-0.1.0.dev2.dist-info/RECORD +40 -0
- stackthread-0.1.0.dev2.dist-info/WHEEL +4 -0
- stackthread-0.1.0.dev2.dist-info/entry_points.txt +2 -0
- 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()
|