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,406 @@
1
+ """After pass 2: dynamic references whose name or receiver comes from a
2
+ parameter or an instance attribute, bounded by what every call site or
3
+ ``__init__`` passes."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections import defaultdict
8
+ from typing import Any
9
+
10
+ from diffcone.indexer.definitions import _ATTRIBUTE_HOOKS
11
+ from diffcone.indexer.facts import _AttrWrite, _ParamDynamic
12
+ from diffcone.indexer.resolver import Resolver
13
+ from diffcone.indexer.scopes import Node, Resolved, Unresolved
14
+ from diffcone.indexer.syntax import IMPORT_ATTRIBUTION_DEPTH
15
+ from diffcone.model import (
16
+ CLASS,
17
+ REFERENCES,
18
+ UNRESOLVED_ATTRIBUTE,
19
+ UNRESOLVED_DYNAMIC,
20
+ Edge,
21
+ UnresolvedReference,
22
+ )
23
+
24
+
25
+ class DynamicBounds(Resolver):
26
+ """Bounds on dynamic references, after pass 2."""
27
+
28
+ def _resolve_param_dynamics(self) -> None:
29
+ """Expand ``getattr(x, p)`` / ``import_module(p)`` where ``p`` is a
30
+ parameter or an instance attribute, using the literal strings every
31
+ resolved call site passes (for an attribute: what ``__init__`` binds
32
+ it to, see _attribute_writes). A function that escapes (used as a
33
+ value, or whose name occurs as an unresolved reference so callers may
34
+ be unknown) or has an unbounded call site stays dynamic.
35
+
36
+ Expanding a ``getattr`` can itself make a function escape (its value
37
+ is used) or record a new name-bounded reference (a name that cannot
38
+ be resolved on its receiver), either of which can unbound another
39
+ expansion, so the candidates are recomputed until both the escape
40
+ set and the unresolved names are stable."""
41
+ unresolved_names = {
42
+ u.name for u in self.index.unresolved if u.name and not u.detail.startswith("super().")
43
+ }
44
+ # ``super().m`` that did not resolve in class K can still only reach
45
+ # an ``m`` after K in the MRO of K or of a subclass of K.
46
+ self._super_misses.clear()
47
+ for u in self.index.unresolved:
48
+ if u.name and u.detail.startswith("super()."):
49
+ source = self.index.symbols.get(u.symbol)
50
+ while source is not None and source.kind != CLASS:
51
+ source = self.index.symbols.get(source.container or "")
52
+ if source is None:
53
+ unresolved_names.add(u.name)
54
+ else:
55
+ self._super_misses[u.name].add(source.id)
56
+ writes: dict[tuple[str, str], list[_AttrWrite]] = defaultdict(list)
57
+ for w in self.out.attr_writes:
58
+ writes[(w.cls, w.attr)].append(w)
59
+ while True:
60
+ escapes = set(self.out.escapes)
61
+ names = set(unresolved_names)
62
+ planned: list[tuple[_ParamDynamic, list[str] | None]] = []
63
+ for pd in self.out.param_dynamics:
64
+ if pd.self_class:
65
+ values = self._attribute_strings(
66
+ pd.self_class, pd.param, writes, unresolved_names
67
+ )
68
+ else:
69
+ values = self._param_values(pd.function, pd.param, unresolved_names)
70
+ planned.append((pd, values))
71
+ for pd, values in planned:
72
+ if values is None or pd.kind != "getattr":
73
+ continue
74
+ for name in dict.fromkeys(values):
75
+ if pd.base is None:
76
+ unresolved_names.add(name)
77
+ continue
78
+ node, rest = self.resolve_chain_names(pd.base + [name], pd.scope)
79
+ if isinstance(node, Resolved) and not node.detail:
80
+ self.escape(node)
81
+ elif isinstance(node, Unresolved) and node.name:
82
+ unresolved_names.add(node.name)
83
+ unresolved_names.update(rest)
84
+ if self.out.escapes == escapes and unresolved_names == names:
85
+ break
86
+ for pd, values in planned:
87
+ if pd.kind == "import" and self._import_per_caller(pd):
88
+ continue
89
+ if values is None:
90
+ # The name is unbounded. If the *receiver* is one the call
91
+ # sites name, the read is still bounded: it can only be an
92
+ # attribute of those classes, so depend on their members
93
+ # rather than on everything (see _receiver_classes).
94
+ classes = self._receiver_classes(pd, writes) if pd.kind == "getattr" else None
95
+ if classes:
96
+ for member in sorted(self._class_members(classes)):
97
+ self.out.edges.add(
98
+ Edge(pd.function, member, REFERENCES, "attribute read dynamically")
99
+ )
100
+ continue
101
+ self.out.unresolved.add(
102
+ UnresolvedReference(pd.function, UNRESOLVED_DYNAMIC, "", pd.detail)
103
+ )
104
+ continue
105
+ for name in dict.fromkeys(values):
106
+ if pd.kind == "import":
107
+ if name.startswith("."):
108
+ self.out.unresolved.add(
109
+ UnresolvedReference(pd.function, UNRESOLVED_DYNAMIC, "", pd.detail)
110
+ )
111
+ else:
112
+ self._module_import_edge(pd.function, name)
113
+ elif pd.base is None:
114
+ self.out.unresolved.add(
115
+ UnresolvedReference(
116
+ pd.function, UNRESOLVED_ATTRIBUTE, name, f"getattr(..., {name!r})"
117
+ )
118
+ )
119
+ else:
120
+ chain = ".".join(pd.base + [name])
121
+ node, rest = self.resolve_chain_names(pd.base + [name], pd.scope)
122
+ self._record(pd.function, node, chain=chain)
123
+ for extra in rest:
124
+ self.out.unresolved.add(
125
+ UnresolvedReference(pd.function, UNRESOLVED_ATTRIBUTE, extra, chain)
126
+ )
127
+ for ref in self.out.attr_refs:
128
+ bound = self._attribute_writes(ref.cls, ref.attr, writes, unresolved_names)
129
+ if bound is None or any(binding[0] != "symbol" for _, binding in bound):
130
+ continue
131
+ for _, binding in bound:
132
+ node: Node = Resolved(binding[1])
133
+ for attr in ref.rest:
134
+ node = self._step(node, attr)
135
+ if isinstance(node, Resolved) and not ref.rest and node.symbol != ref.source:
136
+ self.out.edges.add(
137
+ Edge(ref.source, node.symbol, REFERENCES, f"self.{ref.attr}")
138
+ )
139
+ else:
140
+ self._record(ref.source, node, chain=ref.chain)
141
+
142
+ def _import_per_caller(self, pd: _ParamDynamic) -> bool:
143
+ """``import_optional_dependency(name)``: resolve the parameter per call
144
+ site rather than once for the function.
145
+
146
+ A caller that passes a literal can only cause an import of *that*
147
+ module, so the edge belongs to it; one that passes something unbounded
148
+ keeps the dynamic reference, and only what reaches that caller is
149
+ selected conservatively. Attributing the import to the caller rather
150
+ than to the helper that runs it is deliberate: a target reaching the
151
+ caller reaches the import, and the helper's other callers did not name
152
+ that module. Returns False when the callers are not known, which
153
+ leaves the all-or-nothing treatment in place.
154
+ """
155
+ attributed = self._attributed_imports(pd.function, pd.param, set())
156
+ if attributed is None:
157
+ return False
158
+ for caller, names in attributed:
159
+ if names is None:
160
+ self.out.unresolved.add(
161
+ UnresolvedReference(caller, UNRESOLVED_DYNAMIC, "", pd.detail)
162
+ )
163
+ continue
164
+ for name in dict.fromkeys(names):
165
+ self._module_import_edge(caller, name)
166
+ return True
167
+
168
+ def _attributed_imports(
169
+ self, function: str, param: str, seen: set[tuple[str, str]], depth: int = 0
170
+ ) -> list[tuple[str, tuple[str, ...] | None]] | None:
171
+ """Per call site of ``function``, who imports what through ``param``.
172
+
173
+ A site that passes its own parameter answers one level further out --
174
+ pandas' ``skip_if_no(name)`` hands its parameter to the importer, and
175
+ its own callers name the module -- so the search follows it, with a
176
+ depth cap and a guard against a cycle. None when the callers cannot
177
+ be known at all."""
178
+ if (function, param) in seen or depth > IMPORT_ATTRIBUTION_DEPTH:
179
+ return None
180
+ seen.add((function, param))
181
+ info = self.out.func_params.get(function)
182
+ symbol = self.index.symbols.get(function)
183
+ sites = self.out.call_sites.get(function, [])
184
+ if info is None or symbol is None or not sites:
185
+ return None
186
+ if function in self.out.escapes or self._super_may_reach(symbol):
187
+ return None # it may be called from somewhere unseen
188
+ if not all(site.caller for site in sites):
189
+ return None # an older cache entry, without the caller recorded
190
+ found: list[tuple[str, tuple[str, ...] | None]] = []
191
+ for site in sites:
192
+ names = site.value_for(param, info)
193
+ if names is not None and not any(name.startswith(".") for name in names):
194
+ found.append((site.caller, tuple(names)))
195
+ continue
196
+ outer = site.param_for(param, info) if names is None else None
197
+ deeper = (
198
+ self._attributed_imports(site.caller, outer, seen, depth + 1)
199
+ if outer is not None
200
+ else None
201
+ )
202
+ found.extend(deeper if deeper is not None else [(site.caller, None)])
203
+ return found
204
+
205
+ def _class_members(self, classes: set[str]) -> set[str]:
206
+ """Every symbol inside those classes and their in-scope subclasses: an
207
+ instance of one may be an instance of the other."""
208
+ family = set(classes)
209
+ for cls in classes:
210
+ family.update(self._descendants.get(cls, ()))
211
+ return {
212
+ symbol
213
+ for symbol in self.index.symbols
214
+ for cls in family
215
+ if symbol.startswith(cls + ".")
216
+ }
217
+
218
+ def _receiver_classes(
219
+ self, pd: _ParamDynamic, writes: dict[tuple[str, str], list[_AttrWrite]]
220
+ ) -> set[str] | None:
221
+ """The classes the receiver of ``getattr(receiver, <unbounded>)`` may
222
+ be an instance of, or None when nothing says.
223
+
224
+ Two shapes carry the answer. A receiver that is a parameter is
225
+ whatever the call sites pass (``invoke(Provider(), name)``). A
226
+ receiver that is ``self.<attr>`` is what ``__init__`` bound it to,
227
+ which is usually a parameter of its own, so the constructions answer
228
+ instead (structlog's ``getattr(self._logger, method_name)``)."""
229
+ if pd.base is None or pd.self_class:
230
+ return None
231
+ info = self.out.func_params.get(pd.function)
232
+ if info is None:
233
+ return None
234
+ if len(pd.base) == 1 and pd.base[0] in info.positional:
235
+ return self._passed_classes(pd.function, pd.base[0])
236
+ if (
237
+ len(pd.base) == 2
238
+ and pd.scope.self_class
239
+ and pd.base[0] == pd.scope.self_name
240
+ and not pd.scope.self_is_class
241
+ ):
242
+ return self._attribute_classes(pd.scope.self_class, pd.base[1], writes)
243
+ return None
244
+
245
+ def _passed_classes(self, function: str, param: str) -> set[str] | None:
246
+ """The classes every resolved call site passes for ``param``; None
247
+ when the function may be called from somewhere unseen or a site says
248
+ nothing about what it passes."""
249
+ info = self.out.func_params.get(function)
250
+ symbol = self.index.symbols.get(function)
251
+ sites = self.out.call_sites.get(function, [])
252
+ if (
253
+ info is None
254
+ or symbol is None
255
+ or not sites
256
+ or function in self.out.escapes
257
+ or self._super_may_reach(symbol)
258
+ ):
259
+ return None
260
+ found: set[str] = set()
261
+ for site in sites:
262
+ cls = site.class_for(param, info)
263
+ if cls is None:
264
+ return None
265
+ found.add(cls)
266
+ return found or None
267
+
268
+ def _attribute_classes(
269
+ self, class_id: str, attr: str, writes: dict[tuple[str, str], list[_AttrWrite]]
270
+ ) -> set[str] | None:
271
+ """What ``self.<attr>`` holds, as classes: every write must assign a
272
+ class, or a parameter whose constructions all pass one.
273
+
274
+ The guards are this rule's own, not ``_attribute_writes``'s. Both
275
+ refuse when the attribute is written through a receiver whose type is
276
+ unknown, is a class-level name, or the class customises attribute
277
+ access. This one does not refuse merely because the class escapes:
278
+ an unseen subclass lives in code outside the source roots, and what
279
+ such code puts in the attribute comes from there too. A construction
280
+ we *can* see whose argument says nothing still gives up (below), which
281
+ is the case that matters -- a factory inside the project."""
282
+ unbound = self.out.attr_unbound
283
+ if ("", "*") in unbound or ("", attr) in unbound:
284
+ return None
285
+ family: set[str] = set()
286
+ for cid in (class_id, *self._descendants.get(class_id, ())):
287
+ family.update(self._mro(cid))
288
+ bound: list[tuple[_AttrWrite, list[Any]]] = []
289
+ for cid in sorted(family):
290
+ cscope = self.class_scopes.get(cid)
291
+ if cscope is None or cscope.opaque or (cid, "*") in unbound or (cid, attr) in unbound:
292
+ return None
293
+ if attr in cscope.members or attr in cscope.bindings:
294
+ return None
295
+ if _ATTRIBUTE_HOOKS & cscope.members.keys():
296
+ return None
297
+ for w in writes.get((cid, attr), ()):
298
+ if w.binding is None:
299
+ return None
300
+ bound.append((w, w.binding))
301
+ if not bound:
302
+ return None
303
+ found: set[str] = set()
304
+ for w, (kind, value) in bound:
305
+ if kind == "symbol":
306
+ symbol = self.index.symbols.get(value)
307
+ if symbol is None or symbol.kind != CLASS:
308
+ return None
309
+ found.add(value)
310
+ elif kind == "param":
311
+ passed = self._passed_classes(w.method, value)
312
+ if passed is None:
313
+ return None
314
+ found |= passed
315
+ else:
316
+ return None
317
+ return found or None
318
+
319
+ def _param_values(
320
+ self, function: str, param: str, unresolved_names: set[str]
321
+ ) -> list[str] | None:
322
+ """The literal strings every call site of ``function`` passes for
323
+ ``param``, or None when some caller may be unseen or unbounded."""
324
+ info = self.out.func_params.get(function)
325
+ symbol = self.index.symbols.get(function)
326
+ sites = self.out.call_sites.get(function, [])
327
+ if (
328
+ info is None
329
+ or symbol is None
330
+ or function in self.out.escapes
331
+ or symbol.name in unresolved_names
332
+ or self._super_may_reach(symbol)
333
+ or not sites
334
+ or self._constructor_escapes(symbol, unresolved_names)
335
+ ):
336
+ return None
337
+ values: list[str] = []
338
+ for site in sites:
339
+ found = site.value_for(param, info)
340
+ if found is None:
341
+ return None
342
+ values.extend(found)
343
+ return values
344
+
345
+ def _attribute_writes(
346
+ self,
347
+ class_id: str,
348
+ attr: str,
349
+ writes: dict[tuple[str, str], list[_AttrWrite]],
350
+ unresolved_names: set[str],
351
+ ) -> list[tuple[_AttrWrite, list[Any]]] | None:
352
+ """What ``self.<attr>`` may hold in a method of ``class_id``: its
353
+ bound writes with their bindings, or None when it cannot be bounded. The
354
+ instance may belong to any in-scope subclass, so every class in the
355
+ MRO of the class or of a subclass counts; each must be plain and
356
+ fully in scope, none may define the attribute at class level or
357
+ customise attribute access, and every write must be a bounded
358
+ ``__init__`` assignment. A class that escapes, or whose name occurs
359
+ as an unresolved reference, may have subclasses the index cannot
360
+ see (``class S(Base)`` with ``Base = Foo if X else Bar``), whose
361
+ writes are unknown."""
362
+ unbound = self.out.attr_unbound
363
+ if ("", "*") in unbound or ("", attr) in unbound:
364
+ return None
365
+ family: set[str] = set()
366
+ for cid in (class_id, *self._descendants.get(class_id, ())):
367
+ family.update(self._mro(cid))
368
+ bound: list[tuple[_AttrWrite, list[Any]]] = []
369
+ for cid in sorted(family):
370
+ cscope = self.class_scopes[cid]
371
+ if not cscope.plain or cscope.opaque or (cid, "*") in unbound or (cid, attr) in unbound:
372
+ return None
373
+ if cid in self.out.escapes or self.index.symbols[cid].name in unresolved_names:
374
+ return None
375
+ if attr in cscope.members or attr in cscope.bindings:
376
+ return None
377
+ if _ATTRIBUTE_HOOKS & cscope.members.keys():
378
+ return None
379
+ for w in writes.get((cid, attr), ()):
380
+ if w.binding is None:
381
+ return None
382
+ bound.append((w, w.binding))
383
+ return bound or None
384
+
385
+ def _attribute_strings(
386
+ self,
387
+ class_id: str,
388
+ attr: str,
389
+ writes: dict[tuple[str, str], list[_AttrWrite]],
390
+ unresolved_names: set[str],
391
+ ) -> list[str] | None:
392
+ bound = self._attribute_writes(class_id, attr, writes, unresolved_names)
393
+ if bound is None:
394
+ return None
395
+ values: list[str] = []
396
+ for w, (kind, value) in bound:
397
+ if kind == "strings":
398
+ values.extend(value)
399
+ elif kind == "param":
400
+ found = self._param_values(w.method, value, unresolved_names)
401
+ if found is None:
402
+ return None
403
+ values.extend(found)
404
+ else:
405
+ return None
406
+ return values