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,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
|