leyline-code 0.8.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.
- leyline/__init__.py +3 -0
- leyline/adapters/__init__.py +19 -0
- leyline/adapters/csharp.py +1026 -0
- leyline/adapters/generic.py +844 -0
- leyline/adapters/python.py +929 -0
- leyline/adapters/typescript.py +1512 -0
- leyline/affected.py +509 -0
- leyline/agent_skills.py +311 -0
- leyline/change.py +500 -0
- leyline/channels.py +1782 -0
- leyline/cli.py +1029 -0
- leyline/cluster.py +149 -0
- leyline/context.py +531 -0
- leyline/coupling.py +485 -0
- leyline/coverage.py +376 -0
- leyline/diagrams.py +680 -0
- leyline/diff.py +1078 -0
- leyline/drift.py +747 -0
- leyline/exact.py +410 -0
- leyline/explain.py +973 -0
- leyline/export.py +673 -0
- leyline/fncoupling.py +328 -0
- leyline/formats.py +923 -0
- leyline/grade.py +207 -0
- leyline/incremental.py +931 -0
- leyline/indexer.py +3733 -0
- leyline/learnings.py +743 -0
- leyline/loop.py +584 -0
- leyline/model.py +142 -0
- leyline/outline.py +1066 -0
- leyline/packing.py +86 -0
- leyline/patterns.py +478 -0
- leyline/pr.py +1249 -0
- leyline/props.py +255 -0
- leyline/query.py +683 -0
- leyline/quick.py +853 -0
- leyline/related.py +266 -0
- leyline/removal.py +231 -0
- leyline/renames.py +231 -0
- leyline/rereview.py +375 -0
- leyline/roslyn_refs/Program.cs +179 -0
- leyline/roslyn_refs/RoslynRefs.csproj +16 -0
- leyline/rules.py +171 -0
- leyline/schema.sql +178 -0
- leyline/scip_pb2.py +97 -0
- leyline/server.py +1230 -0
- leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
- leyline/skills/leyline-ask/SKILL.md +57 -0
- leyline/skills/leyline-change-impact/SKILL.md +79 -0
- leyline/skills/leyline-explain-flow/SKILL.md +70 -0
- leyline/skills/leyline-explore-module/SKILL.md +55 -0
- leyline/skills/leyline-pr-review/SKILL.md +77 -0
- leyline/skills/leyline-quick-change/SKILL.md +63 -0
- leyline/skills/leyline-spec/SKILL.md +179 -0
- leyline/skills/leyline-tour/SKILL.md +46 -0
- leyline/spec.py +1819 -0
- leyline/store.py +560 -0
- leyline/tours.py +352 -0
- leyline/verdicts.py +390 -0
- leyline/viewer/viewer.html +1622 -0
- leyline_code-0.8.1.dist-info/METADATA +1325 -0
- leyline_code-0.8.1.dist-info/RECORD +65 -0
- leyline_code-0.8.1.dist-info/WHEEL +4 -0
- leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
- leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
"""Python adapter: tree-sitter structure, imports, and call sites with simple receiver inference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import tree_sitter_python
|
|
9
|
+
from tree_sitter import Language, Parser
|
|
10
|
+
|
|
11
|
+
from .. import channels
|
|
12
|
+
from ..model import CallSite, Endpoint, FieldUse, Edge, FileResult, ImportRef, Node, Spawn, TypeRef
|
|
13
|
+
|
|
14
|
+
NAME = "tree-sitter-python"
|
|
15
|
+
VERSION = "0.1"
|
|
16
|
+
LANGUAGE = "python"
|
|
17
|
+
EXTENSIONS = (".py",)
|
|
18
|
+
|
|
19
|
+
_parser = Parser(Language(tree_sitter_python.language()))
|
|
20
|
+
LAUNCHERS = {"subprocess.Popen", "subprocess.run", "subprocess.check_output", "subprocess.check_call",
|
|
21
|
+
"subprocess.call", "os.system", "os.popen", "Popen", "check_output", "check_call"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
HTTP_VERBS = {"get", "post", "put", "delete", "patch", "head", "options"}
|
|
25
|
+
ROUTE = re.compile(r"\.(route|get|post|put|delete|patch|websocket)\(\s*[rfbu]*['\"]([^'\"]*)['\"]")
|
|
26
|
+
FILE_WRITE = {"torch.save", "np.save", "np.savez", "np.savez_compressed", "numpy.save", "pickle.dump", "joblib.dump", "plt.savefig"}
|
|
27
|
+
FILE_READ = {"torch.load", "np.load", "numpy.load", "pickle.load", "joblib.load", "pd.read_csv", "np.loadtxt", "glob.glob",
|
|
28
|
+
"os.listdir", "np.fromfile"}
|
|
29
|
+
PATH_BUILDERS = {"os.path.join", "Path", "pathlib.Path", "join", "glob.glob"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pathlike(s: str) -> bool:
|
|
33
|
+
"""A literal that looks like part of a file path: `.bin`, `runs/out.json`, `*.parity.json`."""
|
|
34
|
+
if not s or len(s) > 120 or any(ch.isspace() for ch in s) or s.startswith(("http:", "https:")):
|
|
35
|
+
return False
|
|
36
|
+
return bool(re.fullmatch(r"\*?(\.[A-Za-z0-9_]{1,10}){1,2}", s)) or ("/" in s.strip("/") and "<" not in s)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _strings(node) -> list[str]:
|
|
40
|
+
"""Every string literal under a node, without quotes."""
|
|
41
|
+
out, stack = [], [node]
|
|
42
|
+
while stack:
|
|
43
|
+
n = stack.pop()
|
|
44
|
+
if n.type == "string":
|
|
45
|
+
body = "".join(_text(c) for c in n.children if c.type == "string_content")
|
|
46
|
+
if body:
|
|
47
|
+
out.append(body)
|
|
48
|
+
else:
|
|
49
|
+
stack.extend(reversed(n.children))
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _text(node) -> str:
|
|
54
|
+
return node.text.decode("utf8", "replace") if node is not None else ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _url_text(node) -> str:
|
|
58
|
+
"""A string literal's text, with each f-string hole written {}: f"/api/users/{uid}" -> /api/users/{}."""
|
|
59
|
+
return "".join(_text(c) if c.type == "string_content" else "{}"
|
|
60
|
+
for c in node.children if c.type in ("string_content", "interpolation"))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _kw_string(args, name: str):
|
|
64
|
+
"""The string a keyword argument is given (`method="POST"`), or None."""
|
|
65
|
+
for a in args.named_children if args is not None else ():
|
|
66
|
+
if a.type == "keyword_argument" and _text(a.child_by_field_name("name")) == name:
|
|
67
|
+
v = a.child_by_field_name("value")
|
|
68
|
+
return _url_text(v) if v is not None and v.type == "string" else None
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _squash(s: str) -> str:
|
|
73
|
+
return re.sub(r"\s+", " ", s).strip()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def module_path(rel_path: str) -> str:
|
|
77
|
+
p = rel_path[:-3] if rel_path.endswith(".py") else rel_path
|
|
78
|
+
if p.endswith("/__init__"):
|
|
79
|
+
p = p[: -len("/__init__")]
|
|
80
|
+
return p.replace("/", ".")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
WRAPPERS = {"Optional", "Union", "Iterator", "Iterable", "Generator", "AsyncIterator", "AsyncGenerator", "Awaitable",
|
|
84
|
+
"Callable", "Any", "Type", "List", "Dict", "Set", "Tuple", "Sequence", "Mapping", "None", "Annotated",
|
|
85
|
+
"ClassVar", "Final", "Literal", "Self", "TypeVar", "Collection", "NoReturn", "IO"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _annotation_types(text: str) -> list[str]:
|
|
89
|
+
"""Class names in an annotation, without the typing wrappers around them: Iterator[Flask] -> [Flask].
|
|
90
|
+
What `Callable[..., Flask]` or `type[Flask]` holds is not an instance of Flask, so those are left out, and so
|
|
91
|
+
are the values of a `Literal[...]` and the metadata after the type in `Annotated[Flask, Field("...")]`."""
|
|
92
|
+
m = re.search(r"\b(Callable|type|Type|Literal|Annotated)\[", text)
|
|
93
|
+
while m:
|
|
94
|
+
depth, i, comma = 1, m.end(), None
|
|
95
|
+
while i < len(text) and depth:
|
|
96
|
+
depth += {"[": 1, "]": -1, "(": 1, ")": -1}.get(text[i], 0)
|
|
97
|
+
if text[i] == "," and depth == 1 and comma is None:
|
|
98
|
+
comma = i
|
|
99
|
+
i += 1
|
|
100
|
+
keep = text[m.end():comma] if m.group(1) == "Annotated" and comma is not None else ""
|
|
101
|
+
text = text[:m.start()] + keep + text[i:]
|
|
102
|
+
m = re.search(r"\b(Callable|type|Type|Literal|Annotated)\[", text)
|
|
103
|
+
names = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", text)
|
|
104
|
+
return [x for x in names if x.lstrip("_")[:1].isupper() and x not in WRAPPERS] # _Private classes too
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _class_name(fn) -> Optional[str]:
|
|
108
|
+
"""The class a call constructs, read off the callee: `Flask` or `flask.Flask`. A capitalised last name is
|
|
109
|
+
taken for a class; the module path in front is kept so the class can be found through the import."""
|
|
110
|
+
text = _text(fn)
|
|
111
|
+
if fn is None or fn.type not in ("identifier", "attribute") or not text.rsplit(".", 1)[-1].lstrip("_")[:1].isupper():
|
|
112
|
+
return None
|
|
113
|
+
head, _, last = text.rpartition(".")
|
|
114
|
+
if head and (head.split(".")[0] in ("self", "cls") or not re.fullmatch(r"[a-z_][\w.]*", head)):
|
|
115
|
+
return last
|
|
116
|
+
return text
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
CONTAINER = re.compile(r"(?:[A-Za-z_]\w*\.)?(list|List|set|Set|frozenset|FrozenSet|Sequence|MutableSequence|Iterable|"
|
|
120
|
+
r"Iterator|Collection|deque|Deque|AbstractSet|tuple|Tuple)\[(.+)\]")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _element_type(text: Optional[str]) -> Optional[str]:
|
|
124
|
+
"""The class of what iterating a value of this annotation gives: list[Tag] -> Tag. None for anything else,
|
|
125
|
+
including a tuple of mixed types."""
|
|
126
|
+
m = CONTAINER.fullmatch((text or "").replace(" ", ""))
|
|
127
|
+
if not m:
|
|
128
|
+
return None
|
|
129
|
+
inner = m.group(2)
|
|
130
|
+
if m.group(1) in ("tuple", "Tuple"):
|
|
131
|
+
if not inner.endswith(",..."):
|
|
132
|
+
return None
|
|
133
|
+
inner = inner[:-4]
|
|
134
|
+
if "," in inner or "[" in inner:
|
|
135
|
+
return None
|
|
136
|
+
return (_annotation_types(inner) or [None])[0]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _narrowing(cond) -> list[tuple]:
|
|
140
|
+
"""(name, class) for each `isinstance(name, Class)` a condition requires: alone or joined by `and`."""
|
|
141
|
+
if cond is None:
|
|
142
|
+
return []
|
|
143
|
+
if cond.type == "parenthesized_expression" and cond.named_children:
|
|
144
|
+
return _narrowing(cond.named_children[0])
|
|
145
|
+
if cond.type == "boolean_operator" and any(c.type == "and" for c in cond.children):
|
|
146
|
+
return _narrowing(cond.child_by_field_name("left")) + _narrowing(cond.child_by_field_name("right"))
|
|
147
|
+
if cond.type != "call" or _text(cond.child_by_field_name("function")) != "isinstance":
|
|
148
|
+
return []
|
|
149
|
+
args = [a for a in (cond.child_by_field_name("arguments") or cond).named_children if a.type != "comment"]
|
|
150
|
+
if len(args) == 2 and args[0].type == "identifier" and args[1].type in ("identifier", "attribute"):
|
|
151
|
+
cls = _class_name(args[1])
|
|
152
|
+
return [(_text(args[0]), cls)] if cls else []
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
OVERLOAD = re.compile(r"@(typing\.|t\.)?overload$")
|
|
157
|
+
|
|
158
|
+
COMPOUND = ("if_statement", "try_statement", "with_statement", "for_statement", "while_statement")
|
|
159
|
+
CLAUSES = ("elif_clause", "else_clause", "except_clause", "except_group_clause", "finally_clause")
|
|
160
|
+
DEFINITIONS = ("function_definition", "class_definition", "decorated_definition")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _defines(node) -> bool:
|
|
164
|
+
"""Whether a compound statement's blocks, or those of one inside them, define a function or a class."""
|
|
165
|
+
for c in node.children:
|
|
166
|
+
if c.type in ("block",) + CLAUSES and _defines(c):
|
|
167
|
+
return True
|
|
168
|
+
if node.type == "block" and (c.type in DEFINITIONS or c.type in COMPOUND and _defines(c)):
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Methods that change the collection they are called on. A field used this way is written, not only read.
|
|
174
|
+
MUTATORS = frozenset("""append extend insert remove pop clear add update discard sort reverse setdefault popitem
|
|
175
|
+
popleft appendleft extendleft put put_nowait write writelines""".split())
|
|
176
|
+
|
|
177
|
+
# Methods of the built-in types (str, list, dict, set, a file). A call to one on a receiver of unknown type is far more
|
|
178
|
+
# often the built-in (`self.ids.add(x)`, `con.close()`) than the one method of that name in the repository.
|
|
179
|
+
COMMON_METHODS = MUTATORS | frozenset("""get items keys values copy index count split rsplit splitlines join strip lstrip rstrip
|
|
180
|
+
replace startswith endswith lower upper format encode decode find rfind partition rpartition read readline readlines close
|
|
181
|
+
flush seek""".split())
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _Walker:
|
|
185
|
+
def __init__(self, repo: str, rel_path: str, file_id: str, src: bytes):
|
|
186
|
+
self.repo = repo
|
|
187
|
+
self.path = rel_path
|
|
188
|
+
self.file_id = file_id
|
|
189
|
+
self.mod = module_path(rel_path)
|
|
190
|
+
self.src = src
|
|
191
|
+
self.res = FileResult(declares=[self.mod])
|
|
192
|
+
self.tree = _parser.parse(src)
|
|
193
|
+
self.top_id = f"{repo}:python:{self.mod}.<module>"
|
|
194
|
+
self.top_used = False
|
|
195
|
+
self.self_types: dict[str, dict[str, str]] = {} # class id -> attribute -> class name
|
|
196
|
+
self.self_elems: dict[str, dict[str, str]] = {} # class id -> attribute -> class of its items (list[Tag])
|
|
197
|
+
self.done_calls: dict = {} # call span -> its call site
|
|
198
|
+
self.fn_nodes: dict[str, Node] = {} # function id -> its node, for facts found in its body
|
|
199
|
+
self.consts: dict[str, list[str]] = {} # module-level NAME = "..." or [...] -> its strings
|
|
200
|
+
self.all_strings: Optional[list[str]] = None
|
|
201
|
+
base = rel_path.rsplit("/", 1)[-1]
|
|
202
|
+
self.is_test_file = base.startswith("test_") or base.endswith("_test.py")
|
|
203
|
+
for st in self.tree.root_node.children:
|
|
204
|
+
if st.type == "expression_statement" and st.named_child_count and st.named_children[0].type == "assignment":
|
|
205
|
+
left = st.named_children[0].child_by_field_name("left")
|
|
206
|
+
right = st.named_children[0].child_by_field_name("right")
|
|
207
|
+
if left is not None and left.type == "identifier" and right is not None:
|
|
208
|
+
found = _strings(right)
|
|
209
|
+
if found:
|
|
210
|
+
self.consts[_text(left)] = found
|
|
211
|
+
if left is not None and left.type == "identifier":
|
|
212
|
+
ann = st.named_children[0].child_by_field_name("type")
|
|
213
|
+
named = _annotation_types(_text(ann)) if ann is not None else []
|
|
214
|
+
made = _class_name(right.child_by_field_name("function")) if right is not None and right.type == "call" else None
|
|
215
|
+
if named or made:
|
|
216
|
+
self.res.var_types[_text(left)] = named[0] if named else made
|
|
217
|
+
|
|
218
|
+
def run(self) -> FileResult:
|
|
219
|
+
root = self.tree.root_node
|
|
220
|
+
scope: dict[str, str] = {}
|
|
221
|
+
self._block(root, parent_id=self.file_id, qual=self.mod, class_id=None,
|
|
222
|
+
cid=self.top_id, scope=scope, top=True)
|
|
223
|
+
if self.top_used:
|
|
224
|
+
self.res.nodes.append(Node(
|
|
225
|
+
id=self.top_id, kind="callable", name="<module>", parent_id=self.file_id,
|
|
226
|
+
language=LANGUAGE, path=self.path, span_start=1, span_end=root.end_point[0] + 1,
|
|
227
|
+
attrs={"signature": f"module {self.mod}", "visibility": "public",
|
|
228
|
+
"native_kind": "module_body", "argc_min": 0, "argc_max": 0}))
|
|
229
|
+
return self.res
|
|
230
|
+
|
|
231
|
+
# -- declarations --------------------------------------------------------
|
|
232
|
+
def _block(self, node, parent_id, qual, class_id, cid, scope, top=False) -> None:
|
|
233
|
+
calls_before = len(self.res.calls)
|
|
234
|
+
for c in node.children:
|
|
235
|
+
t = c.type
|
|
236
|
+
target = c
|
|
237
|
+
decorators: list[str] = []
|
|
238
|
+
if t == "decorated_definition":
|
|
239
|
+
decorators = [_squash(_text(d)) for d in c.children if d.type == "decorator"]
|
|
240
|
+
target = c.child_by_field_name("definition") or c.children[-1]
|
|
241
|
+
t = target.type
|
|
242
|
+
if any(OVERLOAD.match(d) for d in decorators):
|
|
243
|
+
continue # a signature for type checkers; the implementation follows under the same name
|
|
244
|
+
if top and t == "if_statement" and "TYPE_CHECKING" in _text(c.child_by_field_name("condition")):
|
|
245
|
+
# Declarations only type checkers read: imports and classes that annotations elsewhere name.
|
|
246
|
+
self._block(c.child_by_field_name("consequence"), parent_id, qual, class_id, cid, scope, top)
|
|
247
|
+
continue
|
|
248
|
+
if t in ("function_definition", "class_definition"):
|
|
249
|
+
self._head(c, target, cid, scope)
|
|
250
|
+
if t == "function_definition":
|
|
251
|
+
self._function(target, c, parent_id, qual, class_id, decorators)
|
|
252
|
+
elif t == "class_definition":
|
|
253
|
+
self._class(target, c, parent_id, qual, decorators, cid)
|
|
254
|
+
elif t in ("import_statement", "import_from_statement"):
|
|
255
|
+
self._import(c)
|
|
256
|
+
elif top and t == "if_statement" and "__name__" in _text(c.child_by_field_name("condition")):
|
|
257
|
+
self.top_used = True
|
|
258
|
+
self.res.nodes.append(Node(
|
|
259
|
+
id=self.top_id + "#entry", kind="entry_point", name=f"{self.path} (__main__)",
|
|
260
|
+
parent_id=self.file_id, language=LANGUAGE, path=self.path,
|
|
261
|
+
span_start=c.start_point[0] + 1, span_end=c.end_point[0] + 1,
|
|
262
|
+
attrs={"trigger": "cli", "address": self.path}))
|
|
263
|
+
self.res.edges.append(Edge("exposes", self.top_id + "#entry", self.top_id))
|
|
264
|
+
self._body(c, cid, class_id, scope)
|
|
265
|
+
elif t in COMPOUND and _defines(c):
|
|
266
|
+
self._compound(c, parent_id, qual, class_id, cid, scope, top)
|
|
267
|
+
else:
|
|
268
|
+
self._body(c, cid, None if cid == self.top_id else class_id, scope)
|
|
269
|
+
if cid == self.top_id and any(c.src_id == cid for c in self.res.calls[calls_before:]):
|
|
270
|
+
self.top_used = True
|
|
271
|
+
|
|
272
|
+
def _compound(self, node, parent_id, qual, class_id, cid, scope, top) -> None:
|
|
273
|
+
"""An if, try, with or loop at the top of a module or class whose blocks define functions or classes (a
|
|
274
|
+
fallback when an import fails, one definition per platform): those are declared as if written outside it,
|
|
275
|
+
and the rest is walked as it would be."""
|
|
276
|
+
for c in node.children:
|
|
277
|
+
if c.type == "block":
|
|
278
|
+
self._block(c, parent_id, qual, class_id, cid, scope, top)
|
|
279
|
+
elif c.type in CLAUSES:
|
|
280
|
+
self._compound(c, parent_id, qual, class_id, cid, scope, top)
|
|
281
|
+
else:
|
|
282
|
+
self._body(c, cid, None if cid == self.top_id else class_id, scope)
|
|
283
|
+
|
|
284
|
+
def _class(self, node, outer, parent_id, qual, decorators, run_cid=None) -> None:
|
|
285
|
+
"""run_cid: the function whose code runs the class statement (the module body, at the top level).
|
|
286
|
+
The class body runs there too, so its calls are made by that function."""
|
|
287
|
+
name = _text(node.child_by_field_name("name"))
|
|
288
|
+
qname = f"{qual}.{name}"
|
|
289
|
+
tid = f"{self.repo}:python:{qname}"
|
|
290
|
+
if tid in self.self_types:
|
|
291
|
+
return # defined again under the same name (in another branch): the first one stands
|
|
292
|
+
body = node.child_by_field_name("body")
|
|
293
|
+
head_end = body.start_byte if body is not None else node.end_byte
|
|
294
|
+
self.res.nodes.append(Node(
|
|
295
|
+
id=tid, kind="type", name=name, parent_id=parent_id, language=LANGUAGE, path=self.path,
|
|
296
|
+
span_start=outer.start_point[0] + 1, span_end=outer.end_point[0] + 1,
|
|
297
|
+
attrs={"native_kind": "class", "namespace": qual,
|
|
298
|
+
"visibility": "private" if name.startswith("_") else "public",
|
|
299
|
+
"decorators": decorators, "is_abstract": False,
|
|
300
|
+
"signature": _squash(self.src[node.start_byte:head_end].decode("utf8", "replace")).rstrip(":")}))
|
|
301
|
+
supers = node.child_by_field_name("superclasses")
|
|
302
|
+
if supers is not None:
|
|
303
|
+
for s in supers.named_children:
|
|
304
|
+
if s.type == "call":
|
|
305
|
+
# class Manager(BaseManager.from_queryset(QuerySet)): a base made at run time, members unknown.
|
|
306
|
+
self.res.nodes[-1].attrs["open_base"] = True
|
|
307
|
+
if s.type == "subscript":
|
|
308
|
+
s = s.child_by_field_name("value") # a generic base: Mixin[T] is Mixin
|
|
309
|
+
if s is not None and s.type in ("identifier", "attribute"):
|
|
310
|
+
# flask.Flask stays whole: the module in front says which Flask, when it was imported as a module.
|
|
311
|
+
self.res.type_refs.append(TypeRef(tid, [_text(s)], "base", s.start_point[0] + 1))
|
|
312
|
+
self.self_types.setdefault(tid, {})
|
|
313
|
+
if body is None:
|
|
314
|
+
return
|
|
315
|
+
# Class-level assignments are fields.
|
|
316
|
+
for st in body.children:
|
|
317
|
+
if st.type == "expression_statement" and st.named_child_count and st.named_children[0].type == "assignment":
|
|
318
|
+
left = st.named_children[0].child_by_field_name("left")
|
|
319
|
+
if left is not None and left.type == "identifier":
|
|
320
|
+
self._field(tid, _text(left), st, st.named_children[0])
|
|
321
|
+
self._block(body, tid, qname, tid, run_cid, {})
|
|
322
|
+
|
|
323
|
+
def _field(self, tid, name, node, assignment=None, scope=None, cid=None) -> None:
|
|
324
|
+
fid = f"{tid}.{name}"
|
|
325
|
+
declared, cls, via, via_param = None, None, None, None
|
|
326
|
+
if assignment is not None:
|
|
327
|
+
ann = assignment.child_by_field_name("type")
|
|
328
|
+
declared = _squash(_text(ann)) if ann is not None else None
|
|
329
|
+
cls = (_annotation_types(declared) or [None])[0] if declared else None
|
|
330
|
+
elem = _element_type(declared)
|
|
331
|
+
if elem:
|
|
332
|
+
self.self_elems.setdefault(tid, {}).setdefault(name, elem)
|
|
333
|
+
right = assignment.child_by_field_name("right")
|
|
334
|
+
if right is not None and right.type == "call":
|
|
335
|
+
fn = right.child_by_field_name("function")
|
|
336
|
+
made = _class_name(fn)
|
|
337
|
+
if made and not cls:
|
|
338
|
+
cls = made
|
|
339
|
+
declared = declared or made
|
|
340
|
+
elif fn is not None and fn.type == "attribute" and _text(fn.child_by_field_name("object")) == "self":
|
|
341
|
+
via = _text(fn.child_by_field_name("attribute")) # self.config = self.make_config(): what it returns
|
|
342
|
+
elif right is not None and right.type == "identifier" and scope:
|
|
343
|
+
cls = cls or scope.get(_text(right)) # self.serializer = serializer, a parameter of a declared type
|
|
344
|
+
if scope.get("^" + _text(right)) and cid:
|
|
345
|
+
via_param = [cid, _text(right)] # and whatever callers pass for it
|
|
346
|
+
if cls and name not in self.self_types.get(tid, {}):
|
|
347
|
+
self.self_types.setdefault(tid, {})[name] = cls
|
|
348
|
+
seen = next((n for n in self.res.nodes[-200:] if n.id == fid), None)
|
|
349
|
+
if seen is not None:
|
|
350
|
+
# Declared again (often `None` in one place, an instance in another): keep the first type found.
|
|
351
|
+
if cls and not seen.attrs.get("type_name"):
|
|
352
|
+
seen.attrs["type_name"], seen.attrs["declared_type"] = cls, seen.attrs.get("declared_type") or declared
|
|
353
|
+
if via and not seen.attrs.get("type_call"):
|
|
354
|
+
seen.attrs["type_call"] = via
|
|
355
|
+
if via_param:
|
|
356
|
+
seen.attrs["from_params"] = (seen.attrs.get("from_params") or []) + [via_param]
|
|
357
|
+
return
|
|
358
|
+
self.res.nodes.append(Node(
|
|
359
|
+
id=fid, kind="field", name=name, parent_id=tid, language=LANGUAGE, path=self.path,
|
|
360
|
+
span_start=node.start_point[0] + 1, span_end=node.end_point[0] + 1,
|
|
361
|
+
attrs={"native_kind": "attribute", "declared_type": declared,
|
|
362
|
+
"type_name": self.self_types.get(tid, {}).get(name), "type_call": via,
|
|
363
|
+
"from_params": [via_param] if via_param else None,
|
|
364
|
+
"visibility": "private" if name.startswith("_") else "public", "is_mutable": True}))
|
|
365
|
+
self.res.edges.append(Edge("has_field", tid, fid))
|
|
366
|
+
|
|
367
|
+
def _function(self, node, outer, parent_id, qual, class_id, decorators, outer_scope=None) -> None:
|
|
368
|
+
name = _text(node.child_by_field_name("name"))
|
|
369
|
+
cid = f"{self.repo}:python:{qual}.{name}"
|
|
370
|
+
params = node.child_by_field_name("parameters")
|
|
371
|
+
body = node.child_by_field_name("body")
|
|
372
|
+
scope: dict[str, str] = dict(outer_scope or {}) # a nested function sees the enclosing locals
|
|
373
|
+
pnames: list[str] = []
|
|
374
|
+
ptypes: dict[str, str] = {}
|
|
375
|
+
required = total = 0
|
|
376
|
+
variadic = False
|
|
377
|
+
star_at = None # how many named parameters come before *args
|
|
378
|
+
is_method = class_id is not None and parent_id == class_id
|
|
379
|
+
is_static = any("staticmethod" in d for d in decorators)
|
|
380
|
+
if params is not None:
|
|
381
|
+
first = True
|
|
382
|
+
for p in params.named_children:
|
|
383
|
+
pname, ptype, has_default = None, None, False
|
|
384
|
+
if p.type == "typed_parameter" and p.named_children and p.named_children[0].type in (
|
|
385
|
+
"list_splat_pattern", "dictionary_splat_pattern"):
|
|
386
|
+
p = p.named_children[0] # *args: Any is still *args
|
|
387
|
+
if p.type == "identifier":
|
|
388
|
+
pname = _text(p)
|
|
389
|
+
elif p.type in ("typed_parameter", "default_parameter", "typed_default_parameter"):
|
|
390
|
+
nm = p.child_by_field_name("name") or (p.named_children[0] if p.named_children else None)
|
|
391
|
+
pname = _text(nm)
|
|
392
|
+
ptype = p.child_by_field_name("type")
|
|
393
|
+
has_default = "default" in p.type
|
|
394
|
+
elif p.type in ("list_splat_pattern", "dictionary_splat_pattern"):
|
|
395
|
+
variadic = True
|
|
396
|
+
if p.type == "list_splat_pattern" and star_at is None:
|
|
397
|
+
star_at = len(pnames)
|
|
398
|
+
continue
|
|
399
|
+
else:
|
|
400
|
+
continue
|
|
401
|
+
if first and is_method and not is_static:
|
|
402
|
+
first = False
|
|
403
|
+
continue
|
|
404
|
+
first = False
|
|
405
|
+
total += 1
|
|
406
|
+
if pname:
|
|
407
|
+
pnames.append(pname)
|
|
408
|
+
scope.pop(pname, None) # a parameter hides an outer local of the same name
|
|
409
|
+
scope.pop("[" + pname, None)
|
|
410
|
+
scope["^" + pname] = "1"
|
|
411
|
+
if not has_default:
|
|
412
|
+
required += 1
|
|
413
|
+
if ptype is not None:
|
|
414
|
+
elem = _element_type(_text(ptype))
|
|
415
|
+
if elem and pname:
|
|
416
|
+
scope["[" + pname] = elem
|
|
417
|
+
tn = _annotation_types(_text(ptype))
|
|
418
|
+
if tn:
|
|
419
|
+
scope[pname] = ptypes[pname] = tn[0]
|
|
420
|
+
self.res.type_refs.append(TypeRef(cid, tn, "param", p.start_point[0] + 1))
|
|
421
|
+
head_end = body.start_byte if body is not None else node.end_byte
|
|
422
|
+
ret = node.child_by_field_name("return_type")
|
|
423
|
+
returns = (_annotation_types(_text(ret)) or [None])[0] if ret is not None else None
|
|
424
|
+
fn_node = Node(
|
|
425
|
+
id=cid, kind="callable", name=name, parent_id=parent_id, language=LANGUAGE, path=self.path,
|
|
426
|
+
span_start=outer.start_point[0] + 1, span_end=outer.end_point[0] + 1,
|
|
427
|
+
attrs={"signature": _squash(self.src[node.start_byte:head_end].decode("utf8", "replace")).rstrip(":"),
|
|
428
|
+
"visibility": "private" if name.startswith("_") and not name.startswith("__") else "public",
|
|
429
|
+
"is_static": is_static or not is_method, "is_async": _text(node).startswith("async"),
|
|
430
|
+
"is_virtual": is_method, "decorators": decorators,
|
|
431
|
+
"is_test": bool(self.is_test_file and name.startswith("test")) or None,
|
|
432
|
+
"framework": "pytest" if self.is_test_file and name.startswith("test") else None,
|
|
433
|
+
"native_kind": "method" if is_method else "function",
|
|
434
|
+
"argc_min": required, "argc_max": 99 if variadic else total, "type_id": class_id if is_method else None,
|
|
435
|
+
"params": pnames, "returns": returns, "ptypes": ptypes or None, "star_at": star_at,
|
|
436
|
+
"body_line": body.start_point[0] + 1 if body is not None else None,
|
|
437
|
+
"is_fixture": any(re.search(r"\bfixture\b", d) for d in decorators) or None})
|
|
438
|
+
self.res.nodes.append(fn_node)
|
|
439
|
+
self.fn_nodes[cid] = fn_node
|
|
440
|
+
for d in decorators:
|
|
441
|
+
m = ROUTE.search(d)
|
|
442
|
+
if m and m.group(2).startswith("/"):
|
|
443
|
+
self.res.endpoints.append(Endpoint("http", "serve", cid, m.group(2), outer.start_point[0] + 1,
|
|
444
|
+
None if m.group(1) in ("route", "websocket") else m.group(1).upper()))
|
|
445
|
+
if body is not None:
|
|
446
|
+
self._body(body, cid, class_id if is_method else None, scope, qual=f"{qual}.{name}")
|
|
447
|
+
inner = {_text(d.child_by_field_name("name")) for c in body.children
|
|
448
|
+
for d in [c.child_by_field_name("definition") if c.type == "decorated_definition" else c]
|
|
449
|
+
if d is not None and d.type == "function_definition"}
|
|
450
|
+
made = next((x for x in self._return_names(body) if x in inner), None)
|
|
451
|
+
if made:
|
|
452
|
+
fn_node.attrs["returns_fn"] = made # a decorator factory: `return decorator`, defined just above
|
|
453
|
+
if returns is None:
|
|
454
|
+
# No annotation: read the type off what is returned or yielded.
|
|
455
|
+
kind, value = self._returned(body, scope)
|
|
456
|
+
if kind == "type":
|
|
457
|
+
fn_node.attrs["returns"] = value
|
|
458
|
+
elif kind == "call":
|
|
459
|
+
fn_node.attrs["returns_call"] = value
|
|
460
|
+
|
|
461
|
+
def _return_names(self, body) -> list[str]:
|
|
462
|
+
"""Names returned as they are (`return decorator`), not looking into nested functions."""
|
|
463
|
+
out, stack = [], list(body.children)
|
|
464
|
+
while stack:
|
|
465
|
+
n = stack.pop()
|
|
466
|
+
if n.type in ("function_definition", "class_definition", "decorated_definition", "lambda"):
|
|
467
|
+
continue
|
|
468
|
+
if n.type == "return_statement" and n.named_children and n.named_children[0].type == "identifier":
|
|
469
|
+
out.append(_text(n.named_children[0]))
|
|
470
|
+
stack.extend(n.children)
|
|
471
|
+
return out
|
|
472
|
+
|
|
473
|
+
def _returned(self, body, scope):
|
|
474
|
+
stack = list(body.children)
|
|
475
|
+
while stack:
|
|
476
|
+
n = stack.pop()
|
|
477
|
+
if n.type in ("function_definition", "class_definition", "decorated_definition", "lambda"):
|
|
478
|
+
continue
|
|
479
|
+
if n.type in ("return_statement", "yield"):
|
|
480
|
+
expr = n.named_children[0] if n.named_children else None
|
|
481
|
+
if expr is not None and expr.type == "identifier" and _text(expr) in scope:
|
|
482
|
+
return "type", scope[_text(expr)]
|
|
483
|
+
if expr is not None and expr.type == "call":
|
|
484
|
+
fn = expr.child_by_field_name("function")
|
|
485
|
+
last = _text(fn).split(".")[-1]
|
|
486
|
+
if _class_name(fn):
|
|
487
|
+
return "type", _class_name(fn)
|
|
488
|
+
if fn is not None and fn.type == "attribute" and fn.child_by_field_name("object").type == "identifier":
|
|
489
|
+
return "call", [_text(fn.child_by_field_name("object")), last]
|
|
490
|
+
stack.extend(n.children)
|
|
491
|
+
return None, None
|
|
492
|
+
|
|
493
|
+
def _import(self, node, src=None) -> None:
|
|
494
|
+
"""src: the function an import inside a function belongs to. The names it binds are that function's."""
|
|
495
|
+
src = src or self.file_id
|
|
496
|
+
if node.type == "import_statement":
|
|
497
|
+
for c in node.named_children:
|
|
498
|
+
if c.type == "dotted_name":
|
|
499
|
+
self.res.imports.append(ImportRef(src, _text(c), alias=_text(c).split(".")[0]))
|
|
500
|
+
elif c.type == "aliased_import":
|
|
501
|
+
self.res.imports.append(ImportRef(
|
|
502
|
+
src, _text(c.child_by_field_name("name")),
|
|
503
|
+
alias=_text(c.child_by_field_name("alias"))))
|
|
504
|
+
else:
|
|
505
|
+
mod = node.child_by_field_name("module_name")
|
|
506
|
+
target = _text(mod)
|
|
507
|
+
if target.startswith("."):
|
|
508
|
+
if self.path.endswith("__init__.py"):
|
|
509
|
+
base = self.mod # inside a package's __init__, `.` is the package itself
|
|
510
|
+
else:
|
|
511
|
+
base = self.mod.rsplit(".", 1)[0] if "." in self.mod else ""
|
|
512
|
+
dots = len(target) - len(target.lstrip("."))
|
|
513
|
+
for _ in range(dots - 1):
|
|
514
|
+
base = base.rsplit(".", 1)[0] if "." in base else ""
|
|
515
|
+
rest = target.lstrip(".")
|
|
516
|
+
target = ".".join(x for x in (base, rest) if x)
|
|
517
|
+
symbols = []
|
|
518
|
+
for c in node.named_children:
|
|
519
|
+
if c is mod or (mod is not None and c.start_byte == mod.start_byte):
|
|
520
|
+
continue
|
|
521
|
+
if c.type == "dotted_name":
|
|
522
|
+
symbols.append(_text(c))
|
|
523
|
+
elif c.type == "aliased_import":
|
|
524
|
+
symbols.append(_text(c.child_by_field_name("name")) + " as " + _text(c.child_by_field_name("alias")))
|
|
525
|
+
elif c.type == "wildcard_import":
|
|
526
|
+
symbols.append("*")
|
|
527
|
+
self.res.imports.append(ImportRef(src, target, symbols=symbols))
|
|
528
|
+
|
|
529
|
+
def _use(self, node, cid, class_id, scope) -> None:
|
|
530
|
+
"""Record obj.attr as a read or an assignment of a possible field."""
|
|
531
|
+
parent = node.parent
|
|
532
|
+
same = lambda a, b: a is not None and b is not None and a.start_byte == b.start_byte and a.end_byte == b.end_byte
|
|
533
|
+
if parent is None or (parent.type == "call" and same(parent.child_by_field_name("function"), node)):
|
|
534
|
+
return # a method call
|
|
535
|
+
obj, attr = node.child_by_field_name("object"), node.child_by_field_name("attribute")
|
|
536
|
+
if obj is None or attr is None:
|
|
537
|
+
return
|
|
538
|
+
name, otext = _text(attr), _text(obj)
|
|
539
|
+
receiver = rtype = chain = None
|
|
540
|
+
if otext == "self":
|
|
541
|
+
receiver = "this"
|
|
542
|
+
elif obj.type == "identifier":
|
|
543
|
+
receiver, rtype = otext, scope.get(otext)
|
|
544
|
+
chain = None if rtype else scope.get("~" + otext)
|
|
545
|
+
elif obj.type == "attribute" and _text(obj.child_by_field_name("object")) == "self":
|
|
546
|
+
inner = _text(obj.child_by_field_name("attribute"))
|
|
547
|
+
receiver, rtype = "." + inner, self.self_types.get(class_id or "", {}).get(inner)
|
|
548
|
+
elif obj.type == "call":
|
|
549
|
+
receiver, chain = "?", self._site(obj, cid, class_id, scope)
|
|
550
|
+
rtype = _class_name(obj.child_by_field_name("function")) # Box().size: a Box
|
|
551
|
+
chain = None if rtype else chain
|
|
552
|
+
else:
|
|
553
|
+
receiver = "?"
|
|
554
|
+
cur, up = node, parent
|
|
555
|
+
while up is not None and up.type in ("pattern_list", "tuple_pattern", "list_pattern", "tuple", "parenthesized_expression"):
|
|
556
|
+
cur, up = up, up.parent
|
|
557
|
+
access = "r"
|
|
558
|
+
if up is not None:
|
|
559
|
+
if up.type == "assignment" and same(up.child_by_field_name("left"), cur):
|
|
560
|
+
access = "w"
|
|
561
|
+
elif up.type == "augmented_assignment" and same(up.child_by_field_name("left"), cur):
|
|
562
|
+
access = "rw"
|
|
563
|
+
elif up.type == "subscript" and same(up.child_by_field_name("value"), cur):
|
|
564
|
+
outer = up.parent
|
|
565
|
+
if outer is not None and outer.type in ("assignment", "augmented_assignment") and same(outer.child_by_field_name("left"), up):
|
|
566
|
+
access = "rw" # self.x[i] = v changes what the field holds
|
|
567
|
+
if access == "r" and up is not None: # self.x.append(v), self.x[k].add(v): a call that changes what the field holds
|
|
568
|
+
c2, u2 = cur, up
|
|
569
|
+
if u2.type == "subscript" and same(u2.child_by_field_name("value"), c2):
|
|
570
|
+
c2, u2 = u2, u2.parent
|
|
571
|
+
if u2 is not None and u2.type == "attribute" and same(u2.child_by_field_name("object"), c2) \
|
|
572
|
+
and u2.parent is not None and u2.parent.type == "call" and same(u2.parent.child_by_field_name("function"), u2) \
|
|
573
|
+
and _text(u2.child_by_field_name("attribute")) in MUTATORS:
|
|
574
|
+
access = "rw"
|
|
575
|
+
self.res.field_uses.append(FieldUse(cid, name, receiver, rtype, access, node.start_point[0] + 1, class_id, chain))
|
|
576
|
+
|
|
577
|
+
def _site(self, node, cid, class_id, scope) -> Optional[CallSite]:
|
|
578
|
+
"""Record one call. A call used as the receiver of another is recorded once and shared."""
|
|
579
|
+
key = (node.start_byte, node.end_byte)
|
|
580
|
+
if key in self.done_calls:
|
|
581
|
+
return self.done_calls[key]
|
|
582
|
+
self.done_calls[key] = None
|
|
583
|
+
fn = node.child_by_field_name("function")
|
|
584
|
+
args = node.child_by_field_name("arguments")
|
|
585
|
+
if fn is None:
|
|
586
|
+
return None
|
|
587
|
+
argc = sum(a.type != "comment" for a in args.named_children) if args is not None else 0
|
|
588
|
+
site = self._callee(fn, argc, node.start_point[0] + 1, node.start_point[1], cid, class_id, scope)
|
|
589
|
+
if site is not None and args is not None and args.type == "argument_list":
|
|
590
|
+
site.args = self._hints(args, scope, class_id)
|
|
591
|
+
if site is not None:
|
|
592
|
+
self.res.calls.append(site)
|
|
593
|
+
self._endpoint(node, fn, args, cid, site.line)
|
|
594
|
+
self.done_calls[key] = site
|
|
595
|
+
return site
|
|
596
|
+
|
|
597
|
+
def _hints(self, args, scope, class_id) -> tuple:
|
|
598
|
+
"""What each positional argument is, for working out later what a parameter receives: a class name,
|
|
599
|
+
`@self`, `@p:name` (a parameter of the caller), `@f:name` (a field of self), `@*` (*args passed on)."""
|
|
600
|
+
out = []
|
|
601
|
+
for a in args.named_children:
|
|
602
|
+
if a.type == "comment":
|
|
603
|
+
continue
|
|
604
|
+
if a.type in ("keyword_argument", "dictionary_splat"):
|
|
605
|
+
break
|
|
606
|
+
text = _text(a)
|
|
607
|
+
if a.type == "list_splat":
|
|
608
|
+
out.append("@*")
|
|
609
|
+
elif a.type == "identifier":
|
|
610
|
+
out.append("@self" if text == "self" and class_id else "@p:" + text if scope.get("^" + text) else scope.get(text))
|
|
611
|
+
elif a.type == "attribute" and _text(a.child_by_field_name("object")) == "self":
|
|
612
|
+
out.append("@f:" + _text(a.child_by_field_name("attribute")))
|
|
613
|
+
elif a.type == "call":
|
|
614
|
+
out.append(_class_name(a.child_by_field_name("function")))
|
|
615
|
+
else:
|
|
616
|
+
out.append(None)
|
|
617
|
+
return tuple(out)
|
|
618
|
+
|
|
619
|
+
def _callee(self, fn, argc, line, col, cid, class_id, scope) -> Optional[CallSite]:
|
|
620
|
+
"""The call site for a callee expression: `f`, `self.f`, `x.f`, `a.make().f`."""
|
|
621
|
+
site = None
|
|
622
|
+
if fn.type == "identifier":
|
|
623
|
+
site = CallSite(cid, _text(fn), None, None, argc, line, class_id, col)
|
|
624
|
+
elif fn.type == "attribute":
|
|
625
|
+
obj = fn.child_by_field_name("object")
|
|
626
|
+
name = _text(fn.child_by_field_name("attribute"))
|
|
627
|
+
otext = _text(obj)
|
|
628
|
+
if obj is not None and obj.type == "call" and _text(obj.child_by_field_name("function")) == "super":
|
|
629
|
+
site = CallSite(cid, name, "base", None, argc, line, class_id, col)
|
|
630
|
+
elif otext == "self":
|
|
631
|
+
site = CallSite(cid, name, "this", None, argc, line, class_id, col)
|
|
632
|
+
elif obj is not None and obj.type == "identifier":
|
|
633
|
+
known = scope.get(otext)
|
|
634
|
+
site = CallSite(cid, name, otext, known, argc, line, class_id, col,
|
|
635
|
+
chain=None if known else scope.get("~" + otext))
|
|
636
|
+
elif obj is not None and obj.type == "attribute" and _text(obj.child_by_field_name("object")) == "self":
|
|
637
|
+
attr = _text(obj.child_by_field_name("attribute"))
|
|
638
|
+
rtype = self.self_types.get(class_id or "", {}).get(attr)
|
|
639
|
+
site = CallSite(cid, name, "." + attr, rtype, argc, line, class_id, col)
|
|
640
|
+
elif obj is not None and obj.type == "call":
|
|
641
|
+
inner = self._site(obj, cid, class_id, scope) # a.make().run(): typed by what make returns
|
|
642
|
+
made = _class_name(obj.child_by_field_name("function")) # Box().size(): a Box, __init__ or not
|
|
643
|
+
site = CallSite(cid, name, "?", made, argc, line, class_id, col, chain=None if made else inner)
|
|
644
|
+
elif obj is not None and obj.type == "attribute":
|
|
645
|
+
# app.config.load(), flask.g.get(): typed by the attribute read in front, once that is resolved.
|
|
646
|
+
inner = self._callee(obj, -1, line, col, cid, class_id, scope)
|
|
647
|
+
if inner is not None:
|
|
648
|
+
inner.attr = True
|
|
649
|
+
site = CallSite(cid, name, "?", None, argc, line, class_id, col, chain=inner)
|
|
650
|
+
else:
|
|
651
|
+
site = CallSite(cid, name, "?", None, argc, line, class_id, col)
|
|
652
|
+
return site
|
|
653
|
+
|
|
654
|
+
def _head(self, outer, fn, cid, scope, class_id=None) -> None:
|
|
655
|
+
"""Decorators and default values run where a function or class is defined, not inside it: their
|
|
656
|
+
calls are made by the code around the definition (the module body, for a method)."""
|
|
657
|
+
if cid is None:
|
|
658
|
+
return
|
|
659
|
+
for d in outer.children if outer.type == "decorated_definition" else ():
|
|
660
|
+
expr = d.named_children[0] if d.type == "decorator" and d.named_children else None
|
|
661
|
+
if expr is None:
|
|
662
|
+
continue
|
|
663
|
+
if expr.type in ("identifier", "attribute"):
|
|
664
|
+
# `@cache`, `@app.before_request`: the decorator itself is called with the function.
|
|
665
|
+
site = self._callee(expr, 1, expr.start_point[0] + 1, expr.start_point[1], cid, class_id, scope)
|
|
666
|
+
if site is not None:
|
|
667
|
+
self.res.calls.append(site)
|
|
668
|
+
else:
|
|
669
|
+
self._body(expr, cid, class_id, scope)
|
|
670
|
+
if expr.type == "call":
|
|
671
|
+
# `@app.route("/")`: what route returns is then called with the function.
|
|
672
|
+
site = self._site(expr, cid, class_id, scope)
|
|
673
|
+
if site is not None:
|
|
674
|
+
self.res.calls.append(CallSite(cid, "__call__", "?", None, 1, site.line, class_id, site.col, chain=site))
|
|
675
|
+
params = fn.child_by_field_name("parameters") if fn.type == "function_definition" else None
|
|
676
|
+
for p in params.named_children if params is not None else ():
|
|
677
|
+
value = p.child_by_field_name("value") if p.type in ("default_parameter", "typed_default_parameter") else None
|
|
678
|
+
if value is not None:
|
|
679
|
+
self._body(value, cid, class_id, scope)
|
|
680
|
+
|
|
681
|
+
def _endpoint(self, node, fn, args, cid, line) -> None:
|
|
682
|
+
"""Is this call one end of an HTTP or file channel?"""
|
|
683
|
+
full = _text(fn)
|
|
684
|
+
last = full.rsplit(".", 1)[-1]
|
|
685
|
+
if node.parent is not None and node.parent.type == "decorator" and not (cid in self.fn_nodes and (
|
|
686
|
+
self.is_test_file or self.fn_nodes[cid].attrs.get("is_test") or self.fn_nodes[cid].attrs.get("is_fixture"))):
|
|
687
|
+
# @app.post("/x") at the top of a module, or in an app factory, declares a route and requests nothing.
|
|
688
|
+
# Inside a test it is kept: the test declares the route to call it, often as client.get() with the path
|
|
689
|
+
# left out.
|
|
690
|
+
return
|
|
691
|
+
first = args.named_children[0] if args is not None and args.named_children else None
|
|
692
|
+
if fn.type == "attribute" and last in HTTP_VERBS | {"open"} and first is not None and first.type == "string":
|
|
693
|
+
path = _url_text(first)
|
|
694
|
+
if path.startswith(("/", "http://", "https://")):
|
|
695
|
+
method = None if last == "open" else last.upper()
|
|
696
|
+
if last == "open": # a test client's open("/x", method="POST")
|
|
697
|
+
method = _kw_string(args, "method")
|
|
698
|
+
method = method.upper() if method else None
|
|
699
|
+
self.res.endpoints.append(Endpoint("http", "call", cid, path, line, method))
|
|
700
|
+
return
|
|
701
|
+
if path.startswith("{}/") and len(path) > 3: # f"{BASE}/api/x": the host is in a variable
|
|
702
|
+
self.res.endpoints.append(Endpoint("http", "maybe", cid, path[2:].split("?", 1)[0], line,
|
|
703
|
+
None if last == "open" else last.upper()))
|
|
704
|
+
return
|
|
705
|
+
if fn.type == "attribute" and last in HTTP_VERBS and first is not None and first.type == "binary_operator" \
|
|
706
|
+
and _text(first.child_by_field_name("operator")) == "+":
|
|
707
|
+
right = first.child_by_field_name("right")
|
|
708
|
+
path = _url_text(right) if right is not None and right.type == "string" else ""
|
|
709
|
+
if path.startswith("/") and path.count("/") >= 2: # BASE_URL + "/api/x"
|
|
710
|
+
self.res.endpoints.append(Endpoint("http", "maybe", cid, path.split("?", 1)[0], line, last.upper()))
|
|
711
|
+
return
|
|
712
|
+
if self._wrapped_request(fn, full, last, args, cid, line):
|
|
713
|
+
return
|
|
714
|
+
role = None
|
|
715
|
+
if full == "open" or full.endswith((".open",)) and last == "open" and fn.type == "attribute" and _text(fn.child_by_field_name("object")) in ("io", "codecs", "gzip"):
|
|
716
|
+
mode = ""
|
|
717
|
+
if args is not None:
|
|
718
|
+
named = args.named_children
|
|
719
|
+
if len(named) > 1 and named[1].type == "string":
|
|
720
|
+
mode = "".join(_text(c) for c in named[1].children if c.type == "string_content")
|
|
721
|
+
for a in named:
|
|
722
|
+
if a.type == "keyword_argument" and _text(a.child_by_field_name("name")) == "mode":
|
|
723
|
+
mode = _text(a.child_by_field_name("value")).strip("'\"")
|
|
724
|
+
role = "write" if any(ch in mode for ch in "wax+") else "read"
|
|
725
|
+
elif full in FILE_WRITE or last in ("write_text", "write_bytes", "to_csv", "savefig", "to_json", "to_parquet"):
|
|
726
|
+
role = "write"
|
|
727
|
+
elif full in FILE_READ or last in ("read_text", "read_bytes", "read_csv", "read_json", "read_parquet"):
|
|
728
|
+
role = "read"
|
|
729
|
+
if role:
|
|
730
|
+
self.res.endpoints.append(Endpoint("file", role, cid, "", line, None, _strings(args) if args is not None else []))
|
|
731
|
+
if full in PATH_BUILDERS and args is not None:
|
|
732
|
+
self.res.path_strings.setdefault(cid, []).extend(_strings(args))
|
|
733
|
+
|
|
734
|
+
def _wrapped_request(self, fn, full, last, args, cid, line) -> bool:
|
|
735
|
+
"""A request through something the name does not give away: a session helper's request("GET", "/x"), an
|
|
736
|
+
api_get("/api/x/1") of the project's own, a url= keyword. Recorded as a path a route may serve; the indexer
|
|
737
|
+
links it only when exactly one route fits (see Indexer._resolve_endpoints)."""
|
|
738
|
+
if args is None or full in FILE_WRITE | FILE_READ | PATH_BUILDERS | LAUNCHERS or full == "open" \
|
|
739
|
+
or last in ("join", "open", "exists", "glob", "rglob", "joinpath", "startswith", "endswith", "split",
|
|
740
|
+
"replace", "format", "match", "search", "sub", "compile", "fullmatch", "findall", "strip",
|
|
741
|
+
"rstrip", "lstrip", "removeprefix", "removesuffix", "print", "info", "debug", "warning", "error",
|
|
742
|
+
"route", "websocket", "add_url_rule", "add_api_route", "api_route", "url_for", "redirect",
|
|
743
|
+
"Blueprint", "APIRouter", "Rule", "path", "re_path", "mount", "include_router"):
|
|
744
|
+
return False
|
|
745
|
+
named = args.named_children
|
|
746
|
+
method, url = None, None
|
|
747
|
+
if last == "request" and len(named) > 1 and named[0].type == "string" and named[1].type == "string":
|
|
748
|
+
verb = _url_text(named[0])
|
|
749
|
+
if verb.lower() in HTTP_VERBS:
|
|
750
|
+
method, url = verb.upper(), _url_text(named[1])
|
|
751
|
+
if url is None and named and named[0].type == "string":
|
|
752
|
+
url = _url_text(named[0])
|
|
753
|
+
if url is None:
|
|
754
|
+
url = _kw_string(args, "url") or _kw_string(args, "path")
|
|
755
|
+
if not url:
|
|
756
|
+
return False
|
|
757
|
+
url = url[2:] if url.startswith("{}/") else url
|
|
758
|
+
if not url.startswith("/") or url.count("/") < 2 or any(ch.isspace() for ch in url) or "." in url.rsplit("/", 1)[-1]:
|
|
759
|
+
return False
|
|
760
|
+
if method is None:
|
|
761
|
+
m = _kw_string(args, "method")
|
|
762
|
+
method = m.upper() if m and m.lower() in HTTP_VERBS else None
|
|
763
|
+
self.res.endpoints.append(Endpoint("http", "maybe", cid, url.split("?", 1)[0], line, method))
|
|
764
|
+
return True
|
|
765
|
+
|
|
766
|
+
# -- bodies --------------------------------------------------------------
|
|
767
|
+
def _body(self, node, cid, class_id, scope, qual=None) -> None:
|
|
768
|
+
t = node.type
|
|
769
|
+
if t == "function_definition" or t == "class_definition" or t == "decorated_definition":
|
|
770
|
+
if cid is not None and qual is not None:
|
|
771
|
+
target = node.child_by_field_name("definition") if t == "decorated_definition" else node
|
|
772
|
+
nested = [_squash(_text(d)) for d in node.children if d.type == "decorator"] if t == "decorated_definition" else []
|
|
773
|
+
if target is not None and target.type == "function_definition":
|
|
774
|
+
if any(OVERLOAD.match(d) for d in nested):
|
|
775
|
+
return
|
|
776
|
+
self._head(node, target, cid, scope, class_id)
|
|
777
|
+
self._function(target, node, cid, qual, None, nested, scope)
|
|
778
|
+
elif target is not None and target.type == "class_definition":
|
|
779
|
+
# A class defined inside a function (common in tests: a Flask subclass for one case).
|
|
780
|
+
self._head(node, target, cid, scope, class_id)
|
|
781
|
+
self._class(target, node, cid, qual, nested, cid)
|
|
782
|
+
return
|
|
783
|
+
if t in ("import_statement", "import_from_statement"):
|
|
784
|
+
self._import(node, None if cid in (None, self.top_id) else cid) # in a try block, or inside a function
|
|
785
|
+
elif t == "assignment":
|
|
786
|
+
left = node.child_by_field_name("left")
|
|
787
|
+
right = node.child_by_field_name("right")
|
|
788
|
+
if right is not None:
|
|
789
|
+
# The value is worked out before the name is bound: in `app = Wrap(app)` the argument is the old app.
|
|
790
|
+
self._body(right, cid, class_id, scope, qual)
|
|
791
|
+
if left is not None and left.type == "identifier":
|
|
792
|
+
scope.pop("^" + _text(left), None) # no longer the parameter's value
|
|
793
|
+
if left is not None and right is not None and right.type == "call":
|
|
794
|
+
cls = _class_name(right.child_by_field_name("function"))
|
|
795
|
+
if left.type == "identifier":
|
|
796
|
+
scope.pop("~" + _text(left), None)
|
|
797
|
+
if cls:
|
|
798
|
+
if left.type == "identifier":
|
|
799
|
+
scope[_text(left)] = cls
|
|
800
|
+
elif left.type == "identifier" and cid is not None:
|
|
801
|
+
# `x = a.make()`: typed by what make returns, once calls are resolved.
|
|
802
|
+
site = self._site(right, cid, class_id, scope)
|
|
803
|
+
if site is not None:
|
|
804
|
+
scope.pop(_text(left), None)
|
|
805
|
+
scope["~" + _text(left)] = site
|
|
806
|
+
elif left is not None and left.type == "identifier" and right is not None and right.type == "attribute" and cid is not None:
|
|
807
|
+
# `app = ctx.app`: typed by the attribute read, once that is resolved.
|
|
808
|
+
inner = self._callee(right, -1, right.start_point[0] + 1, right.start_point[1], cid, class_id, scope)
|
|
809
|
+
scope.pop(_text(left), None)
|
|
810
|
+
scope.pop("~" + _text(left), None)
|
|
811
|
+
if inner is not None:
|
|
812
|
+
inner.attr = True
|
|
813
|
+
scope["~" + _text(left)] = inner
|
|
814
|
+
if left is not None and left.type == "attribute" and class_id is not None:
|
|
815
|
+
obj = left.child_by_field_name("object")
|
|
816
|
+
attr = _text(left.child_by_field_name("attribute"))
|
|
817
|
+
if _text(obj) == "self" and attr:
|
|
818
|
+
self._field(class_id, attr, node, node, scope, cid)
|
|
819
|
+
elif left is not None and left.type in ("pattern_list", "tuple_pattern", "list_pattern") and class_id is not None:
|
|
820
|
+
# self.w, self.h = w, h: each is a field, of no type the right side says outright.
|
|
821
|
+
for el in left.named_children:
|
|
822
|
+
if el.type == "attribute" and _text(el.child_by_field_name("object")) == "self":
|
|
823
|
+
self._field(class_id, _text(el.child_by_field_name("attribute")), node)
|
|
824
|
+
for c in node.children:
|
|
825
|
+
if right is None or c.start_byte != right.start_byte or c.end_byte != right.end_byte:
|
|
826
|
+
self._body(c, cid, class_id, scope, qual)
|
|
827
|
+
return
|
|
828
|
+
elif t == "call":
|
|
829
|
+
fn = node.child_by_field_name("function")
|
|
830
|
+
args = node.child_by_field_name("arguments")
|
|
831
|
+
argc = len(args.named_children) if args is not None else 0
|
|
832
|
+
line = node.start_point[0] + 1
|
|
833
|
+
if fn is not None and cid is not None and _text(fn) in LAUNCHERS and args is not None:
|
|
834
|
+
found = _strings(args)
|
|
835
|
+
stack = [args]
|
|
836
|
+
while stack:
|
|
837
|
+
n = stack.pop()
|
|
838
|
+
if n.type == "identifier" and _text(n) in self.consts:
|
|
839
|
+
found.extend(self.consts[_text(n)])
|
|
840
|
+
stack.extend(n.children)
|
|
841
|
+
if self.all_strings is None:
|
|
842
|
+
self.all_strings = _strings(self.tree.root_node)
|
|
843
|
+
self.res.spawns.append(Spawn(cid, found, self.all_strings, "PIPE" in _text(args), line))
|
|
844
|
+
if cid == self.top_id:
|
|
845
|
+
self.top_used = True
|
|
846
|
+
if fn is not None and cid is not None:
|
|
847
|
+
self._site(node, cid, class_id, scope)
|
|
848
|
+
if fn is not None and _text(fn) in ("isinstance", "callable") and args is not None and args.named_children \
|
|
849
|
+
and args.named_children[0].type == "identifier" and cid in self.fn_nodes:
|
|
850
|
+
# `isinstance(x, Foo)`: the code branches on what x is, so what callers pass says little about
|
|
851
|
+
# which branch a call on x sits in.
|
|
852
|
+
narrowed = self.fn_nodes[cid].attrs.setdefault("narrowed", [])
|
|
853
|
+
if _text(args.named_children[0]) not in narrowed:
|
|
854
|
+
narrowed.append(_text(args.named_children[0]))
|
|
855
|
+
elif t == "attribute" and cid is not None:
|
|
856
|
+
self._use(node, cid, class_id, scope)
|
|
857
|
+
elif t == "string" and cid is not None:
|
|
858
|
+
text = "".join(_text(c) for c in node.children if c.type == "string_content")
|
|
859
|
+
if pathlike(text):
|
|
860
|
+
self.res.path_strings.setdefault(cid, []).append(text)
|
|
861
|
+
elif t == "if_statement" and cid is not None and _narrowing(node.child_by_field_name("condition")):
|
|
862
|
+
# `if isinstance(rhs, Query): rhs.add_q(...)`: inside the branch, rhs is a Query.
|
|
863
|
+
cond, body = node.child_by_field_name("condition"), node.child_by_field_name("consequence")
|
|
864
|
+
self._body(cond, cid, class_id, scope, qual)
|
|
865
|
+
saved = {}
|
|
866
|
+
for name, cls in _narrowing(cond):
|
|
867
|
+
for k in (name, "~" + name):
|
|
868
|
+
saved[k] = scope.pop(k, None)
|
|
869
|
+
scope[name] = cls
|
|
870
|
+
if body is not None:
|
|
871
|
+
self._body(body, cid, class_id, scope, qual)
|
|
872
|
+
for k, v in saved.items():
|
|
873
|
+
scope.pop(k, None)
|
|
874
|
+
if v is not None:
|
|
875
|
+
scope[k] = v
|
|
876
|
+
for c in node.children:
|
|
877
|
+
if c.type in ("elif_clause", "else_clause"):
|
|
878
|
+
self._body(c, cid, class_id, scope, qual)
|
|
879
|
+
return
|
|
880
|
+
elif t == "for_statement" and cid is not None:
|
|
881
|
+
left, right = node.child_by_field_name("left"), node.child_by_field_name("right")
|
|
882
|
+
if left is not None and left.type == "identifier" and right is not None:
|
|
883
|
+
# `for tag in self.order`, with `order: list[JSONTag]`: each tag is a JSONTag.
|
|
884
|
+
elem = None
|
|
885
|
+
if right.type == "identifier":
|
|
886
|
+
elem = scope.get("[" + _text(right))
|
|
887
|
+
elif right.type == "attribute" and _text(right.child_by_field_name("object")) == "self":
|
|
888
|
+
elem = self.self_elems.get(class_id or "", {}).get(_text(right.child_by_field_name("attribute")))
|
|
889
|
+
name = _text(left)
|
|
890
|
+
for k in (name, "~" + name, "^" + name, "[" + name):
|
|
891
|
+
scope.pop(k, None)
|
|
892
|
+
if elem:
|
|
893
|
+
scope[name] = elem
|
|
894
|
+
elif t == "with_item" and cid is not None and node.named_children:
|
|
895
|
+
# `with app.app_context():` runs the context manager's __enter__ and __exit__.
|
|
896
|
+
value = node.named_children[0]
|
|
897
|
+
if value.type == "as_pattern" and value.named_children:
|
|
898
|
+
value = value.named_children[0]
|
|
899
|
+
recv, rtype, chain = "?", None, None
|
|
900
|
+
if value.type == "call":
|
|
901
|
+
chain = self._site(value, cid, class_id, scope)
|
|
902
|
+
elif value.type == "identifier":
|
|
903
|
+
recv, rtype = _text(value), scope.get(_text(value))
|
|
904
|
+
chain = None if rtype else scope.get("~" + recv)
|
|
905
|
+
elif value.type == "attribute" and _text(value.child_by_field_name("object")) == "self":
|
|
906
|
+
attr = _text(value.child_by_field_name("attribute"))
|
|
907
|
+
recv, rtype = "." + attr, self.self_types.get(class_id or "", {}).get(attr)
|
|
908
|
+
if recv != "?" or chain is not None:
|
|
909
|
+
line, col = value.start_point[0] + 1, value.start_point[1]
|
|
910
|
+
for name, argc in (("__enter__", 0), ("__exit__", 3)):
|
|
911
|
+
self.res.calls.append(CallSite(cid, name, recv, rtype, argc, line, class_id, col, chain=chain))
|
|
912
|
+
elif t == "as_pattern" and cid is not None and node.named_child_count >= 2:
|
|
913
|
+
# `with app.test_client() as c:` binds c to what the call returns.
|
|
914
|
+
value, alias = node.named_children[0], node.named_children[-1]
|
|
915
|
+
target = alias.named_children[0] if alias.type == "as_pattern_target" and alias.named_children else alias
|
|
916
|
+
if value.type == "call" and target.type == "identifier":
|
|
917
|
+
site = self._site(value, cid, class_id, scope)
|
|
918
|
+
if site is not None:
|
|
919
|
+
scope.pop(_text(target), None)
|
|
920
|
+
scope["~" + _text(target)] = site
|
|
921
|
+
for c in node.children:
|
|
922
|
+
self._body(c, cid, class_id, scope, qual)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def parse(repo: str, rel_path: str, file_id: str, src: bytes, module: str = "") -> FileResult:
|
|
926
|
+
w = _Walker(repo, rel_path, file_id, src)
|
|
927
|
+
res = w.run()
|
|
928
|
+
channels.extract(LANGUAGE, w.tree, res, file_id, w.consts)
|
|
929
|
+
return res
|