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.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. 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