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,895 @@
1
+ """The second pass's AST walker: what a function, class or module body
2
+ references, calls, writes and looks up dynamically."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ast
7
+ from typing import TYPE_CHECKING
8
+
9
+ from diffcone.indexer.definitions import _flatten_chain
10
+ from diffcone.indexer.facts import _AttrRef, _AttrWrite, _CallSite, _ParamDynamic
11
+ from diffcone.indexer.literals import _collect_store_names, _LocalBindings
12
+ from diffcone.indexer.scopes import (
13
+ External,
14
+ ModuleNode,
15
+ Node,
16
+ Resolved,
17
+ Scope,
18
+ Unresolved,
19
+ _resolve_relative_name,
20
+ _string_prefix,
21
+ )
22
+ from diffcone.indexer.syntax import (
23
+ DYNAMIC_CALLS,
24
+ REFLECTIVE_ATTRIBUTES,
25
+ REFLECTIVE_BUILTINS,
26
+ REFLECTIVE_CALLS,
27
+ )
28
+ from diffcone.model import (
29
+ CLASS,
30
+ FUNCTION,
31
+ METHOD,
32
+ MODULE,
33
+ REFERENCES,
34
+ UNRESOLVED_ATTRIBUTE,
35
+ UNRESOLVED_DYNAMIC,
36
+ VARIABLE,
37
+ Edge,
38
+ ExternalReference,
39
+ UnresolvedReference,
40
+ )
41
+ from diffcone.snapshot import module_name_for, split_root
42
+
43
+ if TYPE_CHECKING:
44
+ from diffcone.indexer.resolver import Resolver
45
+
46
+
47
+ # Calls that read a file's text or bytes: code built from them is not the
48
+ # program's own.
49
+ FILE_READS = frozenset(
50
+ {"open", "read", "read_text", "read_bytes", "get_data", "read_binary", "files", "readlines"}
51
+ )
52
+
53
+
54
+ def _reads_files(node: ast.AST) -> bool:
55
+ for inner in ast.walk(node):
56
+ if isinstance(inner, ast.Call):
57
+ func = inner.func
58
+ name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
59
+ if name in FILE_READS:
60
+ return True
61
+ return False
62
+
63
+
64
+ class _ReferenceCollector(ast.NodeVisitor):
65
+ """Walk a symbol's code and record edges / unresolved references.
66
+
67
+ Nested functions, lambdas and comprehensions push their own scope so a
68
+ name bound there does not shadow the enclosing symbol's references. With
69
+ ``skip_defs`` (module and class bodies) nested definitions are not
70
+ entered at all: they are symbols resolved on their own.
71
+ """
72
+
73
+ def __init__(
74
+ self, indexer: Resolver, source: str, scope: Scope, *, skip_defs: bool = False
75
+ ) -> None:
76
+ self.indexer = indexer
77
+ self.source = source
78
+ self.scope = scope
79
+ self.skip_defs = skip_defs
80
+ # Positions that name a class without constructing it (the second
81
+ # argument of ``isinstance``/``issubclass``, the first of
82
+ # ``typing.cast``). Annotations are not among them: frameworks build
83
+ # instances from them (injector, FastAPI's ``Depends()``, pydantic).
84
+ self._type_nodes: set[int] = set()
85
+ # ``self.<attr> = value`` targets in ``__init__`` -> what they bind.
86
+ self._bindings: dict[int, list | None] = {}
87
+
88
+ def _push(
89
+ self,
90
+ bound: set[str],
91
+ literals: dict[str, tuple[str, ...] | None] | None = None,
92
+ node: ast.AST | None = None,
93
+ ) -> Scope:
94
+ outer = self.scope
95
+ self.scope = Scope(
96
+ module=outer.module,
97
+ local_imports=outer.local_imports,
98
+ # A class body's names are not visible in scopes nested in it.
99
+ locals=(set() if outer.class_level else outer.locals) | bound,
100
+ self_name=None if outer.self_name in bound else outer.self_name,
101
+ self_class=None if outer.self_name in bound else outer.self_class,
102
+ self_is_class=outer.self_is_class,
103
+ literal_node=node,
104
+ literal_parent=outer,
105
+ literal_bound=frozenset(bound),
106
+ literal_extra=dict(literals or {}),
107
+ params={}, # a nested scope's names are not the enclosing function's parameters
108
+ param_aliases={k: v for k, v in outer.param_aliases.items() if k not in bound},
109
+ )
110
+ return outer
111
+
112
+ def _is_shadowed(self, name: str) -> bool:
113
+ """True when ``name`` is bound by the program rather than a builtin."""
114
+ scope = self.scope
115
+ if name in scope.locals or name in scope.local_imports:
116
+ return True
117
+ module = scope.module
118
+ return name in module.members or name in module.imports or name in module.bindings
119
+
120
+ def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
121
+ if self.skip_defs:
122
+ return
123
+ for dec in node.decorator_list:
124
+ self.visit(dec)
125
+ for default in list(node.args.defaults) + [d for d in node.args.kw_defaults if d]:
126
+ self.visit(default)
127
+ outer = self._push(_LocalBindings().collect(node), node=node)
128
+ try:
129
+ for arg in ast.walk(node.args):
130
+ if isinstance(arg, ast.arg) and arg.annotation is not None:
131
+ self.visit(arg.annotation)
132
+ if node.returns is not None:
133
+ self.visit(node.returns)
134
+ for stmt in node.body:
135
+ self.visit(stmt)
136
+ finally:
137
+ self.scope = outer
138
+
139
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
140
+ return self.visit_FunctionDef(node)
141
+
142
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
143
+ if self.skip_defs:
144
+ return
145
+ for expr in list(node.bases) + list(node.keywords) + list(node.decorator_list):
146
+ self.visit(expr)
147
+ bases: list[str] = []
148
+ for expr in node.bases:
149
+ parts = _flatten_chain(expr)
150
+ target = self.indexer.resolve_chain(parts, self.scope) if parts else None
151
+ if isinstance(target, Resolved) and not target.detail:
152
+ if target.symbol in self.indexer.class_scopes:
153
+ bases.append(target.symbol)
154
+ self.indexer.class_creation(self.source, bases, node.keywords, self.scope)
155
+ outer = self._push(_LocalBindings().collect(node))
156
+ try:
157
+ for stmt in node.body:
158
+ self.visit(stmt)
159
+ finally:
160
+ self.scope = outer
161
+
162
+ def visit_Lambda(self, node: ast.Lambda) -> None:
163
+ for default in list(node.args.defaults) + [d for d in node.args.kw_defaults if d]:
164
+ self.visit(default)
165
+ outer = self._push(_LocalBindings().collect(node))
166
+ try:
167
+ self.visit(node.body)
168
+ finally:
169
+ self.scope = outer
170
+
171
+ def _visit_comprehension(
172
+ self, node: ast.ListComp | ast.SetComp | ast.GeneratorExp | ast.DictComp
173
+ ) -> None:
174
+ generators = node.generators
175
+ bound: set[str] = set()
176
+ literals: dict[str, tuple[str, ...] | None] = {}
177
+ for gen in generators:
178
+ bound |= _collect_store_names(gen.target)
179
+ if isinstance(gen.target, ast.Name):
180
+ literals[gen.target.id] = self.scope.string_candidates(gen.iter)
181
+ outer = self._push(bound, literals)
182
+ try:
183
+ for gen in generators:
184
+ self.visit(gen.iter)
185
+ for cond in gen.ifs:
186
+ self.visit(cond)
187
+ for field_name in ("elt", "key", "value"):
188
+ child = getattr(node, field_name, None)
189
+ if child is not None:
190
+ self.visit(child)
191
+ finally:
192
+ self.scope = outer
193
+
194
+ visit_ListComp = visit_SetComp = visit_DictComp = visit_GeneratorExp = _visit_comprehension
195
+
196
+ def _resolve(self, parts: list[str], kind: str = REFERENCES) -> None:
197
+ node, rest = self.indexer.resolve_chain_names(parts, self.scope)
198
+ chain = ".".join(parts)
199
+ self.indexer._record(self.source, node, kind=kind, chain=chain)
200
+ for name in rest:
201
+ self.indexer.out.unresolved.add(
202
+ UnresolvedReference(self.source, UNRESOLVED_ATTRIBUTE, name, chain)
203
+ )
204
+
205
+ def _dynamic(self, detail: str) -> None:
206
+ self.indexer.out.unresolved.add(
207
+ UnresolvedReference(self.source, UNRESOLVED_DYNAMIC, "", detail)
208
+ )
209
+
210
+ def visit_Name(self, node: ast.Name) -> None:
211
+ if isinstance(node.ctx, ast.Load):
212
+ self._resolve([node.id])
213
+ self._mark_escape(node, [node.id])
214
+
215
+ def _self_class(self, node: ast.expr) -> str | None:
216
+ """The class of the method this is in, when ``node`` is its ``self``
217
+ or ``cls``."""
218
+ if isinstance(node, ast.Name) and node.id == self.scope.self_name:
219
+ return self.scope.self_class
220
+ return None
221
+
222
+ def _is_self(self, node: ast.expr) -> bool:
223
+ return self._self_class(node) is not None
224
+
225
+ def visit_Attribute(self, node: ast.Attribute) -> None:
226
+ if not isinstance(node.ctx, ast.Load):
227
+ self._attribute_write(node)
228
+ elif node.attr in REFLECTIVE_ATTRIBUTES:
229
+ self.indexer.out.reflection.add((self.source, f".{node.attr}"))
230
+ if node.attr == "__dict__":
231
+ # ``__dict__`` can read or write any attribute; through another
232
+ # receiver (aliased, reassigned, ``|=``) the class is unknown.
233
+ owner = self.scope.self_class if self._is_self(node.value) else ""
234
+ self.indexer.out.attr_unbound.add((owner or "", "*"))
235
+ parts = _flatten_chain(node)
236
+ if parts is not None:
237
+ self._resolve(parts)
238
+ self._mark_escape(node, parts)
239
+ if (
240
+ isinstance(node.ctx, ast.Load)
241
+ and len(parts) >= 2
242
+ and parts[0] == self.scope.self_name
243
+ and self.scope.self_class is not None
244
+ and isinstance(
245
+ self.indexer.lookup_in_class(self.scope.self_class, parts[1]), Unresolved
246
+ )
247
+ ):
248
+ self.indexer.out.attr_refs.append(
249
+ _AttrRef(
250
+ self.source, self.scope.self_class, parts[1], parts[2:], ".".join(parts)
251
+ )
252
+ )
253
+ return
254
+ if self._is_zero_arg_super(node.value) and self.scope.self_class is not None:
255
+ # ``super().m``: next definition of ``m`` in the enclosing class's MRO.
256
+ target = self.indexer.lookup_super(self.scope.self_class, node.attr)
257
+ self.indexer._record(self.source, target, chain=f"super().{node.attr}")
258
+ if node is not self._call_func:
259
+ self._escape(target)
260
+ return
261
+ # ``Foo().run``, ``items[0].run``, ``make().run``: the base value is
262
+ # unknown, but the attribute name still bounds what it may refer to.
263
+ self.indexer.out.unresolved.add(
264
+ UnresolvedReference(self.source, UNRESOLVED_ATTRIBUTE, node.attr, f"<expr>.{node.attr}")
265
+ )
266
+ self.generic_visit(node)
267
+
268
+ def _is_zero_arg_super(self, node: ast.expr) -> bool:
269
+ return (
270
+ isinstance(node, ast.Call)
271
+ and isinstance(node.func, ast.Name)
272
+ and node.func.id == "super"
273
+ and not node.args
274
+ and not self._is_shadowed("super")
275
+ )
276
+
277
+ def visit_Import(self, node: ast.Import) -> None:
278
+ return # handled by Indexer._import_edges
279
+
280
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
281
+ return
282
+
283
+ # Methods that mutate a container in place: a call of one on a variable
284
+ # makes the caller a writer of that variable.
285
+ MUTATING_METHODS = frozenset(
286
+ {
287
+ "append",
288
+ "extend",
289
+ "insert",
290
+ "pop",
291
+ "popitem",
292
+ "remove",
293
+ "clear",
294
+ "update",
295
+ "setdefault",
296
+ "add",
297
+ "discard",
298
+ "sort",
299
+ "reverse",
300
+ "__setitem__",
301
+ "__delitem__",
302
+ }
303
+ )
304
+
305
+ def _mutation_target(self, expr: ast.expr) -> None:
306
+ """Record ``source`` as a writer when ``expr`` (an assignment target or
307
+ a receiver of a mutating call) is a subscript/attribute of a variable
308
+ symbol, or the variable itself under ``global``; or another module's
309
+ variable assigned through the module (``settings.DEBUG = True`` in a
310
+ conftest: whoever reads ``settings.DEBUG`` sees the writer)."""
311
+ if isinstance(expr, ast.Attribute):
312
+ chain = _flatten_chain(expr)
313
+ if chain is not None and len(chain) > 1 and chain[0] not in self.scope.locals:
314
+ target = self.indexer.resolve_chain(chain, self.scope)
315
+ if isinstance(target, Resolved):
316
+ symbol = self.indexer.index.symbols.get(target.symbol)
317
+ if (
318
+ symbol is not None
319
+ and symbol.id != self.source
320
+ and symbol.module != self.scope.module.name
321
+ and (
322
+ (symbol.kind == VARIABLE and not target.detail)
323
+ or (symbol.kind == MODULE and target.detail.startswith("attribute:"))
324
+ )
325
+ ):
326
+ self.indexer.out.edges.add(
327
+ Edge(symbol.id, self.source, REFERENCES, "mutated_by")
328
+ )
329
+ return
330
+ base = expr
331
+ while isinstance(base, (ast.Subscript, ast.Attribute)):
332
+ base = base.value
333
+ if not isinstance(base, ast.Name):
334
+ return
335
+ if base.id in self.scope.locals and base.id not in self.scope.param_aliases:
336
+ return
337
+ if base is expr and base.id in self.scope.locals:
338
+ return # rebinding a parameter is not a mutation of its default
339
+ node = self.indexer.resolve_chain([base.id], self.scope)
340
+ if isinstance(node, Resolved) and not node.detail:
341
+ symbol = self.indexer.index.symbols.get(node.symbol)
342
+ if symbol is None or symbol.kind != VARIABLE or symbol.id == self.source:
343
+ return
344
+ if self.skip_defs and symbol.module == self.scope.module.name:
345
+ return # a module's own top-level mutations are part of the variable's hash
346
+ self.indexer.out.edges.add(Edge(symbol.id, self.source, REFERENCES, "mutated_by"))
347
+
348
+ def _attribute_write(self, node: ast.Attribute) -> None:
349
+ """A store or delete of ``<receiver>.<attr>``: on ``self`` it is a
350
+ write of that class's instance attribute (bound only when it is a
351
+ plain ``__init__`` assignment); on any other receiver the type is
352
+ unknown, so no class's ``attr`` can be bounded."""
353
+ if (cls := self._self_class(node.value)) is not None:
354
+ binding = self._bindings.pop(id(node), None)
355
+ self.indexer.out.attr_writes.append(
356
+ _AttrWrite(cls, node.attr, self.scope.method, binding)
357
+ )
358
+ else:
359
+ self.indexer.out.attr_unbound.add(("", node.attr))
360
+
361
+ def _init_binding(self, value: ast.expr) -> list | None:
362
+ """What ``self.<attr> = value`` in ``__init__`` binds, if bounded."""
363
+ if isinstance(value, ast.Name) and value.id in self.scope.params:
364
+ return None if value.id in self.scope.rebound else ["param", value.id]
365
+ strings = self.scope.string_candidates(value)
366
+ if strings is not None:
367
+ return ["strings", list(strings)]
368
+ parts = _flatten_chain(value)
369
+ if parts is None:
370
+ return None
371
+ target = self.indexer.resolve_chain(parts, self.scope)
372
+ if (
373
+ isinstance(target, Resolved)
374
+ and not (target.detail or target.receiver or target.overrides)
375
+ and not target.uncertain_attr
376
+ ):
377
+ return ["symbol", target.symbol]
378
+ return None
379
+
380
+ def _stash_binding(self, target: ast.expr, value: ast.expr | None) -> None:
381
+ if (
382
+ value is not None
383
+ and isinstance(target, ast.Attribute)
384
+ and self._is_self(target.value)
385
+ and self.scope.method.rsplit(".", 1)[-1] == "__init__"
386
+ ):
387
+ self._bindings[id(target)] = self._init_binding(value)
388
+
389
+ def _reflective_write(self, receiver: ast.expr | None, name: ast.expr | None) -> None:
390
+ """``setattr(receiver, name, ...)`` and its relatives."""
391
+ names = self.scope.string_candidates(name) if name is not None else None
392
+ owner = (self._self_class(receiver) if receiver is not None else None) or ""
393
+ for attr in names if names is not None else ("*",):
394
+ self.indexer.out.attr_unbound.add((owner, attr.rsplit(".", 1)[-1]))
395
+
396
+ def _dict_write(self, expr: ast.expr) -> None:
397
+ """``x.__dict__[k] = v``, ``vars(x).update(...)``: any attribute."""
398
+ while isinstance(expr, ast.Subscript):
399
+ expr = expr.value
400
+ receiver: ast.expr | None = None
401
+ if isinstance(expr, ast.Attribute) and expr.attr == "__dict__":
402
+ receiver = expr.value
403
+ elif (
404
+ isinstance(expr, ast.Call)
405
+ and isinstance(expr.func, ast.Name)
406
+ and expr.func.id == "vars"
407
+ and expr.args
408
+ ):
409
+ receiver = expr.args[0]
410
+ if receiver is not None:
411
+ self._reflective_write(receiver, None)
412
+
413
+ def visit_Assign(self, node: ast.Assign) -> None:
414
+ if len(node.targets) == 1:
415
+ self._stash_binding(node.targets[0], node.value)
416
+ for target in node.targets:
417
+ if isinstance(target, ast.Subscript):
418
+ self._dict_write(target)
419
+ for sub in ast.walk(target):
420
+ if isinstance(sub, (ast.Subscript, ast.Attribute)):
421
+ self._mutation_target(sub)
422
+ elif isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Store):
423
+ self._mutation_target(sub) # rebinding a ``global`` variable
424
+ self.generic_visit(node)
425
+
426
+ def visit_AugAssign(self, node: ast.AugAssign) -> None:
427
+ self._mutation_target(node.target)
428
+ if isinstance(node.target, ast.Subscript):
429
+ self._dict_write(node.target)
430
+ self.generic_visit(node)
431
+
432
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
433
+ if node.value is not None:
434
+ self._mutation_target(node.target)
435
+ self._stash_binding(node.target, node.value)
436
+ self.visit(node.target)
437
+ self.visit(node.annotation)
438
+ if node.value is not None:
439
+ self.visit(node.value)
440
+
441
+ def visit_Delete(self, node: ast.Delete) -> None:
442
+ for target in node.targets:
443
+ self._mutation_target(target)
444
+ if isinstance(target, ast.Subscript):
445
+ self._dict_write(target)
446
+ self.generic_visit(node)
447
+
448
+ def visit_Call(self, node: ast.Call) -> None:
449
+ parts = _flatten_chain(node.func)
450
+ if (
451
+ isinstance(node.func, ast.Attribute)
452
+ and node.func.attr in self.MUTATING_METHODS
453
+ and isinstance(node.func.value, (ast.Name, ast.Subscript, ast.Attribute, ast.Call))
454
+ ):
455
+ self._mutation_target(node.func.value)
456
+ self._dict_write(node.func.value)
457
+ if isinstance(node.func, ast.Attribute) and node.func.attr in (
458
+ "__setattr__",
459
+ "__delattr__",
460
+ ):
461
+ # ``object.__setattr__(self, n, v)`` / ``self.__setattr__(n, v)``.
462
+ receiver = node.func.value if self._is_self(node.func.value) else None
463
+ if receiver is None and node.args and self._is_self(node.args[0]):
464
+ receiver = node.args[0]
465
+ self._reflective_write(receiver, None)
466
+ if parts is not None:
467
+ name = ".".join(parts)
468
+ builtin = len(parts) == 1 and not self._is_shadowed(name)
469
+ if (builtin and parts[0] in REFLECTIVE_BUILTINS) or (
470
+ not builtin and self._canonical_name(parts) in REFLECTIVE_CALLS
471
+ ):
472
+ self.indexer.out.reflection.add((self.source, f"{name}()"))
473
+ if builtin and parts[0] in DYNAMIC_CALLS:
474
+ code = node.args[0] if node.args else None
475
+ literal = isinstance(code, ast.Constant) and isinstance(code.value, str)
476
+ scope_node = self.scope.literal_node or self.scope.module.tree
477
+ if (
478
+ parts[0] in ("exec", "eval")
479
+ and code is not None
480
+ and not literal
481
+ and (
482
+ _reads_files(code) or (scope_node is not None and _reads_files(scope_node))
483
+ )
484
+ ):
485
+ # Code read from a file at run time (``exec(open("plugin.py")
486
+ # .read())``) can import anything; code generated from the
487
+ # program's own templates reaches only what its module can.
488
+ self._dynamic(f"{name}() of code read at run time: an import of anything")
489
+ else:
490
+ self._dynamic(f"{name}()")
491
+ elif builtin and parts[0] == "__import__":
492
+ self._import_module(node, "__import__")
493
+ elif builtin and parts[0] == "getattr":
494
+ self._getattr(node)
495
+ elif (canonical := self._canonical_name(parts)) in (
496
+ "importlib.import_module",
497
+ "importlib.__import__",
498
+ "runpy.run_module",
499
+ ):
500
+ self._import_module(node, canonical)
501
+ elif canonical == "runpy.run_path":
502
+ self._dynamic("runpy.run_path()") # runs a file by path: anything
503
+ if builtin and parts[0] == "vars" and node.args:
504
+ if (cls := self._self_class(node.args[0])) is not None:
505
+ self.indexer.out.attr_unbound.add((cls, "*"))
506
+ if builtin and parts[0] == "type" and len(node.args) == 1:
507
+ if (cls := self._self_class(node.args[0])) is not None:
508
+ self.indexer.escape_class_family(cls)
509
+ if parts[-1] in ("setattr", "delattr") or parts[-2:] == ["patch", "object"]:
510
+ self._setattr_call(node, parts)
511
+ self._string_targets(node, parts)
512
+ if builtin and parts[0] in ("isinstance", "issubclass") and len(node.args) == 2:
513
+ self._mark_type_node(node.args[1])
514
+ elif node.args and self._canonical_name(parts) in (
515
+ "typing.cast",
516
+ "typing_extensions.cast",
517
+ ):
518
+ self._mark_type_node(node.args[0])
519
+ self._record_call_site(node, parts)
520
+ elif (
521
+ isinstance(node.func, ast.Attribute)
522
+ and self._is_zero_arg_super(node.func.value)
523
+ and self.scope.self_class is not None
524
+ ):
525
+ target = self.indexer.lookup_super(self.scope.self_class, node.func.attr)
526
+ self._add_call_site(node, target, receiver_bound=True)
527
+ self._call_func = node.func
528
+ self.generic_visit(node)
529
+
530
+ _call_func: ast.expr | None = None
531
+
532
+ def _mark_type_node(self, node: ast.expr) -> None:
533
+ self._type_nodes.add(id(node))
534
+ if isinstance(node, ast.Tuple):
535
+ self._type_nodes |= {id(e) for e in node.elts}
536
+
537
+ def _string_targets(self, node: ast.Call, parts: list[str]) -> None:
538
+ """Names a call reaches only through a string: ``monkeypatch.setattr(
539
+ "pkg.config.TIMEOUT", 0)``, ``mock.patch("pkg.api.fetch")``,
540
+ ``patch.dict("pkg.config.D")`` and ``(obj, "NAME")`` pairs depend on
541
+ that name existing (patching a missing one raises); a string
542
+ ``skipif``/``xfail`` condition is code pytest evaluates."""
543
+ args = node.args
544
+ last = parts[-1]
545
+ if last in ("skipif", "xfail") and args:
546
+ condition = args[0]
547
+ if isinstance(condition, ast.Constant) and isinstance(condition.value, str):
548
+ try:
549
+ expr = ast.parse(condition.value, mode="eval").body
550
+ except SyntaxError:
551
+ return
552
+ self.visit(expr)
553
+ return
554
+ patching = (
555
+ (last in ("setattr", "delattr") and len(parts) > 1)
556
+ or last == "patch"
557
+ or parts[-2:] in (["patch", "object"], ["patch", "dict"])
558
+ )
559
+ if not patching or not args:
560
+ return
561
+ first = args[0]
562
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
563
+ chain = first.value.split(".")
564
+ if len(chain) > 1 and all(c.isidentifier() for c in chain):
565
+ node_ = self.indexer.resolve_dotted(chain)
566
+ if node_ is not None:
567
+ self.indexer._record(self.source, node_, chain=first.value)
568
+ return
569
+ if len(args) > 1 and isinstance(args[1], ast.Constant) and isinstance(args[1].value, str):
570
+ receiver = _flatten_chain(first)
571
+ if receiver is not None and args[1].value.isidentifier():
572
+ self._resolve([*receiver, args[1].value])
573
+
574
+ def _setattr_call(self, node: ast.Call, parts: list[str]) -> None:
575
+ """``setattr``/``delattr``, ``monkeypatch.setattr`` and
576
+ ``patch.object``: the receiver is the first argument and the name the
577
+ second (``monkeypatch.setattr("pkg.mod.name", value)`` names it in a
578
+ dotted string instead)."""
579
+ args = list(node.args)
580
+ keywords = {k.arg: k.value for k in node.keywords if k.arg}
581
+ if len(parts) > 1 and args and isinstance(args[0], ast.Constant):
582
+ self._reflective_write(None, args[0])
583
+ return
584
+ receiver = args[0] if args else keywords.get("target")
585
+ name = args[1] if len(args) > 1 else keywords.get("name", keywords.get("attribute"))
586
+ self._reflective_write(receiver, name)
587
+
588
+ def _record_call_site(self, node: ast.Call, parts: list[str]) -> None:
589
+ target = self.indexer.resolve_chain(parts, self.scope)
590
+ if not (isinstance(target, Resolved) and not target.detail):
591
+ return
592
+ symbol = self.indexer.index.symbols.get(target.symbol)
593
+ if symbol is not None and symbol.kind == CLASS:
594
+ # Constructing a class calls the ``__init__`` its MRO resolves to
595
+ # (any subclass's, for ``cls(...)``), with ``self`` implicit.
596
+ init = self.indexer.lookup_in_class(symbol.id, "__init__", dispatch=target.receiver)
597
+ self._add_call_site(node, init, receiver_bound=True)
598
+ return
599
+ if symbol is None or symbol.kind not in (FUNCTION, METHOD):
600
+ return
601
+ receiver_bound = False
602
+ if symbol.kind == METHOD and len(parts) > 1:
603
+ base = self.indexer._lookup_base(parts[0], self.scope)
604
+ base_is_class = (
605
+ isinstance(base, Resolved)
606
+ and not base.detail
607
+ and base.symbol in self.indexer.class_scopes
608
+ and len(parts) == 2
609
+ and parts[0] != self.scope.self_name # self/cls also resolve to the class
610
+ )
611
+ receiver_bound = not base_is_class
612
+ self._add_call_site(node, target, receiver_bound=receiver_bound)
613
+
614
+ def _add_call_site(self, node: ast.Call, target: Node, *, receiver_bound: bool) -> None:
615
+ if not (isinstance(target, Resolved) and not target.detail):
616
+ return
617
+ symbol = self.indexer.index.symbols.get(target.symbol)
618
+ if symbol is None or symbol.kind not in (FUNCTION, METHOD):
619
+ return
620
+ unbounded = any(isinstance(a, ast.Starred) for a in node.args) or any(
621
+ k.arg is None for k in node.keywords
622
+ )
623
+ site = _CallSite(
624
+ positional=[self.scope.string_candidates(a) for a in node.args],
625
+ keywords={
626
+ k.arg: self.scope.string_candidates(k.value)
627
+ for k in node.keywords
628
+ if k.arg is not None
629
+ },
630
+ unbounded=unbounded,
631
+ receiver_bound=receiver_bound,
632
+ positional_classes=[self._argument_class(a) for a in node.args],
633
+ keyword_classes={
634
+ k.arg: self._argument_class(k.value) for k in node.keywords if k.arg is not None
635
+ },
636
+ positional_sources=[self._argument_source(a) for a in node.args],
637
+ keyword_sources={
638
+ k.arg: self._argument_source(k.value) for k in node.keywords if k.arg is not None
639
+ },
640
+ caller=self.source,
641
+ positional_params=[self._argument_param(a) for a in node.args],
642
+ keyword_params={
643
+ k.arg: self._argument_param(k.value) for k in node.keywords if k.arg is not None
644
+ },
645
+ )
646
+ self.indexer.out.call_sites[symbol.id].append(site)
647
+ # A dispatched call may land on any override: they share the call site.
648
+ for override_id, detail in target.overrides:
649
+ if not detail:
650
+ self.indexer.out.call_sites[override_id].append(site)
651
+
652
+ def _argument_class(self, expr: ast.expr) -> str | None:
653
+ """The class an argument is an instance of, when the argument says so:
654
+ ``C()`` passes an instance of C, ``C`` passes the class itself. A name
655
+ holding an instance, or anything a function returns, says nothing."""
656
+ target = expr.func if isinstance(expr, ast.Call) else expr
657
+ parts = _flatten_chain(target)
658
+ if parts is None:
659
+ return None
660
+ node = self.indexer.resolve_chain(parts, self.scope)
661
+ if not isinstance(node, Resolved) or node.detail:
662
+ return None
663
+ symbol = self.indexer.index.symbols.get(node.symbol)
664
+ return node.symbol if symbol is not None and symbol.kind == CLASS else None
665
+
666
+ def _argument_param(self, expr: ast.expr) -> str | None:
667
+ """The enclosing function's parameter an argument is, when it is
668
+ exactly that name and the body never rebinds it."""
669
+ if not isinstance(expr, ast.Name) or isinstance(expr.ctx, ast.Store):
670
+ return None
671
+ if expr.id not in self.scope.params or expr.id in self.scope.rebound:
672
+ return None
673
+ return expr.id
674
+
675
+ def _argument_source(self, expr: ast.expr) -> str | None:
676
+ """The function an argument came out of: ``make()`` directly, or a
677
+ name this scope assigned once from such a call (``obj = make()``).
678
+ What that function returns is known only once every module has been
679
+ indexed, so the join happens at the end (see Indexer.build)."""
680
+ if isinstance(expr, ast.Name) and not isinstance(expr.ctx, ast.Store):
681
+ expr = self._local_source(expr.id) or expr
682
+ if not isinstance(expr, ast.Call):
683
+ return None
684
+ parts = _flatten_chain(expr.func)
685
+ if parts is None:
686
+ return None
687
+ node = self.indexer.resolve_chain(parts, self.scope)
688
+ if not isinstance(node, Resolved) or node.detail:
689
+ return None
690
+ symbol = self.indexer.index.symbols.get(node.symbol)
691
+ return node.symbol if symbol is not None and symbol.kind in (FUNCTION, METHOD) else None
692
+
693
+ def _local_source(self, name: str) -> ast.expr | None:
694
+ """What this scope assigns ``name``, when that is the only thing that
695
+ binds it. A second assignment, a loop target, a ``with ... as``, a
696
+ ``del`` or a parameter of the same name all say nothing: the object
697
+ could be either."""
698
+ body = self.scope.literal_node
699
+ if body is None or name in self.scope.params:
700
+ return None
701
+ bindings = [
702
+ inner
703
+ for inner in ast.walk(body)
704
+ if isinstance(inner, ast.Name)
705
+ and inner.id == name
706
+ and not isinstance(inner.ctx, ast.Load)
707
+ ]
708
+ if len(bindings) != 1:
709
+ return None
710
+ for inner in ast.walk(body):
711
+ if isinstance(inner, ast.Assign) and any(t is bindings[0] for t in inner.targets):
712
+ return inner.value
713
+ if isinstance(inner, ast.AnnAssign) and inner.target is bindings[0]:
714
+ return inner.value
715
+ return None
716
+
717
+ def _mark_escape(self, node: ast.expr, parts: list[str]) -> None:
718
+ """A function referenced other than as the callee of a call may be
719
+ called from anywhere with anything; so may a class (constructed), unless
720
+ it is only named by ``isinstance``/``issubclass``/``typing.cast``
721
+ (annotations count: frameworks construct from them). ``self`` as a value is an
722
+ instance, not its class; ``cls``, ``type(self)`` and
723
+ ``self.__class__`` are the class of any in-scope subclass."""
724
+ if parts[0] == self.scope.self_name and self.scope.self_class is not None:
725
+ if parts == [parts[0], "__class__"] or (
726
+ len(parts) == 1 and self.scope.self_is_class and node is not self._call_func
727
+ ):
728
+ self.indexer.escape_class_family(self.scope.self_class)
729
+ return
730
+ if len(parts) == 1:
731
+ return
732
+ if node is self._call_func:
733
+ return
734
+ type_position = id(node) in self._type_nodes
735
+ self._escape(self.indexer.resolve_chain(parts, self.scope), classes=not type_position)
736
+
737
+ def _escape(self, target: Node, *, classes: bool = True) -> None:
738
+ if isinstance(target, Resolved) and not target.detail:
739
+ self.indexer.escape(target, classes=classes)
740
+
741
+ def _param_dynamic(
742
+ self, expr: ast.expr, kind: str, base: list[str] | None, detail: str
743
+ ) -> bool:
744
+ """Defer a dynamic use whose name is one of the enclosing function's
745
+ parameters (not rebound in its body) or an instance attribute
746
+ ``self.<name>``; returns False when that does not apply."""
747
+ if isinstance(expr, ast.Attribute) and (cls := self._self_class(expr.value)) is not None:
748
+ self.indexer.out.param_dynamics.append(
749
+ _ParamDynamic(self.source, expr.attr, kind, base, self.scope, detail, cls)
750
+ )
751
+ return True
752
+ if not (
753
+ isinstance(expr, ast.Name)
754
+ and expr.id in self.scope.params
755
+ and expr.id not in self.scope.rebound
756
+ ):
757
+ return False
758
+ self.indexer.out.param_dynamics.append(
759
+ _ParamDynamic(self.source, expr.id, kind, base, self.scope, detail)
760
+ )
761
+ return True
762
+
763
+ def _forwarded_name(self, expr: ast.expr) -> bool:
764
+ """``getattr(x, name)`` inside ``__getattr__``/``__getattribute__``
765
+ with ``name`` the method's own name parameter: the method runs only
766
+ for an access ``obj.<name>``, and every access site records ``<name>``
767
+ itself (resolved, or as a name-bounded reference that reaches
768
+ whatever this lookup can), so it is not a dynamic reference."""
769
+ method = self.scope.method.rsplit(".", 1)[-1]
770
+ if method not in ("__getattr__", "__getattribute__") or not self.scope.self_class:
771
+ return False
772
+ params = [p for p, i in self.scope.params.items() if i == 1]
773
+ return (
774
+ isinstance(expr, ast.Name)
775
+ and bool(params)
776
+ and expr.id == params[0]
777
+ and expr.id not in self.scope.rebound
778
+ )
779
+
780
+ def _getattr_detail(self, receiver: ast.expr, base: list[str] | None) -> str:
781
+ """What bounds an unbounded ``getattr(x, <name>)``. The seeding
782
+ module's import closure covers an object that is one of that
783
+ module's globals: a module it can name holds attributes from its own
784
+ closure, which is inside ours. A receiver that came from somewhere
785
+ else -- a parameter, a call result, an instance whose attributes a
786
+ caller may have set -- can be an object of any module; that read is
787
+ bounded at the other end, by the classes handed to other code (see
788
+ ``SourceIndex.escaped_classes``), and the detail says so."""
789
+ node = self.indexer.resolve_chain(base, self.scope) if base else None
790
+ if isinstance(node, (ModuleNode, External)):
791
+ return "getattr(<non-literal>)"
792
+ return "getattr(<non-literal>) on a receiver from elsewhere, bound to the classes handed on"
793
+
794
+ def _getattr(self, node: ast.Call) -> None:
795
+ if len(node.args) < 2:
796
+ return
797
+ if self._forwarded_name(node.args[1]):
798
+ return
799
+ names = self.scope.string_candidates(node.args[1])
800
+ base = _flatten_chain(node.args[0])
801
+ if names is None:
802
+ prefix = _string_prefix(node.args[1])
803
+ if prefix is not None:
804
+ # ``getattr(obj, f"pytest_{name}")``: every in-scope attribute
805
+ # name with that prefix is a candidate, nothing else.
806
+ names = self.indexer.symbol_names_with_prefix(prefix)
807
+ elif not self._param_dynamic(
808
+ node.args[1], "getattr", base, self._getattr_detail(node.args[0], base)
809
+ ):
810
+ # The name is not a parameter either: nothing bounds it.
811
+ self._dynamic(self._getattr_detail(node.args[0], base))
812
+ return
813
+ else:
814
+ return
815
+ for name in names:
816
+ if base is None:
817
+ # Unknown receiver, known attribute name: bounded like ``obj.name``.
818
+ self.indexer.out.unresolved.add(
819
+ UnresolvedReference(
820
+ self.source, UNRESOLVED_ATTRIBUTE, name, f"getattr(..., {name!r})"
821
+ )
822
+ )
823
+ else:
824
+ # The attribute's value is used, so a function or class
825
+ # found this way may be called from here with anything.
826
+ self._resolve(base + [name])
827
+ self._escape(self.indexer.resolve_chain(base + [name], self.scope))
828
+
829
+ def _canonical_name(self, parts: list[str]) -> str | None:
830
+ """The dotted name a callee chain refers to through the scope's import
831
+ bindings (``import_module`` from ``from importlib import
832
+ import_module`` is ``importlib.import_module``); None for a local."""
833
+ head = parts[0]
834
+ binding = self.scope.local_imports.get(head)
835
+ if binding is None:
836
+ if head in self.scope.locals:
837
+ return None
838
+ binding = self.scope.module.imports.get(head)
839
+ if binding is None:
840
+ return ".".join(parts)
841
+ base = binding.module if binding.attr is None else f"{binding.module}.{binding.attr}"
842
+ return ".".join([base, *parts[1:]])
843
+
844
+ def _import_package(self, node: ast.Call) -> str | None:
845
+ """``import_module``'s ``package`` argument when it is known: a
846
+ literal, ``__name__`` (this module) or ``__package__``."""
847
+ arg = node.args[1] if len(node.args) > 1 else None
848
+ for k in node.keywords:
849
+ if k.arg == "package":
850
+ arg = k.value
851
+ if arg is None:
852
+ return None
853
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
854
+ return arg.value
855
+ module = self.scope.module
856
+ # Under a ``DIR=PREFIX`` root the indexed name is diffcone's own, not
857
+ # the one Python gives the module at runtime.
858
+ roots = [split_root(r)[0] for r in self.indexer.snapshot.source_roots]
859
+ if module_name_for(module.path, roots) != module.name:
860
+ return None
861
+ if isinstance(arg, ast.Name) and not self._is_shadowed(arg.id):
862
+ if arg.id == "__name__":
863
+ return module.name
864
+ if arg.id == "__package__":
865
+ return module.name if module.is_package else module.name.rpartition(".")[0]
866
+ return None
867
+
868
+ def _import_module(self, node: ast.Call, name: str) -> None:
869
+ if not node.args:
870
+ return
871
+ package = self._import_package(node) if name == "importlib.import_module" else None
872
+ names = self.scope.string_candidates(node.args[0])
873
+ if names is not None and package is not None:
874
+ resolved = [_resolve_relative_name(n, package) for n in names]
875
+ if all(r is not None for r in resolved):
876
+ names = tuple(r for r in resolved if r is not None)
877
+ if names is None:
878
+ prefix = _string_prefix(node.args[0])
879
+ if prefix is not None and prefix.startswith(".") and package is not None:
880
+ prefix = _resolve_relative_name(prefix, package, prefix=True)
881
+ if prefix is not None and not prefix.startswith("."):
882
+ # ``import_module(f"attr.{name}")``: every in-scope module under
883
+ # the prefix may be imported; nothing outside it can be.
884
+ names = self.indexer.modules_with_prefix(prefix)
885
+ if not names:
886
+ self.indexer.out.external.add(ExternalReference(self.source, prefix + "*"))
887
+ return
888
+ if names is None or any(n.startswith(".") for n in names):
889
+ if names is not None or not self._param_dynamic(
890
+ node.args[0], "import", None, f"{name}(<non-literal>)"
891
+ ):
892
+ self._dynamic(f"{name}(<non-literal>)")
893
+ return
894
+ for module in names:
895
+ self.indexer._module_import_edge(self.source, module)