diffcone 0.1.0__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.
@@ -0,0 +1,1001 @@
1
+ """Pass 2: the in-scope class model (bases, MRO, overrides) and the
2
+ resolution of names and attribute chains into edges."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ast
7
+ from collections import defaultdict
8
+
9
+ from diffcone.indexer.definitions import (
10
+ _EXPLICIT_SPECIAL_METHODS,
11
+ _STRUCTURAL_BASES,
12
+ _class_attributes,
13
+ _flatten_chain,
14
+ _future_annotations,
15
+ _has_decorator,
16
+ _is_inert_decorator,
17
+ _is_special_method,
18
+ _is_staticmethod,
19
+ _rebound_names,
20
+ )
21
+ from diffcone.indexer.facts import _FuncParams
22
+ from diffcone.indexer.literals import NESTED_SCOPES, _LocalBindings
23
+ from diffcone.indexer.references import _ReferenceCollector
24
+ from diffcone.indexer.scopes import (
25
+ ClassScope,
26
+ External,
27
+ ImportBinding,
28
+ Local,
29
+ ModuleNode,
30
+ ModuleScope,
31
+ Node,
32
+ Resolved,
33
+ Scope,
34
+ Unresolved,
35
+ _absolute_module,
36
+ relative_import_escapes,
37
+ )
38
+ from diffcone.indexer.symbols import FirstPass
39
+ from diffcone.indexer.syntax import (
40
+ BUILTIN_NAMES,
41
+ DEF_NODES,
42
+ FUNC_NODES,
43
+ _digest,
44
+ iter_scope_statements,
45
+ )
46
+ from diffcone.model import (
47
+ CLASS,
48
+ FUNCTION,
49
+ IMPORTS,
50
+ IMPORTS_NAME,
51
+ METHOD,
52
+ MODULE,
53
+ REFERENCES,
54
+ UNRESOLVED_ATTRIBUTE,
55
+ UNRESOLVED_DYNAMIC,
56
+ UNRESOLVED_NAME,
57
+ VARIABLE,
58
+ Edge,
59
+ ExternalReference,
60
+ Symbol,
61
+ UnresolvedReference,
62
+ )
63
+
64
+
65
+ def _with_alternatives(bindings: list[Node]) -> Node:
66
+ """The node for a name bound several ways at module level: the first
67
+ binding (a definition, else the last import), carrying the in-scope
68
+ symbols and modules of the others as alternatives. When the first one
69
+ names nothing in scope (an external fallback tried first) an in-scope
70
+ alternative takes its place, so the reference is not merely external."""
71
+ in_scope: list[str] = []
72
+ for node in bindings:
73
+ if isinstance(node, Resolved):
74
+ in_scope.append(node.symbol)
75
+ elif isinstance(node, ModuleNode):
76
+ in_scope.append(node.module)
77
+ primary = bindings[0]
78
+ if not isinstance(primary, (Resolved, ModuleNode)):
79
+ primary = next((b for b in bindings if isinstance(b, Resolved)), primary)
80
+ if not isinstance(primary, Resolved):
81
+ return primary
82
+ others = tuple(dict.fromkeys(i for i in in_scope if i != primary.symbol))
83
+ if not others:
84
+ return primary
85
+ return Resolved(
86
+ primary.symbol,
87
+ primary.detail,
88
+ primary.uncertain_attr,
89
+ primary.receiver,
90
+ primary.overrides,
91
+ primary.also,
92
+ others,
93
+ )
94
+
95
+
96
+ class Resolver(FirstPass):
97
+ """Pass 2: the class model, and references into edges."""
98
+
99
+ def lookup_super(self, class_id: str, attr: str) -> Node:
100
+ """``super().<attr>`` in ``class_id``: the next definition after it in
101
+ its MRO, plus (as overrides) what follows it in the MRO of each
102
+ in-scope subclass, where a mixin may come first."""
103
+ hit = self.lookup_in_class(class_id, attr, skip_self=True)
104
+ base = (hit.symbol, hit.detail) if isinstance(hit, Resolved) else None
105
+ extra: list[tuple[str, str]] = []
106
+ for sub in self._descendants.get(class_id, ()):
107
+ mro = self._mro(sub)
108
+ if class_id not in mro:
109
+ continue
110
+ for cid in mro[mro.index(class_id) + 1 :]:
111
+ cscope = self.class_scopes[cid]
112
+ if attr in cscope.members:
113
+ pair = (cscope.members[attr], "")
114
+ elif attr in cscope.bindings:
115
+ pair = (cid, f"attribute:{attr}")
116
+ else:
117
+ continue
118
+ if pair != base and pair not in extra:
119
+ extra.append(pair)
120
+ break
121
+ if not extra or not isinstance(hit, Resolved):
122
+ return hit
123
+ return Resolved(hit.symbol, hit.detail, hit.uncertain_attr, overrides=tuple(extra))
124
+
125
+ def _super_may_reach(self, symbol: Symbol) -> bool:
126
+ """An unresolved ``super().<name>`` in class K may call ``symbol``
127
+ when its class follows K in some in-scope MRO."""
128
+ owner = symbol.container
129
+ for k in self._super_misses.get(symbol.name, ()):
130
+ for cid in (k, *self._descendants.get(k, ())):
131
+ mro = self._mro(cid)
132
+ if k in mro and owner in mro[mro.index(k) + 1 :]:
133
+ return True
134
+ return False
135
+
136
+ def _constructor_escapes(self, symbol: Symbol, unresolved_names: set[str]) -> bool:
137
+ """An ``__init__`` also runs whenever a class that inherits it is
138
+ constructed: that happens unseen when such a class escapes or its name
139
+ occurs as an unresolved reference."""
140
+ if symbol.kind != METHOD or symbol.name != "__init__":
141
+ return False
142
+ owner = symbol.container
143
+ if owner not in self.class_scopes:
144
+ return True
145
+ for cid in (owner, *self._descendants.get(owner, ())):
146
+ init = self.lookup_in_class(cid, "__init__")
147
+ if not (isinstance(init, Resolved) and init.symbol == symbol.id):
148
+ continue
149
+ if cid in self.out.escapes or self.index.symbols[cid].name in unresolved_names:
150
+ return True
151
+ return False
152
+
153
+ def _returned_class(self, node: ast.AST, scope: Scope) -> tuple[str, ...] | None:
154
+ """The classes ``node`` returns, when every ``return`` in it yields
155
+ one: ``def make(): return Provider()`` yields that class, and a
156
+ factory that picks between two yields both. One return that says
157
+ something else, or none at all, says nothing -- a guess here would
158
+ bind a class that never reaches the caller."""
159
+ found: set[str] = set()
160
+ for inner in ast.walk(node):
161
+ if isinstance(inner, NESTED_SCOPES) and inner is not node:
162
+ continue
163
+ if not isinstance(inner, ast.Return) or inner.value is None:
164
+ continue
165
+ cls = self._expression_class(inner.value, scope)
166
+ if cls is None:
167
+ return None
168
+ found.add(cls)
169
+ return tuple(sorted(found)) or None
170
+
171
+ def _expression_class(self, expr: ast.expr, scope: Scope) -> str | None:
172
+ """The class an expression is an instance of, when it says so: ``C()``
173
+ constructs one, ``C`` is the class itself."""
174
+ target = expr.func if isinstance(expr, ast.Call) else expr
175
+ parts = _flatten_chain(target)
176
+ if parts is None:
177
+ return None
178
+ node = self.resolve_chain(parts, scope)
179
+ if not isinstance(node, Resolved) or node.detail:
180
+ return None
181
+ symbol = self.index.symbols.get(node.symbol)
182
+ return node.symbol if symbol is not None and symbol.kind == CLASS else None
183
+
184
+ def _ensure_bases(self, class_id: str) -> None:
185
+ """Resolve a class's bases on demand (a dotted base such as
186
+ ``Zed.Inner`` may need another class's MRO first)."""
187
+ cscope = self.class_scopes[class_id]
188
+ if cscope.bases_state:
189
+ return
190
+ cscope.bases_state = 1
191
+ for parts in cscope.base_chains:
192
+ node = self._resolve_base_expr(parts, cscope)
193
+ self._record(cscope.id, node, chain=".".join(parts) if parts else "<expr>")
194
+ if (
195
+ isinstance(node, Resolved)
196
+ and not node.detail
197
+ and not node.uncertain_attr
198
+ and node.symbol in self.class_scopes
199
+ and node.symbol != cscope.id
200
+ ):
201
+ cscope.bases.append(node.symbol)
202
+ else:
203
+ cscope.complete = False # external, dynamic (``Generic[T]``) or unknown
204
+ if not (parts == ["object"] and node is None):
205
+ cscope.opaque = True
206
+ for name in cscope.base_names:
207
+ if self._calls_back(name, cscope):
208
+ cscope.external_base = True
209
+ cscope.bases_state = 2
210
+
211
+ def _calls_back(self, name: list[str] | None, cscope: ClassScope) -> bool:
212
+ """Whether a base (as written) is code outside the source roots that
213
+ may call the subclass's methods: not an in-scope class (its own
214
+ status is inherited through the MRO), not a builtin, not a purely
215
+ structural typing/abc base. An unknown expression counts."""
216
+ if name is None:
217
+ return True
218
+ node = self._resolve_base_expr(name, cscope)
219
+ if isinstance(node, Resolved) and not node.detail and node.symbol in self.class_scopes:
220
+ return False
221
+ head = name[0]
222
+ binding = cscope.module.imports.get(head)
223
+ if binding is None:
224
+ if node is None and head in BUILTIN_NAMES and len(name) == 1:
225
+ return False
226
+ canonical = ".".join(name)
227
+ else:
228
+ base = binding.module if binding.attr is None else f"{binding.module}.{binding.attr}"
229
+ canonical = ".".join([base, *name[1:]])
230
+ return canonical not in _STRUCTURAL_BASES
231
+
232
+ def _resolve_base_expr(self, parts: list[str] | None, cscope: ClassScope) -> Node:
233
+ """A base name is looked up in the enclosing class body (for nested
234
+ classes) and then in the module, as Python does when the class
235
+ statement executes."""
236
+ if parts is None:
237
+ return None # ``Generic[T]``, ``namedtuple(...)``: the collector visits it
238
+ enclosing = cscope.enclosing
239
+ if enclosing is not None and parts[0] in enclosing.members:
240
+ node: Node = Resolved(enclosing.members[parts[0]])
241
+ for attr in parts[1:]:
242
+ node = self._step(node, attr)
243
+ return node
244
+ if enclosing is not None and parts[0] in enclosing.bindings:
245
+ return Resolved(enclosing.id, detail=f"attribute:{parts[0]}")
246
+ return self.resolve_chain(parts, Scope(module=cscope.module))
247
+
248
+ def _mro(self, class_id: str) -> list[str]:
249
+ """Linearisation over in-scope classes: the class, then its bases
250
+ depth-first left to right keeping the last occurrence of a repeated
251
+ base (C3 for ordinary hierarchies; documented as an approximation).
252
+ Memoised only once every class's bases are resolved."""
253
+ cscope = self.class_scopes[class_id]
254
+ if cscope.mro is not None:
255
+ return cscope.mro
256
+ self._ensure_bases(class_id)
257
+ if cscope.in_mro:
258
+ return [class_id] # inheritance cycle: stop here
259
+ cscope.in_mro = True
260
+ try:
261
+ order: list[str] = []
262
+ for base in cscope.bases:
263
+ order.extend(self._mro(base))
264
+ finally:
265
+ cscope.in_mro = False
266
+ seen: set[str] = {class_id}
267
+ tail: list[str] = []
268
+ for cid in reversed(order):
269
+ if cid not in seen:
270
+ seen.add(cid)
271
+ tail.append(cid)
272
+ result = [class_id, *reversed(tail)]
273
+ if self._bases_final:
274
+ cscope.mro = result
275
+ return result
276
+
277
+ def lookup_in_class(
278
+ self, class_id: str, attr: str, *, skip_self: bool = False, dispatch: bool = False
279
+ ) -> Node:
280
+ """Resolve ``attr`` on a class through its in-scope MRO.
281
+
282
+ A hit found after a class whose bases are not all known is marked
283
+ uncertain: an override in the unknown part of the hierarchy could
284
+ win, so the edge is recorded together with a name-bounded unresolved
285
+ reference. Not found anywhere yields the unresolved reference alone.
286
+
287
+ With ``dispatch`` (a lookup on ``self``/``cls``) the receiver may be
288
+ an instance of any in-scope subclass, so every subclass that defines
289
+ ``attr`` itself is returned as an override to record as well.
290
+ """
291
+ uncertain = False
292
+ for cid in self._mro(class_id)[1 if skip_self else 0 :]:
293
+ cscope = self.class_scopes[cid]
294
+ hit: Resolved | None = None
295
+ if attr in cscope.members:
296
+ hit = Resolved(cscope.members[attr], uncertain_attr=attr if uncertain else "")
297
+ elif attr in cscope.bindings:
298
+ hit = Resolved(
299
+ cid, detail=f"attribute:{attr}", uncertain_attr=attr if uncertain else ""
300
+ )
301
+ if hit is not None:
302
+ if dispatch:
303
+ hit = Resolved(
304
+ hit.symbol,
305
+ hit.detail,
306
+ hit.uncertain_attr,
307
+ overrides=self._overrides_of(class_id, attr, (hit.symbol, hit.detail)),
308
+ )
309
+ return hit
310
+ if not cscope.complete:
311
+ uncertain = True
312
+ return Unresolved(UNRESOLVED_ATTRIBUTE, attr)
313
+
314
+ def _external_callers(self) -> None:
315
+ """A class whose MRO has a base outside the source roots (a
316
+ transport, a handler, a visitor) may have any method called by that
317
+ code, which nothing in the source roots shows: the class depends on
318
+ every method it defines, like its special methods. The generic
319
+ bases of ``Base[T]`` are not in the MRO; their status counts too."""
320
+ for class_id in sorted(self.class_scopes):
321
+ cscope = self.class_scopes[class_id]
322
+ related = set(self._mro(class_id))
323
+ for chain, name in zip(cscope.base_chains, cscope.base_names, strict=True):
324
+ if chain is None and name is not None: # ``Base[T]``
325
+ node = self._resolve_base_expr(name, cscope)
326
+ if isinstance(node, Resolved) and node.symbol in self.class_scopes:
327
+ related |= set(self._mro(node.symbol))
328
+ if not any(self.class_scopes[c].external_base for c in related):
329
+ continue
330
+ for member, member_id in sorted(cscope.members.items()):
331
+ symbol = self.index.symbols.get(member_id)
332
+ if symbol is None or symbol.kind != METHOD or _is_special_method(member):
333
+ continue
334
+ if member in _EXPLICIT_SPECIAL_METHODS:
335
+ continue
336
+ self.out.edges.add(Edge(class_id, member_id, REFERENCES, "external_base"))
337
+
338
+ def _build_descendants(self) -> None:
339
+ subclasses: dict[str, set[str]] = defaultdict(set)
340
+ for cscope in self.class_scopes.values():
341
+ for base in cscope.bases:
342
+ subclasses[base].add(cscope.id)
343
+ for class_id in self.class_scopes:
344
+ seen = {class_id}
345
+ order: list[str] = []
346
+ stack = [class_id]
347
+ while stack:
348
+ for sub in sorted(subclasses.get(stack.pop(), ())):
349
+ if sub not in seen:
350
+ seen.add(sub)
351
+ order.append(sub)
352
+ stack.append(sub)
353
+ self._descendants[class_id] = tuple(order)
354
+
355
+ def _overrides_of(
356
+ self, class_id: str, attr: str, base_hit: tuple[str, str]
357
+ ) -> tuple[tuple[str, str], ...]:
358
+ """What ``attr`` resolves to on each in-scope descendant of
359
+ ``class_id`` when that differs from the base hit: a method defined by
360
+ the descendant, one it inherits from a mixin outside the base's
361
+ hierarchy, or a class-attribute rebinding."""
362
+ assert self._bases_final, "overrides need every class's bases resolved"
363
+ found: list[tuple[str, str]] = []
364
+ for sub in self._descendants.get(class_id, ()):
365
+ hit = self.lookup_in_class(sub, attr)
366
+ if isinstance(hit, Resolved):
367
+ pair = (hit.symbol, hit.detail)
368
+ if pair != base_hit and pair not in found:
369
+ found.append(pair)
370
+ return tuple(found)
371
+
372
+ def _module_in_scope(self, name: str) -> bool:
373
+ return name in self._module_prefixes
374
+
375
+ def _resolve_module(self, scope: ModuleScope) -> None:
376
+ assert scope.tree is not None # parsed before a module is resolved
377
+ module_scope = Scope(module=scope)
378
+ # Module-level imports -> init-time edges.
379
+ for node in scope.import_nodes:
380
+ self._import_edges(scope.name, scope, node)
381
+ top_level = [
382
+ s
383
+ for s in scope.tree.body
384
+ if not isinstance(s, DEF_NODES + (ast.Import, ast.ImportFrom))
385
+ ]
386
+ collector = _ReferenceCollector(self, scope.name, module_scope, skip_defs=True)
387
+ variable_ids = {
388
+ id(stmt): symbol
389
+ for name, stmt in scope.variable_stmts.items()
390
+ for symbol in [scope.variables[name]]
391
+ }
392
+ for stmt in top_level:
393
+ owner = variable_ids.get(id(stmt))
394
+ if owner is not None:
395
+ # The right-hand side's references belong to the variable symbol.
396
+ _ReferenceCollector(self, owner, module_scope, skip_defs=True).visit(stmt)
397
+ else:
398
+ collector.visit(stmt)
399
+ self._resolve_definitions(scope, scope.tree.body, scope.members, None)
400
+
401
+ def _resolve_definitions(
402
+ self,
403
+ scope: ModuleScope,
404
+ body: list[ast.stmt],
405
+ members: dict[str, str],
406
+ class_scope: ClassScope | None,
407
+ ) -> None:
408
+ for stmt in iter_scope_statements(body):
409
+ if isinstance(stmt, ast.ClassDef):
410
+ symbol_id = members.get(stmt.name)
411
+ if symbol_id is None or symbol_id not in self.class_scopes:
412
+ continue
413
+ cscope = self.class_scopes[symbol_id]
414
+ class_level = Scope(
415
+ module=scope,
416
+ locals=set(cscope.bindings),
417
+ class_members=dict(cscope.members),
418
+ class_level=True,
419
+ )
420
+ # The class statement (bases, decorators, body) runs when the
421
+ # module is imported, nested classes included: its references
422
+ # are the class's and, as import-time code, the module's.
423
+ collectors = [
424
+ _ReferenceCollector(self, source, class_level, skip_defs=True)
425
+ for source in (symbol_id, scope.name)
426
+ ]
427
+ for creator in (symbol_id, scope.name):
428
+ self.class_creation(creator, cscope.bases, stmt.keywords, Scope(module=scope))
429
+ # Name-chain bases were resolved (and recorded) by _ensure_bases;
430
+ # only dynamic base expressions still need their references collected.
431
+ dynamic_bases = [b for b in stmt.bases if _flatten_chain(b) is None]
432
+ for collector in collectors:
433
+ for expr in dynamic_bases + list(stmt.keywords) + list(stmt.decorator_list):
434
+ collector.visit(expr)
435
+ for inner in stmt.body:
436
+ if not isinstance(inner, DEF_NODES):
437
+ collector.visit(inner)
438
+ # An import in the class body runs when the class is created,
439
+ # at import: the class and the module depend on the imported
440
+ # module's import-time code, as for a module-level import.
441
+ for inner in iter_scope_statements(stmt.body):
442
+ if isinstance(inner, (ast.Import, ast.ImportFrom)):
443
+ for source in (symbol_id, scope.name):
444
+ self._import_edges(source, scope, inner, local=True)
445
+ attributes = _class_attributes(stmt)
446
+ previous = self.out.class_attributes.get(symbol_id)
447
+ if previous is not None: # a conditional second definition
448
+ for name in previous.keys() | attributes.keys():
449
+ attributes[name] = _digest(
450
+ previous.get(name, "") + "|" + attributes.get(name, "")
451
+ )
452
+ self.out.class_attributes[symbol_id] = attributes
453
+ creators = list(stmt.decorator_list) + [k.value for k in stmt.keywords]
454
+ if creators and self._decorators_may_read_docs(
455
+ creators, scope, Scope(module=scope)
456
+ ):
457
+ self.out.doc_decorated.add(symbol_id)
458
+ self.out.class_bases[symbol_id] = tuple(sorted(set(cscope.bases)))
459
+ # Open: decorators or keywords, or a base outside the index
460
+ # that may consume the body (``Enum``, a framework model); not
461
+ # ``object``, ``Exception`` or a typing/abc base.
462
+ if not cscope.plain or cscope.external_base:
463
+ self.out.open_classes.add(symbol_id)
464
+ self._resolve_definitions(scope, stmt.body, cscope.members, cscope)
465
+ elif isinstance(stmt, FUNC_NODES):
466
+ symbol_id = members.get(stmt.name)
467
+ if symbol_id is None:
468
+ continue
469
+ self._resolve_function(scope, stmt, symbol_id, class_scope)
470
+
471
+ def _record_decorations(
472
+ self, symbol_id: str, decorators: list[ast.expr], module: ModuleScope, where: Scope
473
+ ) -> None:
474
+ """For each decorator not known inert: the in-scope function it
475
+ resolves to and the in-scope object it is an attribute of
476
+ (``show`` in ``@show.register(int)``, ``app`` in ``@app.command``).
477
+ A decorator may keep the function it decorates there."""
478
+ for dec in decorators:
479
+ if _is_inert_decorator(dec, module):
480
+ continue
481
+ target = dec.func if isinstance(dec, ast.Call) else dec
482
+ parts = _flatten_chain(target)
483
+ if parts is None:
484
+ continue
485
+ decorator = self.resolve_chain(parts, where)
486
+ decorator_id = decorator.symbol if isinstance(decorator, Resolved) else ""
487
+ receiver_id = ""
488
+ if len(parts) > 1:
489
+ receiver = self.resolve_chain(parts[:-1], where)
490
+ if isinstance(receiver, Resolved) and receiver.symbol != symbol_id:
491
+ receiver_id = receiver.symbol
492
+ if decorator_id or receiver_id:
493
+ self.out.decorations.add((symbol_id, decorator_id, receiver_id))
494
+
495
+ def _decorators_may_read_docs(
496
+ self, decorators: list[ast.expr], module: ModuleScope, where: Scope
497
+ ) -> bool:
498
+ """Whether any decorator (or metaclass) may read the docstring of what
499
+ it decorates: one that resolves to nothing visible (external,
500
+ unresolved, a value), or in-scope code that reads ``__doc__`` (the
501
+ decorator itself, a factory whose wrapper does, a class's
502
+ ``__init__``/``__new__``/``__call__``). Known inert ones never do."""
503
+ for dec in decorators:
504
+ if _is_inert_decorator(dec, module):
505
+ continue
506
+ target = dec.func if isinstance(dec, ast.Call) else dec
507
+ parts = _flatten_chain(target)
508
+ if parts is None:
509
+ return True
510
+ node = self.resolve_chain(parts, where)
511
+ if not isinstance(node, Resolved) or node.detail:
512
+ return True
513
+ symbol = self.index.symbols.get(node.symbol)
514
+ if symbol is None or symbol.kind not in (FUNCTION, METHOD, CLASS):
515
+ return True
516
+ if symbol.kind == CLASS:
517
+ hooks = [
518
+ self.index.symbols.get(f"{symbol.id}.{m}")
519
+ for m in ("__init__", "__new__", "__call__")
520
+ ]
521
+ if any(h is not None and h.reads_docstrings for h in hooks):
522
+ return True
523
+ elif symbol.reads_docstrings:
524
+ return True
525
+ return False
526
+
527
+ def _resolve_function(
528
+ self,
529
+ scope: ModuleScope,
530
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
531
+ symbol_id: str,
532
+ class_scope: ClassScope | None,
533
+ ) -> None:
534
+ fscope = Scope(module=scope, locals=_LocalBindings().collect(node), literal_node=node)
535
+ bound_method = class_scope is not None and not _is_staticmethod(node)
536
+ if bound_method:
537
+ params = node.args.posonlyargs + node.args.args
538
+ if params:
539
+ fscope.self_name = params[0].arg
540
+ fscope.self_class = class_scope.id
541
+ fscope.method = symbol_id
542
+ fscope.self_is_class = _has_decorator(node, "classmethod")
543
+ fscope.rebound = frozenset(_rebound_names(node))
544
+ positional = [a.arg for a in node.args.posonlyargs + node.args.args]
545
+ fscope.params = {name: i for i, name in enumerate(positional)}
546
+ fscope.params.update({a.arg: None for a in node.args.kwonlyargs})
547
+ defaults: dict[str, tuple[str, ...] | None] = {}
548
+ n_defaults = len(node.args.defaults)
549
+ for name, default in zip(
550
+ positional[len(positional) - n_defaults :], node.args.defaults, strict=True
551
+ ):
552
+ defaults[name] = fscope.string_candidates(default)
553
+ for a, default in zip(node.args.kwonlyargs, node.args.kw_defaults, strict=True):
554
+ if default is not None:
555
+ defaults[a.arg] = fscope.string_candidates(default)
556
+ self.out.func_params[symbol_id] = _FuncParams(
557
+ positional=positional,
558
+ bound=bound_method,
559
+ defaults=defaults,
560
+ has_varargs=node.args.vararg is not None or node.args.kwarg is not None,
561
+ )
562
+ returned = self._returned_class(node, fscope)
563
+ if returned is not None:
564
+ self.out.returns[symbol_id] = returned
565
+ # Function-local imports are visible to the whole body.
566
+ for inner in ast.walk(node):
567
+ if isinstance(inner, (ast.Import, ast.ImportFrom)):
568
+ stars: list[str] = []
569
+ self._register_imports(scope, inner, fscope.local_imports, stars)
570
+ self._import_edges(symbol_id, scope, inner, local=True)
571
+ # Decorators, defaults and annotations evaluate where the ``def``
572
+ # statement runs, so the function's own parameters must not shadow
573
+ # them (``def f(info=info)`` refers to the module-level ``info``).
574
+ outer_scope = Scope(
575
+ module=scope,
576
+ locals=set(class_scope.bindings) if class_scope is not None else set(),
577
+ class_members=dict(class_scope.members) if class_scope is not None else {},
578
+ class_level=class_scope is not None,
579
+ )
580
+ outer = _ReferenceCollector(self, symbol_id, outer_scope, skip_defs=True)
581
+ # Decorators, defaults and eagerly evaluated annotations run when the
582
+ # ``def`` does, i.e. when the module is imported (a decorator such as
583
+ # ``@app.get("/")`` calls into a framework then): the module depends on
584
+ # what they reference too.
585
+ at_import = _ReferenceCollector(self, scope.name, outer_scope, skip_defs=True)
586
+ for expr in (
587
+ node.decorator_list
588
+ + [d for d in node.args.defaults]
589
+ + [d for d in node.args.kw_defaults if d is not None]
590
+ ):
591
+ at_import.visit(expr)
592
+ if not _future_annotations(scope):
593
+ for arg in ast.walk(node.args):
594
+ if isinstance(arg, ast.arg) and arg.annotation is not None:
595
+ at_import.visit(arg.annotation)
596
+ if node.returns is not None:
597
+ at_import.visit(node.returns)
598
+ for dec in node.decorator_list:
599
+ outer.visit(dec)
600
+ if self._decorators_may_read_docs(node.decorator_list, scope, outer_scope):
601
+ self.out.doc_decorated.add(symbol_id)
602
+ self._record_decorations(symbol_id, node.decorator_list, scope, outer_scope)
603
+ all_args = node.args.posonlyargs + node.args.args + node.args.kwonlyargs
604
+ for arg in all_args + [a for a in (node.args.vararg, node.args.kwarg) if a]:
605
+ if arg.annotation is not None:
606
+ outer.visit(arg.annotation)
607
+ if node.returns is not None:
608
+ outer.visit(node.returns)
609
+ for name, default in zip(
610
+ positional[len(positional) - n_defaults :] + [a.arg for a in node.args.kwonlyargs],
611
+ list(node.args.defaults) + list(node.args.kw_defaults),
612
+ strict=True,
613
+ ):
614
+ if default is None:
615
+ continue
616
+ outer.visit(default)
617
+ target = (
618
+ self.resolve_chain(parts, outer_scope)
619
+ if (parts := _flatten_chain(default))
620
+ else None
621
+ )
622
+ if isinstance(target, Resolved) and not target.detail:
623
+ aliased = self.index.symbols.get(target.symbol)
624
+ if aliased is not None and aliased.kind == VARIABLE:
625
+ fscope.param_aliases[name] = aliased.id
626
+ collector = _ReferenceCollector(self, symbol_id, fscope)
627
+ for stmt in node.body:
628
+ collector.visit(stmt)
629
+
630
+ def _import_edges(
631
+ self, source: str, scope: ModuleScope, node: ast.stmt, local: bool = False
632
+ ) -> None:
633
+ if isinstance(node, ast.Import):
634
+ for alias in node.names:
635
+ self._module_import_edge(source, alias.name)
636
+ elif isinstance(node, ast.ImportFrom):
637
+ if relative_import_escapes(scope.name, scope.is_package, node.level):
638
+ written = "." * node.level + (node.module or "")
639
+ self.out.unresolved.add(
640
+ UnresolvedReference(
641
+ source,
642
+ UNRESOLVED_DYNAMIC,
643
+ written,
644
+ f"import from {written}: above the top-level package of {scope.name} "
645
+ "(check the source roots)",
646
+ )
647
+ )
648
+ return
649
+ base = _absolute_module(scope, node.module, node.level)
650
+ self._module_import_edge(source, base)
651
+ if not self._module_in_scope(base):
652
+ return
653
+ for alias in node.names:
654
+ if alias.name == "*":
655
+ continue
656
+ target = self._step(ModuleNode(base), alias.name)
657
+ if isinstance(target, ModuleNode):
658
+ self._module_import_edge(source, target.module)
659
+ continue
660
+ self._record(
661
+ source,
662
+ target,
663
+ kind=IMPORTS_NAME if not local else REFERENCES,
664
+ chain=f"from {base} import {alias.name}",
665
+ )
666
+
667
+ def _module_import_edge(self, source: str, module: str) -> None:
668
+ if not self._module_in_scope(module):
669
+ if self._module_in_scope(module.split(".")[0]):
670
+ self.out.unresolved.add(
671
+ UnresolvedReference(
672
+ source, UNRESOLVED_ATTRIBUTE, module.rsplit(".", 1)[-1], f"import {module}"
673
+ )
674
+ )
675
+ else:
676
+ hidden = self._misrooted(module)
677
+ if hidden is not None:
678
+ # ``from calc.ops import add`` while the roots name the
679
+ # package ``src.calc``: not a third-party import, an
680
+ # in-scope one discovery cannot follow. Unbounded.
681
+ self.out.unresolved.add(
682
+ UnresolvedReference(
683
+ source,
684
+ UNRESOLVED_DYNAMIC,
685
+ module,
686
+ f"import {module}: the analysed package is named {hidden}; the "
687
+ "source roots name it differently from the import (add a source "
688
+ "root for the directory holding it, e.g. --source-root src)",
689
+ )
690
+ )
691
+ else:
692
+ self.out.external.add(ExternalReference(source, module))
693
+ return
694
+ parts = module.split(".")
695
+ for i in range(1, len(parts) + 1):
696
+ prefix = ".".join(parts[:i])
697
+ if prefix in self.scopes and prefix != source:
698
+ self.out.edges.add(Edge(source, prefix, IMPORTS))
699
+
700
+ def resolve_dotted(self, chain: list[str]) -> Node:
701
+ """An absolute dotted name as a string names it (``"pkg.mod.NAME"``):
702
+ the longest in-scope module prefix, then attribute steps. None when
703
+ no prefix is in scope (a third-party target)."""
704
+ for i in range(len(chain), 0, -1):
705
+ module = ".".join(chain[:i])
706
+ if self._module_in_scope(module):
707
+ node: Node = ModuleNode(module)
708
+ for attr in chain[i:]:
709
+ if node is None:
710
+ return None
711
+ node = self._step(node, attr)
712
+ return node
713
+ return None
714
+
715
+ def _misrooted(self, module: str) -> str | None:
716
+ """The analysed package an absolute import most likely means when the
717
+ source roots name it with a prefix: ``calc`` for ``src.calc`` when
718
+ ``src`` is a plain directory, not a package (a src layout analysed
719
+ with the root ``.``)."""
720
+ if self._misrooted_names is None:
721
+ names: dict[str, str] = {}
722
+ for m in sorted(self.scopes):
723
+ parts = m.split(".")
724
+ for i in range(1, len(parts)):
725
+ if ".".join(parts[:i]) in self.scopes:
726
+ break
727
+ candidate = ".".join(parts[: i + 1])
728
+ if candidate in self.scopes:
729
+ names.setdefault(parts[i], candidate)
730
+ break
731
+ self._misrooted_names = names
732
+ return self._misrooted_names.get(module.split(".")[0])
733
+
734
+ def _lookup_base(self, name: str, scope: Scope) -> Node:
735
+ if scope.self_name is not None and name == scope.self_name and scope.self_class:
736
+ return Resolved(scope.self_class, receiver=True)
737
+ if name in scope.local_imports:
738
+ return self._import_binding_node(scope.local_imports[name])
739
+ if name in scope.class_members:
740
+ # The member when it is defined above the use; the module's name
741
+ # when it is defined below (order is not tracked): both.
742
+ module_binding = self._lookup_in_module(scope.module, name, set())
743
+ return _with_alternatives(
744
+ [Resolved(scope.class_members[name])]
745
+ + ([module_binding] if module_binding is not None else [])
746
+ )
747
+ if name in scope.locals:
748
+ if name in scope.param_aliases:
749
+ return Resolved(scope.param_aliases[name])
750
+ return Local()
751
+ found = self._lookup_in_module(scope.module, name, set())
752
+ if found is not None:
753
+ return found
754
+ if name in BUILTIN_NAMES or (name.startswith("__") and name.endswith("__")):
755
+ return None
756
+ return Unresolved(UNRESOLVED_NAME, name)
757
+
758
+ def _lookup_in_module(self, target: ModuleScope, name: str, seen: set[str]) -> Node:
759
+ """Resolve ``name`` as seen from inside ``target``'s global namespace.
760
+
761
+ Order: own definitions, import aliases, module-level variables,
762
+ submodules, then star imports. Every analysed star-imported module is
763
+ consulted before an external star import is blamed, so an in-scope
764
+ symbol is never misattributed to a third-party package.
765
+ """
766
+ # Star imports bind too, and the last binding wins at runtime, which
767
+ # statement order alone does not settle (``from a import f`` then
768
+ # ``from b import *``; Django-style settings star-importing a base
769
+ # and then a local override): every in-scope candidate counts.
770
+ stars, external = self._star_hits(target, name, seen) if target.star_imports else ([], None)
771
+ if name in target.members or name in target.imports:
772
+ # Every binding of the name: a definition beside an import of the
773
+ # same name (a fallback), and imports one of which overwrites
774
+ # another in a try/except or if/else.
775
+ bindings: list[Node] = []
776
+ if name in target.members:
777
+ bindings.append(Resolved(target.members[name]))
778
+ if name in target.imports:
779
+ bindings.append(self._import_binding_node(target.imports[name]))
780
+ for binding in target.alt_imports.get(name, ()):
781
+ bindings.append(self._import_binding_node(binding))
782
+ return _with_alternatives(bindings + stars)
783
+ if name in target.variables:
784
+ return _with_alternatives([Resolved(target.variables[name]), *stars])
785
+ if name in target.bindings:
786
+ return _with_alternatives([Resolved(target.name, detail=f"attribute:{name}"), *stars])
787
+ if self._module_in_scope(f"{target.name}.{name}"):
788
+ return ModuleNode(f"{target.name}.{name}")
789
+ if stars:
790
+ return _with_alternatives(stars)
791
+ return External(external) if external is not None else None
792
+
793
+ def _star_hits(
794
+ self, target: ModuleScope, name: str, seen: set[str]
795
+ ) -> tuple[list[Node], str | None]:
796
+ """What each of ``target``'s star imports binds ``name`` to (in-scope
797
+ hits, in order), and the first out-of-scope star import that might."""
798
+ hits: list[Node] = []
799
+ external: str | None = None
800
+ for star in target.star_imports:
801
+ if star in seen:
802
+ continue
803
+ seen.add(star)
804
+ star_scope = self.scopes.get(star)
805
+ if star_scope is None:
806
+ if external is None and not self._module_in_scope(star):
807
+ external = star
808
+ continue
809
+ found = self._lookup_in_module(star_scope, name, seen)
810
+ if isinstance(found, External):
811
+ external = external or found.module
812
+ elif found is not None:
813
+ hits.append(found)
814
+ return hits, external
815
+
816
+ def _import_binding_node(self, binding: ImportBinding) -> Node:
817
+ if not binding.module:
818
+ # Bound by a relative import above the top-level package (see
819
+ # _import_edges, which records the import as unbounded).
820
+ return Unresolved(UNRESOLVED_ATTRIBUTE, binding.attr or "")
821
+ if not self._module_in_scope(binding.module):
822
+ if self._module_in_scope(binding.module.split(".")[0]):
823
+ # ``pkg.missing`` inside an analysed package: not an external
824
+ # dependency, but nothing we can see either (deleted module,
825
+ # compiled extension, generated code).
826
+ name = binding.attr or binding.module.rsplit(".", 1)[-1]
827
+ return Unresolved(UNRESOLVED_ATTRIBUTE, name)
828
+ return External(binding.module)
829
+ node: Node = ModuleNode(binding.module)
830
+ if binding.attr is not None:
831
+ # ``pkg/__init__.py: from pkg import ext`` with no ``ext`` module
832
+ # (a compiled extension) resolves through itself: stop there.
833
+ key = (binding.module, binding.attr)
834
+ if key in self._resolving_bindings:
835
+ return Unresolved(UNRESOLVED_ATTRIBUTE, binding.attr)
836
+ self._resolving_bindings.add(key)
837
+ try:
838
+ node = self._step(node, binding.attr)
839
+ finally:
840
+ self._resolving_bindings.discard(key)
841
+ return node
842
+
843
+ def _step(self, node: Node, attr: str) -> Node:
844
+ """Resolve one attribute access on a resolved node."""
845
+ if isinstance(node, ModuleNode):
846
+ sub = f"{node.module}.{attr}"
847
+ target = self.scopes.get(node.module)
848
+ if self._module_in_scope(sub):
849
+ # A package binding of the same name wins at runtime once the
850
+ # package has run (it binds after importing the submodule);
851
+ # either may be meant, so the attribute denotes both.
852
+ shadow: Resolved | None = None
853
+ if target is not None:
854
+ symbol_id = target.members.get(attr) or target.variables.get(attr)
855
+ if symbol_id is not None:
856
+ shadow = Resolved(symbol_id)
857
+ elif attr in target.imports:
858
+ # ``from .main import main``: the re-exported name.
859
+ bound = self._import_binding_node(target.imports[attr])
860
+ if isinstance(bound, Resolved) and bound.symbol != sub:
861
+ shadow = Resolved(bound.symbol, bound.detail)
862
+ elif attr in target.bindings:
863
+ shadow = Resolved(target.name, detail=f"attribute:{attr}")
864
+ if shadow is not None and sub in self.scopes:
865
+ return Resolved(shadow.symbol, shadow.detail, also=(sub,))
866
+ return ModuleNode(sub)
867
+ if target is None:
868
+ return Unresolved(UNRESOLVED_ATTRIBUTE, attr)
869
+ found = self._lookup_in_module(target, attr, set())
870
+ if found is None or isinstance(found, Unresolved):
871
+ lazy = target.members.get("__getattr__")
872
+ if lazy is not None:
873
+ # PEP 562: a module ``__getattr__`` serves names the module
874
+ # does not bind (lazy loading): the reference depends on it,
875
+ # and stays bounded by the name for what it hands back.
876
+ return Resolved(lazy, uncertain_attr=attr)
877
+ return found if found is not None else Unresolved(UNRESOLVED_ATTRIBUTE, attr)
878
+ if isinstance(node, External):
879
+ return node
880
+ if isinstance(node, Resolved):
881
+ if node.detail:
882
+ return node # attribute of an opaque module/class attribute: stop here
883
+ symbol = self.index.symbols.get(node.symbol)
884
+ if symbol is None:
885
+ return node
886
+ if symbol.kind == CLASS:
887
+ return self.lookup_in_class(symbol.id, attr, dispatch=node.receiver)
888
+ if symbol.kind == MODULE:
889
+ return self._step(ModuleNode(symbol.id), attr)
890
+ return node # attribute on a function object
891
+ if isinstance(node, Unresolved):
892
+ return Unresolved(UNRESOLVED_ATTRIBUTE, attr)
893
+ return None
894
+
895
+ def resolve_chain(self, parts: list[str], scope: Scope) -> Node:
896
+ node = self._lookup_base(parts[0], scope)
897
+ if isinstance(node, Local):
898
+ # ``obj.method`` on a local: the method name bounds what it may be.
899
+ return Unresolved(UNRESOLVED_ATTRIBUTE, parts[1]) if len(parts) > 1 else None
900
+ for attr in parts[1:]:
901
+ if node is None:
902
+ return None
903
+ if isinstance(node, Resolved) and node.detail:
904
+ return node
905
+ node = self._step(node, attr)
906
+ return node
907
+
908
+ def resolve_chain_names(self, parts: list[str], scope: Scope) -> tuple[Node, tuple[str, ...]]:
909
+ """Like resolve_chain, plus the attribute names after the point where
910
+ resolution stopped (an unknown or opaque value: a local, a failed
911
+ step, a variable, a function, a class-level binding). Each is looked
912
+ up on a value of unknown type, so each is a name-bounded reference."""
913
+ node = self._lookup_base(parts[0], scope)
914
+ if isinstance(node, Local):
915
+ if len(parts) < 2:
916
+ return None, ()
917
+ return Unresolved(UNRESOLVED_ATTRIBUTE, parts[1]), tuple(parts[2:])
918
+ for i, attr in enumerate(parts[1:], 1):
919
+ if node is None or isinstance(node, External):
920
+ return node, ()
921
+ if isinstance(node, Resolved):
922
+ symbol = self.index.symbols.get(node.symbol)
923
+ opaque = node.detail or (symbol is not None and symbol.kind not in (CLASS, MODULE))
924
+ if opaque:
925
+ return node, tuple(parts[i:])
926
+ node = self._step(node, attr)
927
+ if isinstance(node, Unresolved):
928
+ return node, tuple(parts[i + 1 :])
929
+ return node, ()
930
+
931
+ def class_creation(
932
+ self, source: str, bases: list[str], keywords: list[ast.keyword], scope: Scope
933
+ ) -> None:
934
+ """Creating a class runs code of its bases and metaclass: the
935
+ ``__init_subclass__`` that the new class's MRO finds after itself
936
+ (the union of what each in-scope base finds covers it), and an
937
+ in-scope metaclass's ``__new__`` and ``__init__``."""
938
+ hooks: list[tuple[str, str]] = []
939
+ for base in bases:
940
+ hooks.append((base, "__init_subclass__"))
941
+ for kw in keywords:
942
+ parts = _flatten_chain(kw.value) if kw.arg == "metaclass" else None
943
+ if parts is None:
944
+ continue
945
+ meta = self.resolve_chain(parts, scope)
946
+ if isinstance(meta, Resolved) and not meta.detail and meta.symbol in self.class_scopes:
947
+ hooks += [(meta.symbol, "__new__"), (meta.symbol, "__init__")]
948
+ for class_id, name in hooks:
949
+ hit = self.lookup_in_class(class_id, name)
950
+ if isinstance(hit, Resolved) and not hit.detail and hit.symbol != source:
951
+ self.out.edges.add(Edge(source, hit.symbol, REFERENCES, name))
952
+
953
+ def escape(self, target: Resolved, *, classes: bool = True) -> None:
954
+ """Record that ``target`` is used as a value (see _mark_escape)."""
955
+ symbol = self.index.symbols.get(target.symbol)
956
+ if symbol is None:
957
+ return
958
+ if symbol.kind in (FUNCTION, METHOD) or (classes and symbol.kind == CLASS):
959
+ self.out.escapes.add(symbol.id)
960
+ for override_id, detail in target.overrides:
961
+ if not detail:
962
+ self.out.escapes.add(override_id)
963
+
964
+ def escape_class_family(self, class_id: str) -> None:
965
+ """The class of an instance of ``class_id``: any in-scope subclass."""
966
+ self.out.escapes.add(class_id)
967
+ self.out.escapes.update(self._descendants.get(class_id, ()))
968
+
969
+ def _record(self, source: str, node: Node, kind: str = REFERENCES, chain: str = "") -> None:
970
+ if node is None:
971
+ return
972
+ if isinstance(node, Resolved):
973
+ if node.symbol != source:
974
+ self.out.edges.add(Edge(source, node.symbol, kind, node.detail))
975
+ if node.uncertain_attr:
976
+ self.out.unresolved.add(
977
+ UnresolvedReference(source, UNRESOLVED_ATTRIBUTE, node.uncertain_attr, chain)
978
+ )
979
+ for symbol_id, detail in node.overrides:
980
+ if symbol_id != source:
981
+ label = f"override:{detail}" if detail else "override"
982
+ self.out.edges.add(Edge(source, symbol_id, kind, label))
983
+ for module in node.also:
984
+ if module != source:
985
+ self.out.edges.add(Edge(source, module, kind, "module"))
986
+ for other in node.alternatives:
987
+ if other != source:
988
+ self.out.edges.add(Edge(source, other, kind, "alternative binding"))
989
+ if kind == REFERENCES and not node.detail and node.symbol in self.class_scopes:
990
+ # Using a class (``Foo(...)``, subclassing) runs its constructor.
991
+ for hook in ("__init__", "__new__"):
992
+ found = self.lookup_in_class(node.symbol, hook)
993
+ if isinstance(found, Resolved) and found.symbol != source:
994
+ self.out.edges.add(Edge(source, found.symbol, REFERENCES, "constructor"))
995
+ elif isinstance(node, ModuleNode):
996
+ if node.module in self.scopes and node.module != source:
997
+ self.out.edges.add(Edge(source, node.module, kind, "module"))
998
+ elif isinstance(node, External):
999
+ self.out.external.add(ExternalReference(source, node.module))
1000
+ elif isinstance(node, Unresolved):
1001
+ self.out.unresolved.add(UnresolvedReference(source, node.kind, node.name, chain))