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,186 @@
1
+ """The node process that runs the bundled extractor. One long-lived worker, JSON lines over stdin and stdout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from stackthread import logs
16
+
17
+ MIN_NODE = (22, 18)
18
+ _log = logs.get("worker")
19
+
20
+
21
+ class NodeUnavailable(RuntimeError):
22
+ pass
23
+
24
+
25
+ def extractor_path() -> Path | None:
26
+ """The bundled extractor: shipped in the wheel, or built in a source checkout, or set by STACKTHREAD_EXTRACTOR."""
27
+ override = os.environ.get("STACKTHREAD_EXTRACTOR")
28
+ if override:
29
+ return Path(override)
30
+ here = Path(__file__).resolve()
31
+ shipped = here.parents[2] / "_vendor" / "extract.mjs"
32
+ if shipped.is_file():
33
+ return shipped
34
+ for parent in here.parents:
35
+ candidate = parent / "js" / "packages" / "extract" / "dist" / "extract.mjs"
36
+ if candidate.is_file():
37
+ return candidate
38
+ return None
39
+
40
+
41
+ def node_version(node: str) -> tuple[int, int] | None:
42
+ try:
43
+ out = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=10, check=False).stdout
44
+ except (OSError, subprocess.TimeoutExpired):
45
+ return None
46
+ m = re.match(r"v(\d+)\.(\d+)", out.strip())
47
+ return (int(m.group(1)), int(m.group(2))) if m else None
48
+
49
+
50
+ def find_node() -> str:
51
+ node = os.environ.get("STACKTHREAD_NODE") or shutil.which("node")
52
+ if not node:
53
+ raise NodeUnavailable("node is not on PATH; frontend sources need Node.js 22.18 or later")
54
+ version = node_version(node)
55
+ if version is None:
56
+ raise NodeUnavailable(f"could not run {node} --version")
57
+ if version < MIN_NODE:
58
+ raise NodeUnavailable(f"node {version[0]}.{version[1]} found; stackthread needs 22.18 or later")
59
+ return node
60
+
61
+
62
+ class JsWorker:
63
+ def __init__(self) -> None:
64
+ self._proc: subprocess.Popen[bytes] | None = None
65
+ self._lock = threading.Lock()
66
+ self._next = 0
67
+
68
+ def _ensure(self) -> subprocess.Popen[bytes]:
69
+ if self._proc is not None and self._proc.poll() is None:
70
+ return self._proc
71
+ script = extractor_path()
72
+ if script is None:
73
+ raise NodeUnavailable(
74
+ "the JavaScript extractor is missing; reinstall stackthread or run `npm run build` in js/"
75
+ )
76
+ node = find_node()
77
+ self._proc = subprocess.Popen(
78
+ [node, str(script), "--worker"],
79
+ stdin=subprocess.PIPE,
80
+ stdout=subprocess.PIPE,
81
+ stderr=subprocess.PIPE,
82
+ bufsize=0,
83
+ )
84
+ _log.info(
85
+ "started the JavaScript reader (node %s, pid %d)",
86
+ ".".join(map(str, node_version(node) or ())),
87
+ self._proc.pid,
88
+ extra=logs.ctx(node=node, script=str(script), pid=self._proc.pid),
89
+ )
90
+ threading.Thread(target=_drain, args=(self._proc,), name="stackthread-node-stderr", daemon=True).start()
91
+ return self._proc
92
+
93
+ def extract(
94
+ self, items: list[tuple[Path, str]], options: dict[str, Any] | None = None
95
+ ) -> dict[str, dict[str, Any]]:
96
+ """Facts for many files at once. `items` are (absolute path, project-relative file). Pipelined: requests
97
+ are written by a helper thread while replies are read, so large batches never deadlock on pipe buffers."""
98
+ if not items:
99
+ return {}
100
+ started = time.perf_counter()
101
+ with self._lock:
102
+ proc = self._ensure()
103
+ assert proc.stdin is not None and proc.stdout is not None
104
+ ids: dict[int, str] = {}
105
+ lines = []
106
+ for path, file in items:
107
+ self._next += 1
108
+ ids[self._next] = file
109
+ lines.append(json.dumps({"id": self._next, "path": str(path), "file": file, "options": options or {}}))
110
+ payload = ("\n".join(lines) + "\n").encode("utf-8")
111
+ stdin = proc.stdin
112
+
113
+ def write() -> None:
114
+ try:
115
+ stdin.write(payload)
116
+ stdin.flush()
117
+ except OSError:
118
+ pass
119
+
120
+ writer = threading.Thread(target=write, daemon=True)
121
+ writer.start()
122
+ out: dict[str, dict[str, Any]] = {}
123
+ for _ in ids:
124
+ raw = proc.stdout.readline()
125
+ if not raw:
126
+ code = proc.poll()
127
+ self._proc = None
128
+ _log.error(
129
+ "the JavaScript reader stopped unexpectedly (exit code %s)", code, extra=logs.ctx(code=code)
130
+ )
131
+ raise NodeUnavailable("the extractor process stopped unexpectedly")
132
+ reply = json.loads(raw)
133
+ file = ids.get(reply.get("id"), "")
134
+ if "facts" in reply:
135
+ out[file] = reply["facts"]
136
+ else:
137
+ _log.warning("could not read %s: %s", file, reply.get("error"), extra=logs.ctx(file=file))
138
+ out[file] = _error_facts(file, reply.get("error", "unknown error"))
139
+ writer.join(timeout=5)
140
+ _log.debug(
141
+ "read %d files with node in %.0f ms",
142
+ len(items),
143
+ (time.perf_counter() - started) * 1000,
144
+ extra=logs.ctx(files=len(items)),
145
+ )
146
+ return out
147
+
148
+ def close(self) -> None:
149
+ with self._lock:
150
+ if self._proc is not None:
151
+ try:
152
+ if self._proc.stdin:
153
+ self._proc.stdin.close()
154
+ self._proc.wait(timeout=3)
155
+ except (OSError, subprocess.TimeoutExpired):
156
+ self._proc.kill()
157
+ self._proc = None
158
+
159
+
160
+ def _drain(proc: subprocess.Popen[bytes]) -> None:
161
+ """Node's own error output goes to the log, line by line, instead of being thrown away."""
162
+ if proc.stderr is None:
163
+ return
164
+ for raw in iter(proc.stderr.readline, b""):
165
+ line = raw.decode("utf-8", errors="replace").rstrip()
166
+ if line:
167
+ _log.warning("node: %s", line[:2000])
168
+
169
+
170
+ def _error_facts(file: str, message: str) -> dict[str, Any]:
171
+ return {
172
+ "schema": 1,
173
+ "lang": "js",
174
+ "file": file,
175
+ "lines": 0,
176
+ "imports": [],
177
+ "exports": [],
178
+ "functions": [],
179
+ "elements": [],
180
+ "http": [],
181
+ "clients": [],
182
+ "routes": [],
183
+ "consts": {},
184
+ "dynamicImports": [],
185
+ "errors": [{"line": 0, "message": message}],
186
+ }
@@ -0,0 +1 @@
1
+ """Python: facts from the stdlib `ast`, symbol resolution, and framework detectors."""
@@ -0,0 +1,249 @@
1
+ """Python facts -> graph: modules, definitions, imports, inheritance, calls, uses, then framework and ORM detectors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from stackthread.config import SourceConfig
8
+ from stackthread.lang.python.resolve import PyIndex, Ref
9
+ from stackthread.model import Edge, Finding, Graph, Node, Site
10
+
11
+ MAX_OUTSIDE = 40
12
+
13
+
14
+ def module_id(source: str, module: str) -> str:
15
+ return f"py:{source}:{module}"
16
+
17
+
18
+ def def_id(source: str, key: str) -> str:
19
+ return f"py:{source}:{key}"
20
+
21
+
22
+ def is_test_file(file: str) -> bool:
23
+ parts = file.split("/")
24
+ name = parts[-1]
25
+ return (
26
+ "tests" in parts[:-1]
27
+ or "test" in parts[:-1]
28
+ or name.startswith("test_")
29
+ or name.endswith("_test.py")
30
+ or name == "conftest.py"
31
+ )
32
+
33
+
34
+ class PyBuild:
35
+ """Builds one Python source into the graph. Detectors use `node_of`, `ref_node` and the index."""
36
+
37
+ def __init__(self, graph: Graph, source: SourceConfig, facts: list[dict[str, Any]]) -> None:
38
+ self.graph = graph
39
+ self.source = source
40
+ self.idx = PyIndex(facts)
41
+ self.module_files = {m: f["file"] for m, f in self.idx.modules.items()}
42
+
43
+ # ---- ids ----
44
+
45
+ def node_of(self, key: str) -> str:
46
+ return def_id(self.source.name, key)
47
+
48
+ def ref_node(self, ref: Ref | None) -> str | None:
49
+ if ref is None:
50
+ return None
51
+ if ref.kind == "def" and ref.key in self.idx.defs:
52
+ return self.node_of(ref.key)
53
+ if ref.kind == "module" and ref.key in self.idx.modules:
54
+ return module_id(self.source.name, ref.key)
55
+ return None
56
+
57
+ def site(self, module: str, line: int) -> Site:
58
+ return Site(self.module_files[module], line)
59
+
60
+ # ---- build ----
61
+
62
+ def run(self) -> None:
63
+ g, src = self.graph, self.source.name
64
+ for module, f in self.idx.modules.items():
65
+ tags = ["test"] if is_test_file(f["file"]) else []
66
+ g.add_node(
67
+ Node(
68
+ id=module_id(src, module),
69
+ kind="module",
70
+ name=module.rsplit(".", 1)[-1],
71
+ qualname=module,
72
+ lang="python",
73
+ source=src,
74
+ file=f["file"],
75
+ line=1,
76
+ end=f["lines"],
77
+ layer="server",
78
+ doc=f["doc"],
79
+ tags=tags + (["package"] if f["package"] else []),
80
+ attrs={"errors": f["errors"]} if f["errors"] else {},
81
+ )
82
+ )
83
+ for err in f["errors"]:
84
+ g.finding(Finding("parse-error", "error", err["message"], f["file"], err["line"]))
85
+ for key, d in self.idx.defs.items():
86
+ module = d["module"]
87
+ tags = ["test"] if is_test_file(d["file"]) else []
88
+ if d.get("async"):
89
+ tags.append("async")
90
+ g.add_node(
91
+ Node(
92
+ id=self.node_of(key),
93
+ kind=d["kind"],
94
+ name=d["name"],
95
+ qualname=f"{module}.{d['q']}",
96
+ lang="python",
97
+ source=src,
98
+ file=d["file"],
99
+ line=d["line"],
100
+ end=d["end"],
101
+ col=d["col"],
102
+ layer="server",
103
+ doc=d["doc"],
104
+ tags=tags,
105
+ attrs={
106
+ k: v
107
+ for k, v in {
108
+ "args": [a["name"] for a in d.get("args", [])],
109
+ "decorators": [x["f"] for x in d.get("decorators", [])],
110
+ "bases": d.get("bases", []),
111
+ }.items()
112
+ if v
113
+ },
114
+ )
115
+ )
116
+ parent = self.node_of(f"{module}:{d['parent']}") if d["parent"] else module_id(src, module)
117
+ g.add_edge(Edge(parent, self.node_of(key), "contains"))
118
+ self._imports()
119
+ self._inheritance()
120
+ self._calls()
121
+ from stackthread.lang.python import frameworks, orm
122
+
123
+ frameworks.detect(self)
124
+ orm.detect(self)
125
+
126
+ def _imports(self) -> None:
127
+ src = self.source.name
128
+ for module, f in self.idx.modules.items():
129
+ outside: list[str] = []
130
+ for imp in f["imports"]:
131
+ target = imp["module"]
132
+ name = imp["name"]
133
+ candidates = [f"{target}.{name}"] if name and name != "*" else []
134
+ candidates.append(target)
135
+ hit = next((c for c in candidates if c in self.idx.modules), None)
136
+ if hit and hit != module:
137
+ self.graph.add_edge(
138
+ Edge(
139
+ module_id(src, module), module_id(src, hit), "imports", site=self.site(module, imp["line"])
140
+ )
141
+ )
142
+ elif not hit and target:
143
+ root = target.split(".")[0]
144
+ if root not in outside:
145
+ outside.append(root)
146
+ if outside:
147
+ self.graph.nodes[module_id(src, module)].attrs["packages"] = outside
148
+
149
+ def _inheritance(self) -> None:
150
+ for key, d in self.idx.defs.items():
151
+ if d["kind"] != "class":
152
+ continue
153
+ for ref in self.idx.bases(key):
154
+ target = self.ref_node(ref)
155
+ if target:
156
+ self.graph.add_edge(
157
+ Edge(self.node_of(key), target, "inherits", site=self.site(d["module"], d["line"]))
158
+ )
159
+
160
+ def _calls(self) -> None:
161
+ g = self.graph
162
+ for key, d in self.idx.defs.items():
163
+ node_id = self.node_of(key)
164
+ module = d["module"]
165
+ outside: list[str] = []
166
+ for call in d.get("calls", []):
167
+ ref, confidence = self.idx.resolve_call(d, call["f"])
168
+ target = self.ref_node(ref)
169
+ if target:
170
+ g.add_edge(
171
+ Edge(
172
+ node_id,
173
+ target,
174
+ "calls",
175
+ confidence, # type: ignore[arg-type]
176
+ site=self.site(module, call["line"]),
177
+ label="raises" if call.get("raise") else "",
178
+ )
179
+ )
180
+ elif ref is not None and ref.kind == "external":
181
+ if ref.key not in outside:
182
+ outside.append(ref.key)
183
+ elif call["f"] not in outside and not call["f"].startswith("?"):
184
+ outside.append(call["f"])
185
+ for arg in [*call.get("args", []), *call.get("kw", {}).values()]:
186
+ self._passes(d, arg, call["line"])
187
+ self._uses(d)
188
+ if outside:
189
+ g.nodes[node_id].attrs["outside"] = outside[:MAX_OUTSIDE]
190
+
191
+ def _passes(self, d: dict[str, Any], arg: dict[str, Any] | None, line: int) -> None:
192
+ """A project function passed as an argument (a callback, a task, a signal handler) is used here."""
193
+ if not arg or arg.get("t") != "name":
194
+ return
195
+ ref = self.idx.resolve_dotted(d["module"], arg["v"], d)
196
+ target = self.ref_node(ref)
197
+ if target and ref and ref.kind == "def" and self.idx.defs[ref.key]["kind"] in ("function", "method"):
198
+ self.graph.add_edge(
199
+ Edge(self.node_of(d["key"]), target, "uses", site=self.site(d["module"], line), label="passes")
200
+ )
201
+
202
+ def _uses(self, d: dict[str, Any]) -> None:
203
+ g, module, node_id = self.graph, d["module"], self.node_of(d["key"])
204
+ site = self.site(module, d["line"])
205
+ for arg in d.get("args", []):
206
+ for name in arg.get("ann", []):
207
+ target = self.ref_node(self.idx.resolve_dotted(module, name))
208
+ if target and g.nodes[target].kind in ("class", "model"):
209
+ g.add_edge(Edge(node_id, target, "uses", site=site, label="type"))
210
+ for value in (arg.get("default"), arg.get("ann_value")):
211
+ for call in _calls_in(value):
212
+ if call.get("args") and call["args"][0] and call["args"][0].get("t") == "name":
213
+ target = self.ref_node(self.idx.resolve_dotted(module, call["args"][0]["v"]))
214
+ if target:
215
+ label = call["f"].rsplit(".", 1)[-1]
216
+ g.add_edge(Edge(node_id, target, "uses", site=site, label=label))
217
+ for name in d.get("returns", []):
218
+ target = self.ref_node(self.idx.resolve_dotted(module, name))
219
+ if target and g.nodes[target].kind in ("class", "model"):
220
+ g.add_edge(Edge(node_id, target, "uses", site=site, label="returns"))
221
+ for deco in d.get("decorators", []):
222
+ target = self.ref_node(self.idx.resolve_dotted(module, deco["f"]))
223
+ if target:
224
+ g.add_edge(Edge(node_id, target, "uses", site=self.site(module, deco["line"]), label="decorator"))
225
+
226
+
227
+ def _calls_in(value: dict[str, Any] | None) -> list[dict[str, Any]]:
228
+ """Every call inside a summarised value, outermost first."""
229
+ if not value:
230
+ return []
231
+ out: list[dict[str, Any]] = []
232
+ t = value.get("t")
233
+ if t == "call":
234
+ out.append(value)
235
+ for a in [*value.get("args", []), *value.get("kw", {}).values()]:
236
+ out += _calls_in(a)
237
+ elif t == "list":
238
+ for v in value["v"]:
239
+ out += _calls_in(v)
240
+ elif t == "dict":
241
+ for v in value["v"].values():
242
+ out += _calls_in(v)
243
+ return out
244
+
245
+
246
+ def build(graph: Graph, source: SourceConfig, facts: list[dict[str, Any]]) -> PyBuild:
247
+ builder = PyBuild(graph, source, facts)
248
+ builder.run()
249
+ return builder