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.
- diffcone/__init__.py +13 -0
- diffcone/cache.py +528 -0
- diffcone/check.py +399 -0
- diffcone/classify.py +255 -0
- diffcone/cli.py +886 -0
- diffcone/collect.py +846 -0
- diffcone/cython.py +757 -0
- diffcone/declarations.py +111 -0
- diffcone/discovery/__init__.py +121 -0
- diffcone/discovery/asv_static.py +494 -0
- diffcone/discovery/common.py +130 -0
- diffcone/discovery/pytest_static.py +2680 -0
- diffcone/evidence.py +738 -0
- diffcone/evidence_plan.py +1487 -0
- diffcone/execution.py +1681 -0
- diffcone/indexer/__init__.py +48 -0
- diffcone/indexer/core.py +286 -0
- diffcone/indexer/definitions.py +292 -0
- diffcone/indexer/dynamics.py +406 -0
- diffcone/indexer/facts.py +373 -0
- diffcone/indexer/literals.py +388 -0
- diffcone/indexer/references.py +895 -0
- diffcone/indexer/resolver.py +1001 -0
- diffcone/indexer/scopes.py +276 -0
- diffcone/indexer/state.py +83 -0
- diffcone/indexer/symbols.py +441 -0
- diffcone/indexer/syntax.py +173 -0
- diffcone/manifest.py +166 -0
- diffcone/model.py +191 -0
- diffcone/planner.py +1453 -0
- diffcone/report.py +256 -0
- diffcone/selection.py +133 -0
- diffcone/snapshot.py +739 -0
- diffcone/testing.py +200 -0
- diffcone-0.1.0.dist-info/METADATA +133 -0
- diffcone-0.1.0.dist-info/RECORD +39 -0
- diffcone-0.1.0.dist-info/WHEEL +4 -0
- diffcone-0.1.0.dist-info/entry_points.txt +3 -0
- diffcone-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|